diff --git a/docs/supported_inventory_types.md b/docs/supported_inventory_types.md index 80873a7dc..7d229b0cc 100644 --- a/docs/supported_inventory_types.md +++ b/docs/supported_inventory_types.md @@ -115,7 +115,7 @@ See the docs on [how to add a new Extractor](/docs/new_extractor.md). | Azure Token | `secrets/azuretoken` | | DigitalOcean API key | `secrets/digitaloceanapikey` | | Docker hub PAT | `secrets/dockerhubpat` | -| GCP API key | `secrets/gcpapikey` | +| GCP API key | `secrets/gcpapikey` or `secrets/gcpapikeystrict`| | GCP Express Mode API key | `secrets/gcpexpressmode` | | GCP service account key | `secrets/gcpsak` | | GCP OAuth 2 Access Tokens | `secrets/gcpoauth2access` | diff --git a/extractor/filesystem/list/list.go b/extractor/filesystem/list/list.go index 6c71023b6..cd7719ec2 100644 --- a/extractor/filesystem/list/list.go +++ b/extractor/filesystem/list/list.go @@ -270,6 +270,7 @@ var ( {slacktoken.NewAppLevelTokenDetector(), "secrets/slackappleveltoken", 0}, {dockerhubpat.NewDetector(), "secrets/dockerhubpat", 0}, {gcpapikey.NewDetector(), "secrets/gcpapikey", 0}, + {gcpapikey.NewStrictDetector(), "secrets/gcpapikeystrict", 0}, {gcpexpressmode.NewDetector(), "secrets/gcpexpressmode", 0}, {gcpsak.NewDetector(), "secrets/gcpsak", 0}, {gitlabpat.NewDetector(), "secrets/gitlabpat", 0}, diff --git a/veles/secrets/gcpapikey/strictdetector.go b/veles/secrets/gcpapikey/strictdetector.go new file mode 100644 index 000000000..f0ec3fd37 --- /dev/null +++ b/veles/secrets/gcpapikey/strictdetector.go @@ -0,0 +1,55 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gcpapikey + +import ( + "regexp" + + "github.com/google/osv-scalibr/veles" +) + +// maxTokenLength is the maximum size of a GPC API key. Adding a buffer to the actual maximum length of 40 characters to account for potential prefixes/suffixes. +const maxTokenLengthStrict = 40 + +// strictRe is a regular expression that matches a GCP API key with boundary checks. +var strictRe = regexp.MustCompile(`\b(AIza[a-zA-Z0-9_-]{35})(?:[^a-zA-Z0-9_-]|$)`) + +// strictDetector is a Veles Detector. +type strictDetector struct{} + +// NewStrictDetector returns a new Detector that matches GCP API keys with +// boundary checks. +func NewStrictDetector() veles.Detector { + return &strictDetector{} +} + +func (d *strictDetector) MaxSecretLen() uint32 { + return maxTokenLength +} + +func (d *strictDetector) Detect(content []byte) ([]veles.Secret, []int) { + var secrets []veles.Secret + var positions []int + for _, m := range strictRe.FindAllSubmatchIndex(content, -1) { + if len(m) != 4 { + continue + } + l, r := m[2], m[3] + key := string(content[l:r]) + secrets = append(secrets, GCPAPIKey{Key: key}) + positions = append(positions, l) + } + return secrets, positions +} diff --git a/veles/secrets/gcpapikey/strictdetector_test.go b/veles/secrets/gcpapikey/strictdetector_test.go new file mode 100644 index 000000000..b0fe7dac2 --- /dev/null +++ b/veles/secrets/gcpapikey/strictdetector_test.go @@ -0,0 +1,168 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gcpapikey_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/osv-scalibr/veles" + "github.com/google/osv-scalibr/veles/secrets/gcpapikey" +) + +const ( + testKeyDash = `AIzatestestestestestestestestestesttes-` +) + +// TestStrictDetector_truePositives tests for cases where we know the Detector +// will find a GCP API key/s. +func TestStrictDetector_truePositives(t *testing.T) { + engine, err := veles.NewDetectionEngine([]veles.Detector{gcpapikey.NewStrictDetector()}) + if err != nil { + t.Fatal(err) + } + cases := []struct { + name string + input string + want []veles.Secret + }{{ + name: "simple matching string", + input: testKey, + want: []veles.Secret{ + gcpapikey.GCPAPIKey{Key: testKey}, + }, + }, { + name: "match at end of string", + input: `API_KEY=` + testKey, + want: []veles.Secret{ + gcpapikey.GCPAPIKey{Key: testKey}, + }, + }, { + name: "match in middle of string", + input: `API_KEY="` + testKey + `"`, + want: []veles.Secret{ + gcpapikey.GCPAPIKey{Key: testKey}, + }, + }, { + name: "matching string with mixed case", + input: testKeyMixedCase, + want: []veles.Secret{ + gcpapikey.GCPAPIKey{Key: testKeyMixedCase}, + }, + }, { + name: "multiple matches", + input: testKey + "&" + testKey + ";" + testKey, + want: []veles.Secret{ + gcpapikey.GCPAPIKey{Key: testKey}, + gcpapikey.GCPAPIKey{Key: testKey}, + gcpapikey.GCPAPIKey{Key: testKey}, + }, + }, { + name: "multiple distinct matches", + input: testKey + "\n" + testKey[:len(testKey)-1] + "1\n", + want: []veles.Secret{ + gcpapikey.GCPAPIKey{Key: testKey}, + gcpapikey.GCPAPIKey{Key: testKey[:len(testKey)-1] + "1"}, + }, + }, { + name: "larger input containing key", + input: fmt.Sprintf(` +CONFIG_FILE=config.txt +API_KEY=%s +CLOUD_PROJECT=my-project + `, testKey), + want: []veles.Secret{ + gcpapikey.GCPAPIKey{Key: testKey}, + }, + }, { + name: "potential match longer than max key length", + input: testKey + " test", + want: []veles.Secret{ + gcpapikey.GCPAPIKey{Key: testKey}, + }, + }, { + name: "matching key with dash at the end", + input: testKeyDash, + want: []veles.Secret{ + gcpapikey.GCPAPIKey{Key: testKeyDash}, + }, + }} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := engine.Detect(t.Context(), strings.NewReader(tc.input)) + if err != nil { + t.Errorf("Detect() error: %v, want nil", err) + } + if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("Detect() diff (-want +got):\n%s", diff) + } + }) + } +} + +// TestStrictDetector_trueNegatives tests for cases where we know the Detector +// will not find a GCP API key. +func TestStrictDetector_trueNegatives(t *testing.T) { + engine, err := veles.NewDetectionEngine([]veles.Detector{gcpapikey.NewStrictDetector()}) + if err != nil { + t.Fatal(err) + } + cases := []struct { + name string + input string + want []veles.Secret + }{{ + name: "empty input", + input: "", + }, { + name: "short key should not match", + input: testKey[:len(testKey)-1], + }, { + name: "incorrect casing of prefix should not match", + input: `aizatestestestestestestestestestesttest`, + }, { + name: "special character in key should not match", + input: `AIzatestestestestestestestestestesttes.`, + }, { + name: "special character in prefix should not match", + input: `AI.zatestestestestestestestestestesttes`, + }, { + name: "special character after prefix should not match", + input: `AIza.testestestestestestestestestesttes`, + }, { + name: "overlapping matches are not supported", + input: `AIza` + testKey, + }, { + name: "prefix AIza in the middle of the string should not match", + input: `abcAIzatestestestestestestestestestesttest`, + }, { + name: "key with additional characters at the end should not match", + input: `AIzatestestestestestestestestestesttestabc`, + }} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := engine.Detect(t.Context(), strings.NewReader(tc.input)) + if err != nil { + t.Errorf("Detect() error: %v, want nil", err) + } + if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("Detect() diff (-want +got):\n%s", diff) + } + }) + } +}