* Add Pinecone API key detector
Adds a new detector for Pinecone vector database API keys (pcsk_* format).
Detection:
- Regex matches the pcsk_{key_id}_{secret} structure with tight bounds
(4+ char key ID, 40+ char secret, word boundaries) to minimize false positives.
- Extracts the embedded key_id from the token unconditionally, which maps to
the key entry in the Pinecone console and aids revocation.
Verification:
- Uses GET /indexes (non-state-changing, read-only) with Api-Key header auth.
- Validates response body structure (requires "indexes" JSON key), not just
status codes, to be resilient against API changes.
- 200: Verified. Extracts project_id from index host, total_indexes, and
metadata for up to 5 indexes (name, host, cloud, region).
- 401: Invalid key (handles both plain text and JSON error bodies).
- 403: Valid key with restricted permissions (DataPlane-only roles).
Marked as verified with permission=restricted metadata.
Uses common.SaneHttpClient() with standard timeouts (5s response, 2s dial,
3s TLS) and io.LimitReader (1MB cap) on response body. No SDK dependencies.
Registered as DetectorType Pinecone = 1048.
Made-with: Cursor
* Align Pinecone detector with SecretParts
Migrate Pinecone verification metadata to SecretParts, address the redundant 200-response JSON parsing Bugbot flagged, and add focused regression coverage for malformed verification responses.
* Refactor Pinecone detector and add tests
---------
Co-authored-by: Dylan Ayrey <[email protected]>
Co-authored-by: Dustin Decker <[email protected]>
Co-authored-by: Shahzad Haider <[email protected]>
Co-authored-by: Shahzad Haider <[email protected]>
305 lines
8.5 KiB
Go
305 lines
8.5 KiB
Go
package pinecone
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
|
|
)
|
|
|
|
var (
|
|
// 6-char label, 63-char secret (75 chars total)
|
|
validKeyLong = "pcsk_T5Afk6_5qU9s3iLVFmaSaJtMat7gTHaT9fXa7ykiBk7iz4uUMuLGLemkdutTgwJevYhqtn"
|
|
// 5-char label, 63-char secret (74 chars total)
|
|
validKeyShort = "pcsk_wtQV4_J9qqVGjiMW81LJ9H59iajZMWMedenyLnGVR3vbCWq6V3oaEvcQYwPFQpupFUth1"
|
|
invalidKeyNoID = "pcsk__5qU9s3iLVFmaSaJtMat7gTHaT9fXa7ykiBk7iz4uUMuLGLemkdutTgwJevYhqtn"
|
|
invalidPrefix = "pineconeT5Afk6_5qU9s3iLVFmaSaJtMat7gTHaT9fXa7ykiBk7iz4uUMuLGLemkdutTgwJevYhqtn"
|
|
keyword = "pinecone"
|
|
)
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
func TestPinecone_Pattern(t *testing.T) {
|
|
d := Scanner{}
|
|
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "valid pattern - env var assignment",
|
|
input: keyword + "_API_KEY=" + validKeyLong,
|
|
want: []string{validKeyLong},
|
|
},
|
|
{
|
|
name: "valid pattern - short label",
|
|
input: "export PINECONE_API_KEY=" + validKeyShort,
|
|
want: []string{validKeyShort},
|
|
},
|
|
{
|
|
name: "valid pattern - config file",
|
|
input: `
|
|
pinecone:
|
|
api_key: "` + validKeyLong + `"
|
|
environment: us-east-1-aws
|
|
`,
|
|
want: []string{validKeyLong},
|
|
},
|
|
{
|
|
name: "valid pattern - multiple distinct keys",
|
|
input: "primary=" + validKeyLong + " secondary=" + validKeyShort,
|
|
want: []string{validKeyLong, validKeyShort},
|
|
},
|
|
{
|
|
name: "invalid pattern - missing key_id",
|
|
input: invalidKeyNoID,
|
|
want: []string{},
|
|
},
|
|
{
|
|
name: "invalid pattern - wrong prefix",
|
|
input: invalidPrefix,
|
|
want: []string{},
|
|
},
|
|
{
|
|
name: "invalid pattern - secret too short",
|
|
input: "pcsk_abcd1_tooShort",
|
|
want: []string{},
|
|
},
|
|
{
|
|
name: "invalid pattern - label too short (4 chars)",
|
|
input: "pcsk_abcd_5qU9s3iLVFmaSaJtMat7gTHaT9fXa7ykiBk7iz4uUMuLGLemkdutTgwJevYhqtn",
|
|
want: []string{},
|
|
},
|
|
{
|
|
name: "invalid pattern - secret has trailing alphanumeric",
|
|
input: validKeyLong + "EXTRA",
|
|
want: []string{},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
|
|
if len(matchedDetectors) == 0 && len(test.want) > 0 {
|
|
t.Errorf("keywords '%v' not matched by: %s", d.Keywords(), test.input)
|
|
return
|
|
}
|
|
|
|
results, err := d.FromData(context.Background(), false, []byte(test.input))
|
|
if err != nil {
|
|
t.Errorf("error = %v", err)
|
|
return
|
|
}
|
|
|
|
if len(results) != len(test.want) {
|
|
t.Errorf("expected %d results, got %d", len(test.want), len(results))
|
|
for _, r := range results {
|
|
t.Logf("got: %s", string(r.Raw))
|
|
}
|
|
return
|
|
}
|
|
|
|
actual := make(map[string]struct{}, len(results))
|
|
for _, r := range results {
|
|
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 TestPinecone_ExtractProjectID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
host string
|
|
want string
|
|
}{
|
|
{
|
|
name: "serverless host",
|
|
host: "my-index-abc1234.svc.us-east1-aws.pinecone.io",
|
|
want: "abc1234",
|
|
},
|
|
{
|
|
name: "hyphenated index name",
|
|
host: "my-cool-index-xyz9876.svc.us-west-2.pinecone.io",
|
|
want: "xyz9876",
|
|
},
|
|
{
|
|
name: "missing .svc. segment",
|
|
host: "my-index.pinecone.io",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "missing hyphen before .svc.",
|
|
host: "abc.svc.us-east1.pinecone.io",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty host",
|
|
host: "",
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := extractProjectID(tt.host)
|
|
if got != tt.want {
|
|
t.Errorf("extractProjectID(%q) = %q, want %q", tt.host, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPinecone_VerifyMatchSuccess(t *testing.T) {
|
|
scanner := Scanner{client: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.Method != http.MethodGet {
|
|
t.Fatalf("expected GET request, got %s", req.Method)
|
|
}
|
|
if req.URL.String() != "https://api.pinecone.io/indexes" {
|
|
t.Fatalf("unexpected URL %s", req.URL.String())
|
|
}
|
|
if got := req.Header.Get("Api-Key"); got != validKeyLong {
|
|
t.Fatalf("unexpected api key header %q", got)
|
|
}
|
|
if got := req.Header.Get("X-Pinecone-Api-Version"); got != "2025-10" {
|
|
t.Fatalf("unexpected api version header %q", got)
|
|
}
|
|
|
|
body := `{"indexes":[{"name":"example-index","host":"example-index-abc1234.svc.us-east1-aws.pinecone.io","spec":{"serverless":{"cloud":"aws","region":"us-east-1"}}}]}`
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
Header: make(http.Header),
|
|
Request: req,
|
|
}, nil
|
|
}),
|
|
}}
|
|
|
|
verified, extraData, err := scanner.verifyMatch(context.Background(), scanner.client, validKeyLong)
|
|
if err != nil {
|
|
t.Fatalf("verifyMatch returned error: %v", err)
|
|
}
|
|
if !verified {
|
|
t.Fatal("expected token to verify successfully")
|
|
}
|
|
if extraData["total_indexes"] != "1" {
|
|
t.Fatalf("expected total_indexes=1, got %q", extraData["total_indexes"])
|
|
}
|
|
if extraData["project_id"] != "abc1234" {
|
|
t.Fatalf("expected project_id=abc1234, got %q", extraData["project_id"])
|
|
}
|
|
if extraData["index_0_name"] != "example-index" {
|
|
t.Fatalf("expected index_0_name to be populated, got %q", extraData["index_0_name"])
|
|
}
|
|
}
|
|
|
|
func TestPinecone_VerifyMatchRejectsMissingIndexesKey(t *testing.T) {
|
|
scanner := Scanner{client: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"projects":[]}`)),
|
|
Header: make(http.Header),
|
|
Request: req,
|
|
}, nil
|
|
}),
|
|
}}
|
|
|
|
verified, extraData, err := scanner.verifyMatch(context.Background(), scanner.client, validKeyLong)
|
|
if err == nil {
|
|
t.Fatal("expected an error for malformed 200 response")
|
|
}
|
|
if !strings.Contains(err.Error(), "unexpected response body structure") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if verified {
|
|
t.Fatal("expected malformed 200 response to remain unverified")
|
|
}
|
|
if extraData != nil {
|
|
t.Fatalf("expected no extra data, got %#v", extraData)
|
|
}
|
|
}
|
|
|
|
func TestPinecone_VerifyMatchRejectsInvalidIndexesPayload(t *testing.T) {
|
|
scanner := Scanner{client: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"indexes":{}}`)),
|
|
Header: make(http.Header),
|
|
Request: req,
|
|
}, nil
|
|
}),
|
|
}}
|
|
|
|
verified, extraData, err := scanner.verifyMatch(context.Background(), scanner.client, validKeyLong)
|
|
if err == nil {
|
|
t.Fatal("expected a decode error for invalid indexes payload")
|
|
}
|
|
if !strings.Contains(err.Error(), "failed to decode 200 response") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if verified {
|
|
t.Fatal("expected invalid indexes payload to remain unverified")
|
|
}
|
|
if extraData != nil {
|
|
t.Fatalf("expected no extra data, got %#v", extraData)
|
|
}
|
|
}
|
|
|
|
func TestPinecone_FromDataPreservesMetadataOnVerificationError(t *testing.T) {
|
|
scanner := Scanner{
|
|
client: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(`{"indexes":{}}`)),
|
|
Header: make(http.Header),
|
|
Request: req,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
results, err := scanner.FromData(context.Background(), true, []byte(validKeyLong))
|
|
if err != nil {
|
|
t.Fatalf("FromData returned error: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected one result, got %d", len(results))
|
|
}
|
|
if results[0].Verified {
|
|
t.Fatal("expected malformed verification response to keep result unverified")
|
|
}
|
|
if results[0].VerificationError() == nil {
|
|
t.Fatal("expected malformed verification response to set a verification error")
|
|
}
|
|
if got := results[0].ExtraData["key_id"]; got != "T5Afk6" {
|
|
t.Fatalf("expected key_id=T5Afk6, got %q", got)
|
|
}
|
|
if got := results[0].SecretParts["key"]; got != validKeyLong {
|
|
t.Fatalf("expected secret key part to be preserved, got %q", got)
|
|
}
|
|
}
|