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
2 changes: 1 addition & 1 deletion docs/supported_inventory_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions extractor/filesystem/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
55 changes: 55 additions & 0 deletions veles/secrets/gcpapikey/strictdetector.go
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 24 in veles/secrets/gcpapikey/strictdetector.go

View workflow job for this annotation

GitHub Actions / lint (macos-latest)

const maxTokenLengthStrict is unused (unused)

Check failure on line 24 in veles/secrets/gcpapikey/strictdetector.go

View workflow job for this annotation

GitHub Actions / lint (ubuntu-latest)

const maxTokenLengthStrict is unused (unused)

Check failure on line 24 in veles/secrets/gcpapikey/strictdetector.go

View workflow job for this annotation

GitHub Actions / lint (windows-latest)

const maxTokenLengthStrict is unused (unused)

// 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
}
168 changes: 168 additions & 0 deletions veles/secrets/gcpapikey/strictdetector_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading