Skip to content

Commit 3d6b2f5

Browse files
SCALIBR Teamcopybara-github
authored andcommitted
Added a new GCP API Key detector with strict regex.
PiperOrigin-RevId: 816742124
1 parent 2753b8b commit 3d6b2f5

File tree

4 files changed

+225
-1
lines changed

4 files changed

+225
-1
lines changed

docs/supported_inventory_types.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ See the docs on [how to add a new Extractor](/docs/new_extractor.md).
115115
| Azure Token | `secrets/azuretoken` |
116116
| DigitalOcean API key | `secrets/digitaloceanapikey` |
117117
| Docker hub PAT | `secrets/dockerhubpat` |
118-
| GCP API key | `secrets/gcpapikey` |
118+
| GCP API key | `secrets/gcpapikey` or `secrets/gcpapikeystrict`|
119119
| GCP Express Mode API key | `secrets/gcpexpressmode` |
120120
| GCP service account key | `secrets/gcpsak` |
121121
| GCP OAuth 2 Access Tokens | `secrets/gcpoauth2access` |

extractor/filesystem/list/list.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ var (
270270
{slacktoken.NewAppLevelTokenDetector(), "secrets/slackappleveltoken", 0},
271271
{dockerhubpat.NewDetector(), "secrets/dockerhubpat", 0},
272272
{gcpapikey.NewDetector(), "secrets/gcpapikey", 0},
273+
{gcpapikey.NewStrictDetector(), "secrets/gcpapikeystrict", 0},
273274
{gcpexpressmode.NewDetector(), "secrets/gcpexpressmode", 0},
274275
{gcpsak.NewDetector(), "secrets/gcpsak", 0},
275276
{gitlabpat.NewDetector(), "secrets/gitlabpat", 0},
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package gcpapikey
16+
17+
import (
18+
"regexp"
19+
20+
"github.com/google/osv-scalibr/veles"
21+
)
22+
23+
// 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.
24+
const maxTokenLengthStrict = 40
25+
26+
// strictRe is a regular expression that matches a GCP API key with boundary checks.
27+
var strictRe = regexp.MustCompile(`\b(AIza[a-zA-Z0-9_-]{35})(?:[^a-zA-Z0-9_-]|$)`)
28+
29+
// strictDetector is a Veles Detector.
30+
type strictDetector struct{}
31+
32+
// NewStrictDetector returns a new Detector that matches GCP API keys with
33+
// boundary checks.
34+
func NewStrictDetector() veles.Detector {
35+
return &strictDetector{}
36+
}
37+
38+
func (d *strictDetector) MaxSecretLen() uint32 {
39+
return maxTokenLength
40+
}
41+
42+
func (d *strictDetector) Detect(content []byte) ([]veles.Secret, []int) {
43+
var secrets []veles.Secret
44+
var positions []int
45+
for _, m := range strictRe.FindAllSubmatchIndex(content, -1) {
46+
if len(m) != 4 {
47+
continue
48+
}
49+
l, r := m[2], m[3]
50+
key := string(content[l:r])
51+
secrets = append(secrets, GCPAPIKey{Key: key})
52+
positions = append(positions, l)
53+
}
54+
return secrets, positions
55+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package gcpapikey_test
16+
17+
import (
18+
"fmt"
19+
"strings"
20+
"testing"
21+
22+
"github.com/google/go-cmp/cmp"
23+
"github.com/google/go-cmp/cmp/cmpopts"
24+
"github.com/google/osv-scalibr/veles"
25+
"github.com/google/osv-scalibr/veles/secrets/gcpapikey"
26+
)
27+
28+
const (
29+
testKeyDash = `AIzatestestestestestestestestestesttes-`
30+
)
31+
32+
// TestStrictDetector_truePositives tests for cases where we know the Detector
33+
// will find a GCP API key/s.
34+
func TestStrictDetector_truePositives(t *testing.T) {
35+
engine, err := veles.NewDetectionEngine([]veles.Detector{gcpapikey.NewStrictDetector()})
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
cases := []struct {
40+
name string
41+
input string
42+
want []veles.Secret
43+
}{{
44+
name: "simple matching string",
45+
input: testKey,
46+
want: []veles.Secret{
47+
gcpapikey.GCPAPIKey{Key: testKey},
48+
},
49+
}, {
50+
name: "match at end of string",
51+
input: `API_KEY=` + testKey,
52+
want: []veles.Secret{
53+
gcpapikey.GCPAPIKey{Key: testKey},
54+
},
55+
}, {
56+
name: "match in middle of string",
57+
input: `API_KEY="` + testKey + `"`,
58+
want: []veles.Secret{
59+
gcpapikey.GCPAPIKey{Key: testKey},
60+
},
61+
}, {
62+
name: "matching string with mixed case",
63+
input: testKeyMixedCase,
64+
want: []veles.Secret{
65+
gcpapikey.GCPAPIKey{Key: testKeyMixedCase},
66+
},
67+
}, {
68+
name: "multiple matches",
69+
input: testKey + "&" + testKey + ";" + testKey,
70+
want: []veles.Secret{
71+
gcpapikey.GCPAPIKey{Key: testKey},
72+
gcpapikey.GCPAPIKey{Key: testKey},
73+
gcpapikey.GCPAPIKey{Key: testKey},
74+
},
75+
}, {
76+
name: "multiple distinct matches",
77+
input: testKey + "\n" + testKey[:len(testKey)-1] + "1\n",
78+
want: []veles.Secret{
79+
gcpapikey.GCPAPIKey{Key: testKey},
80+
gcpapikey.GCPAPIKey{Key: testKey[:len(testKey)-1] + "1"},
81+
},
82+
}, {
83+
name: "larger input containing key",
84+
input: fmt.Sprintf(`
85+
CONFIG_FILE=config.txt
86+
API_KEY=%s
87+
CLOUD_PROJECT=my-project
88+
`, testKey),
89+
want: []veles.Secret{
90+
gcpapikey.GCPAPIKey{Key: testKey},
91+
},
92+
}, {
93+
name: "potential match longer than max key length",
94+
input: testKey + " test",
95+
want: []veles.Secret{
96+
gcpapikey.GCPAPIKey{Key: testKey},
97+
},
98+
}, {
99+
name: "matching key with dash at the end",
100+
input: testKeyDash,
101+
want: []veles.Secret{
102+
gcpapikey.GCPAPIKey{Key: testKeyDash},
103+
},
104+
}}
105+
for _, tc := range cases {
106+
t.Run(tc.name, func(t *testing.T) {
107+
got, err := engine.Detect(t.Context(), strings.NewReader(tc.input))
108+
if err != nil {
109+
t.Errorf("Detect() error: %v, want nil", err)
110+
}
111+
if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" {
112+
t.Errorf("Detect() diff (-want +got):\n%s", diff)
113+
}
114+
})
115+
}
116+
}
117+
118+
// TestStrictDetector_trueNegatives tests for cases where we know the Detector
119+
// will not find a GCP API key.
120+
func TestStrictDetector_trueNegatives(t *testing.T) {
121+
engine, err := veles.NewDetectionEngine([]veles.Detector{gcpapikey.NewStrictDetector()})
122+
if err != nil {
123+
t.Fatal(err)
124+
}
125+
cases := []struct {
126+
name string
127+
input string
128+
want []veles.Secret
129+
}{{
130+
name: "empty input",
131+
input: "",
132+
}, {
133+
name: "short key should not match",
134+
input: testKey[:len(testKey)-1],
135+
}, {
136+
name: "incorrect casing of prefix should not match",
137+
input: `aizatestestestestestestestestestesttest`,
138+
}, {
139+
name: "special character in key should not match",
140+
input: `AIzatestestestestestestestestestesttes.`,
141+
}, {
142+
name: "special character in prefix should not match",
143+
input: `AI.zatestestestestestestestestestesttes`,
144+
}, {
145+
name: "special character after prefix should not match",
146+
input: `AIza.testestestestestestestestestesttes`,
147+
}, {
148+
name: "overlapping matches are not supported",
149+
input: `AIza` + testKey,
150+
}, {
151+
name: "prefix AIza in the middle of the string should not match",
152+
input: `abcAIzatestestestestestestestestestesttest`,
153+
}, {
154+
name: "key with additional characters at the end should not match",
155+
input: `AIzatestestestestestestestestestesttestabc`,
156+
}}
157+
for _, tc := range cases {
158+
t.Run(tc.name, func(t *testing.T) {
159+
got, err := engine.Detect(t.Context(), strings.NewReader(tc.input))
160+
if err != nil {
161+
t.Errorf("Detect() error: %v, want nil", err)
162+
}
163+
if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" {
164+
t.Errorf("Detect() diff (-want +got):\n%s", diff)
165+
}
166+
})
167+
}
168+
}

0 commit comments

Comments
 (0)