Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions pkg/detectors/octopusapikey/octopus_api_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package octopusapikey

import (
"context"
"strings"

regexp "github.com/wasilibs/go-re2"

"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)

type Scanner struct {
detectors.DefaultMultiPartCredentialProvider
}

var _ detectors.Detector = (*Scanner)(nil)
var _ detectors.CustomFalsePositiveChecker = (*Scanner)(nil)

var (
// Keep the provider keyword close to the secret pattern to reduce false positives.
// Octopus Deploy API keys are commonly represented as API- followed by 26 uppercase alphanumerics.
keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"octopus", "x-octopus-apikey"}) + `\b(API-[A-Z0-9]{26})(?:['"|\n\r\s\x60;]|$)`)
)

func (s Scanner) Keywords() []string {
return []string{"octopus", "X-Octopus-ApiKey"}
}

func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_OctopusApiKey
}

func (s Scanner) Description() string {
return "Octopus Deploy API keys authenticate requests to Octopus REST API endpoints."
}

func (s Scanner) FromData(_ context.Context, _ bool, data []byte) ([]detectors.Result, error) {
dataStr := string(data)

uniqueMatches := make(map[string]struct{})
for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) {
if len(match) < 2 {
continue
}
uniqueMatches[strings.TrimSpace(match[1])] = struct{}{}
}

results := make([]detectors.Result, 0, len(uniqueMatches))
for key := range uniqueMatches {
results = append(results, detectors.Result{
DetectorType: detector_typepb.DetectorType_OctopusApiKey,
Raw: []byte(key),
ExtraData: map[string]string{
"rotation_guide": "https://octopus.com/docs/octopus-rest-api/how-to-create-an-api-key",
},
SecretParts: map[string]string{"key": key},
})
}

return results, nil
}

func (s Scanner) IsFalsePositive(result detectors.Result) (bool, string) {
return detectors.IsKnownFalsePositive(string(result.Raw), detectors.DefaultFalsePositives, true)
}
111 changes: 111 additions & 0 deletions pkg/detectors/octopusapikey/octopus_api_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package octopusapikey

import (
"context"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)

func TestOctopusApiKey_Pattern(t *testing.T) {
d := Scanner{}
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})

tests := []struct {
name string
input string
want []string
}{
{
name: "valid pattern - header usage",
input: `X-Octopus-ApiKey: API-ZNRMR7SL6L3ATMOIK7GKJDKLPY`,
want: []string{"API-ZNRMR7SL6L3ATMOIK7GKJDKLPY"},
},
{
name: "valid pattern - env assignment",
input: `OCTOPUS_API_KEY="API-7F1M9T3D5P7Q2W4R6Y8U0I2O4A"`,
want: []string{"API-7F1M9T3D5P7Q2W4R6Y8U0I2O4A"},
},
{
name: "valid pattern - multiple keys",
input: `octopus primary=API-ZNRMR7SL6L3ATMOIK7GKJDKLPY
octopus backup=API-1A2B3C4D5E6F7G8H9I0J1K2L3M`,
want: []string{
"API-ZNRMR7SL6L3ATMOIK7GKJDKLPY",
"API-1A2B3C4D5E6F7G8H9I0J1K2L3M",
},
},
{
name: "deduplication - repeated key",
input: `octopus API-ZNRMR7SL6L3ATMOIK7GKJDKLPY octopus API-ZNRMR7SL6L3ATMOIK7GKJDKLPY`,
want: []string{"API-ZNRMR7SL6L3ATMOIK7GKJDKLPY"},
},
{
name: "invalid pattern - too short",
input: `key = "API-ABC123"`,
want: nil,
},
{
name: "invalid pattern - lowercase characters",
input: `key = "API-ZNRMR7SL6L3ATMOIK7GkJDKLPY"`,
want: nil,
},
{
name: "invalid pattern - wrong prefix",
input: `key = "AP1-ZNRMR7SL6L3ATMOIK7GKJDKLPY"`,
want: nil,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invalid pattern tests lack keyword context, testing wrong thing

Low Severity

The three "invalid pattern" test cases (too short, lowercase characters, wrong prefix) don't contain "octopus" or "x-octopus-apikey" in their input. They pass because the keyword context is missing, not because the format validation rejects them. If someone later accidentally weakens the format regex (e.g., changing [A-Z0-9] to [A-Za-z0-9]), the "lowercase characters" test would still pass since it never actually exercises that validation path. Each invalid test input needs the keyword prefix to properly validate format rejection.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit db92812. Configure here.

}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
if len(test.want) > 0 && len(matchedDetectors) == 0 {
t.Errorf("keywords %v not found in input", d.Keywords())
return
}

results, err := d.FromData(context.Background(), false, []byte(test.input))
require.NoError(t, err)

if len(results) != len(test.want) {
t.Errorf("expected %d results, got %d", len(test.want), len(results))
return
}

actual := make(map[string]struct{}, len(results))
for _, r := range results {
if len(r.RawV2) > 0 {
actual[string(r.RawV2)] = struct{}{}
} else {
actual[string(r.Raw)] = struct{}{}
}
}

expected := make(map[string]struct{}, len(test.want))
for _, v := range test.want {
expected[v] = struct{}{}
}

if diff := cmp.Diff(expected, actual); diff != "" {
t.Errorf("%s diff: (-want +got)\n%s", test.name, diff)
}
})
}
}

func TestOctopusApiKey_Type(t *testing.T) {
s := Scanner{}
require.Equal(t, detector_typepb.DetectorType_OctopusApiKey, s.Type())
}

func TestOctopusApiKey_Keywords(t *testing.T) {
s := Scanner{}
require.NotEmpty(t, s.Keywords())
require.Contains(t, s.Keywords(), "octopus")
require.Contains(t, s.Keywords(), "X-Octopus-ApiKey")
}
2 changes: 2 additions & 0 deletions pkg/engine/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/nvapi"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/nylas"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/oanda"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/octopusapikey"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/okta"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/omnisend"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/onedesk"
Expand Down Expand Up @@ -1413,6 +1414,7 @@ func buildDetectorList() []detectors.Detector {
&nvapi.Scanner{},
&nylas.Scanner{},
&oanda.Scanner{},
&octopusapikey.Scanner{},
&okta.Scanner{},
&omnisend.Scanner{},
&onedesk.Scanner{},
Expand Down
3 changes: 3 additions & 0 deletions pkg/pb/detector_typepb/detector_type.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions proto/detector_type.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1054,4 +1054,5 @@ enum DetectorType {
GitLabOauth2 = 1050;
SpectralOps = 1051;
AWSAppSync = 1052;
OctopusApiKey = 1053;
}