-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathrules.go
More file actions
2060 lines (2012 loc) · 79.9 KB
/
Copy pathrules.go
File metadata and controls
2060 lines (2012 loc) · 79.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// The rules in this file are derived from the MIT-licensed
// github.com/docker/mcp-gateway/pkg/secretsscan package, which itself
// copied the patterns from
// github.com/aquasecurity/trivy/pkg/fanal/secret/builtin-rules.go.
//
// Copyright (c) 2025 Docker (MIT). Re-licensed here under Apache-2.0
// per the license-compatibility allowances of the MIT License.
package portcullis
import (
"fmt"
"regexp"
"strings"
"sync"
)
const (
quote = `["']?`
connect = `\s*(:|=>|=)?\s*`
aws = `aws_?`
)
// rule pairs a regular expression with a keyword shortlist. A rule
// matches when the input contains any of the keywords AND the
// expression matches; the keyword filter is what keeps detection
// fast for typical inputs.
//
// Keywords are matched case-insensitively by default (the AC
// pre-filter bakes ASCII case-folding into its transition table).
// Set caseSensitive when the rule's regex itself is case-sensitive
// AND the keyword carries enough case information that a folded
// match would over-fire — e.g. Discord's two-letter token prefixes
// (`MT`, `ND`, `OD`…) overlap the very common bigrams `mt`, `nd`,
// `od` in plain text. Keeping the keyword case-sensitive cuts the
// AC false-pass rate for those rules from ~30% of files to ~5%.
type rule struct {
expression string
keywords []string
caseSensitive bool
validator func(string) bool
}
// asSecretGroup wraps a `?P<secret>…` fragment in a plain group so
// the named subgroup is syntactically valid. Earlier revisions also
// prepended a `[^0-9a-zA-Z]|^` anchor and appended a
// whitespace/punctuation/end-of-input anchor (collectively a
// "word boundary" requirement) so a rule only fired when the secret
// stood alone in the input. Those anchors caused detection to miss
// secrets embedded directly inside larger tokens — e.g.
// `BEFOREghp_…AFTER`, `KEY=AKIA…`, `…EXAMPLEAFTER` — even though the
// recognisable prefix and exact-length payload were both present.
//
// Detection now ignores the surrounding characters entirely. Each
// rule's payload is tightly constrained (fixed-length character
// classes, explicit token shapes) so removing the boundary check
// does not broaden the regex enough to cause super-linear matching:
// Go's RE2-based engine still scans the input in O(len(text)) per
// rule, and the keyword pre-filter in [Redact] keeps the regex hot
// path off most inputs.
func asSecretGroup(str string) string {
return fmt.Sprintf("(%s)", str)
}
// contextual builds a vendor-anchored rule that matches when a known
// vendor word appears within ~50 chars of an `=` / `:` / `=>` / etc.
// assignment, followed by a value of the given shape. Only the value
// span (the named `?P<secret>` subgroup) is redacted, so log readers
// still see the assignment context (`SNYK_TOKEN=[REDACTED]`).
//
// The frame matches the gitleaks default-rule template: a lazy
// 50-char run of \w/./- before the vendor word, then up to 20 chars
// of additional context (e.g. `_API_KEY`), then any of the standard
// assignment operators, then optionally quotes / whitespace, then
// the value, then a terminator (whitespace, quote, semicolon,
// literal `\n`/`\r`, or end of input). Vendor words are case-folded
// by the leading `(?i)` flag.
//
// Body patterns must use plain (non-named) groups; the helper adds
// the outer `?P<secret>…` wrapper itself.
func contextual(vendor, body string) string {
return `(?i)[\w.-]{0,50}?(?:` + vendor + `)(?:[ \t\w.-]{0,20})[\s'"]{0,3}` +
`(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}` +
`(?P<secret>` + body + `)(?:[\x60'"\s;]|\\[nr]|$)`
}
// rules is the source-form catalogue, kept verbatim from upstream so
// future updates apply cleanly. [compiledRuleSet] resolves it into
// the regex-compiled form actually used at scan time.
//
//nolint:funlen // single-source-of-truth for the ruleset
var rules = sync.OnceValue(func() []rule {
return []rule{
{
// aws-access-key-id. Prefix list mirrors the gitleaks
// default rule, minus the four-letter prefixes (`ABIA`,
// `ACCA`) that proved to be too generic in practice — they
// fire on random base64 runs in minified bundles, certificate
// data, and crypto test vectors. The validator decodes the
// embedded 12-digit AWS account ID for modern access-key IDs,
// rejecting strings whose base32 body cannot encode one.
expression: asSecretGroup(`(?P<secret>(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16})` + quote),
keywords: []string{"AKIA", "AGPA", "AIDA", "AROA", "AIPA", "ANPA", "ANVA", "ASIA", "A3T"},
caseSensitive: true,
validator: validAWSAccessKeyID,
},
{
// aws-secret-access-key
expression: fmt.Sprintf(`(?i)%s%s(sec(ret)?)?_?(access)?_?key%s%s%s(?P<secret>[A-Za-z0-9\/\+=]{40})%s`, quote, aws, quote, connect, quote, quote),
keywords: []string{"key"},
},
{
// github-pat
expression: asSecretGroup(`?P<secret>ghp_[0-9a-zA-Z]{36}`),
keywords: []string{"ghp_"},
validator: validGitHubChecksum,
},
{
// github-oauth
expression: asSecretGroup(`?P<secret>gho_[0-9a-zA-Z]{36}`),
keywords: []string{"gho_"},
validator: validGitHubChecksum,
},
{
// github-app-token
expression: asSecretGroup(`?P<secret>(ghu|ghs)_[0-9a-zA-Z]{36}`),
keywords: []string{"ghu_", "ghs_"},
validator: validGitHubChecksum,
},
{
// github-app-stateless-token. Per GitHub's 2026-05-15 changelog
// (`X-GitHub-Stateless-S2S-Token`), installation access tokens
// are migrating from the legacy opaque `ghs_<36 alnum>` shape to
// a stateless JWT carried under the same `ghs_` prefix. The
// validator decodes the JWT envelope (header / payload / signed)
// so this rule won't fire on arbitrary `ghs_eyXXX.eyYYY.ZZZ`-shaped
// strings that happen to match the regex.
expression: `ghs_ey[A-Za-z0-9_-]{10,}\.ey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`,
keywords: []string{"ghs_ey"},
caseSensitive: true,
validator: validGitHubStatelessToken,
},
{
// github-refresh-token
expression: asSecretGroup(`?P<secret>ghr_[0-9a-zA-Z]{36}`),
keywords: []string{"ghr_"},
validator: validGitHubChecksum,
},
{
// github-fine-grained-pat
expression: asSecretGroup(`?P<secret>github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}`),
keywords: []string{"github_pat_"},
validator: validGitHubChecksum,
},
{
// gitlab-pat
expression: asSecretGroup(`?P<secret>glpat-[0-9a-zA-Z\-\_]{20}`),
keywords: []string{"glpat-"},
},
{
// hugging-face-access-token
expression: asSecretGroup(`?P<secret>hf_[A-Za-z0-9]{34,40}`),
keywords: []string{"hf_"},
},
{
// private-key
expression: `(?i)-----\s*?BEGIN[ A-Z0-9_-]*?PRIVATE KEY( BLOCK)?\s*?-----[\s]*?(?P<secret>[A-Za-z0-9=+/\\\r\n][A-Za-z0-9=+/\\\s]+)[\s]*?-----\s*?END[ A-Z0-9_-]*? PRIVATE KEY( BLOCK)?\s*?-----`,
keywords: []string{"-----"},
},
{
// shopify-token
expression: `shp(ss|at|ca|pa)_[a-fA-F0-9]{32}`,
keywords: []string{"shpss_", "shpat_", "shpca_", "shppa_"},
},
{
// slack-access-token
expression: asSecretGroup(`?P<secret>xox[baprs]-([0-9a-zA-Z]{10,48})`),
keywords: []string{"xoxb-", "xoxa-", "xoxp-", "xoxr-", "xoxs-"},
},
{
// stripe-secret-token (the `prod` env tag was added by
// Stripe in 2024 alongside the legacy `test` / `live`
// modes). The publishable-key counterpart (`pk_*`) is
// intentionally NOT redacted: Stripe publishable keys are
// designed to be embedded in client-side code and are not
// considered secret.
expression: asSecretGroup(`?P<secret>(?i)sk_(test|live|prod)_[0-9a-z]{10,32}`),
keywords: []string{"sk_test_", "sk_live_", "sk_prod_"},
},
{
// heroku-api-key
expression: `(?i)(?P<key>heroku[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})['\"]`,
keywords: []string{"heroku"},
},
{
// slack-web-hook
expression: `https:\/\/hooks.slack.com\/services\/[A-Za-z0-9+\/]{44,48}`,
keywords: []string{"hooks.slack.com"},
},
{
// twilio-api-key
expression: `SK[0-9a-fA-F]{32}`,
keywords: []string{"SK"},
caseSensitive: true,
},
{
// age-secret-key. Age uses Bech32 with a checksum; the
// validator decodes the key and requires the 32-byte X25519
// secret payload so typo-shaped strings do not match.
expression: `AGE-SECRET-KEY-1[QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L]{58}`,
keywords: []string{"AGE-SECRET-KEY-1"},
caseSensitive: true,
validator: validAgeSecretKey,
},
{
// facebook-token
expression: `(?i)(?P<key>facebook[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-f0-9]{32})['\"]`,
keywords: []string{"facebook"},
},
{
// twitter-token
expression: `(?i)(?P<key>twitter[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-f0-9]{35,44})['\"]`,
keywords: []string{"twitter"},
},
{
// adobe-client-id
expression: `(?i)(?P<key>adobe[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-f0-9]{32})['\"]`,
keywords: []string{"adobe"},
},
{
// adobe-client-secret
expression: `(p8e-)(?i)[a-z0-9]{32}`,
keywords: []string{"p8e-"},
},
{
// alibaba-secret-key
expression: `(?i)(?P<key>alibaba[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9]{30})['\"]`,
keywords: []string{"alibaba"},
},
{
// asana-client-id
expression: `(?i)(?P<key>asana[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[0-9]{16})['\"]`,
keywords: []string{"asana"},
},
{
// asana-client-secret
expression: `(?i)(?P<key>asana[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9]{32})['\"]`,
keywords: []string{"asana"},
},
{
// atlassian-api-token
expression: `(?i)(?P<key>atlassian[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9]{24})['\"]`,
keywords: []string{"atlassian"},
},
{
// bitbucket-client-id
expression: `(?i)(?P<key>bitbucket[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9]{32})['\"]`,
keywords: []string{"bitbucket"},
},
{
// bitbucket-client-secret
expression: `(?i)(?P<key>bitbucket[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9_\-]{64})['\"]`,
keywords: []string{"bitbucket"},
},
{
// beamer-api-token
expression: `(?i)(?P<key>beamer[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>b_[a-z0-9=_\-]{44})['\"]`,
keywords: []string{"beamer"},
},
{
// clojars-api-token
expression: `(CLOJARS_)(?i)[a-z0-9]{60}`,
keywords: []string{"CLOJARS_"},
caseSensitive: true,
},
{
// contentful-delivery-api-token
expression: `(?i)(?P<key>contentful[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9\-=_]{43})['\"]`,
keywords: []string{"contentful"},
},
{
// databricks-api-token
expression: `dapi[a-h0-9]{32}`,
keywords: []string{"dapi"},
},
{
// discord-api-token
expression: `(?i)(?P<key>discord[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-h0-9]{64})['\"]`,
keywords: []string{"discord"},
},
{
// discord-client-id
expression: `(?i)(?P<key>discord[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[0-9]{18})['\"]`,
keywords: []string{"discord"},
},
{
// discord-client-secret
expression: `(?i)(?P<key>discord[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9=_\-]{32})['\"]`,
keywords: []string{"discord"},
},
{
// doppler-api-token. Personal tokens are `dp.pt.<43 chars>`;
// the prefix is unique to Doppler so quotes aren't required.
expression: `(dp\.pt\.)(?i)[a-z0-9]{43}`,
keywords: []string{"dp.pt."},
},
{
// dropbox-api-secret
expression: `(?i)(dropbox[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-z0-9]{15})['\"]`,
keywords: []string{"dropbox"},
},
{
// dropbox-short-lived-api-token
expression: `(?i)(dropbox[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](sl\.[a-z0-9\-=_]{135})['\"]`,
keywords: []string{"dropbox"},
},
{
// dropbox-long-lived-api-token
expression: `(?i)(dropbox[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"][a-z0-9]{11}(AAAAAAAAAA)[a-z0-9\-_=]{43}['\"]`,
keywords: []string{"dropbox"},
},
{
// duffel-api-token
expression: `['\"]duffel_(test|live)_(?i)[a-z0-9_-]{43}['\"]`,
keywords: []string{"duffel_test_", "duffel_live_"},
},
{
// dynatrace-api-token. `dt0c01.` is the documented version
// prefix; combined with the fixed 24+64 hex body it doesn't
// need quote anchoring to stay specific.
expression: `dt0c01\.(?i)[a-z0-9]{24}\.[a-z0-9]{64}`,
keywords: []string{"dt0c01."},
},
{
// easypost-api-token. `EZAK` (production) / `EZTK` (test)
// prefixes plus the fixed 54-char body are specific enough
// that surrounding quotes aren't required.
expression: `EZ[AT]K(?i)[a-z0-9]{54}`,
keywords: []string{"EZAK", "EZTK"},
caseSensitive: true,
},
{
// fastly-api-token
expression: `(?i)(?P<key>fastly[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9\-=_]{32})['\"]`,
keywords: []string{"fastly"},
},
{
// finicity-client-secret
expression: `(?i)(?P<key>finicity[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9]{20})['\"]`,
keywords: []string{"finicity"},
},
{
// finicity-api-token
expression: `(?i)(?P<key>finicity[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-f0-9]{32})['\"]`,
keywords: []string{"finicity"},
},
{
// flutterwave-public-key
expression: asSecretGroup(`?P<secret>FLW(PUB|SEC)K_TEST-(?i)[a-h0-9]{32}-X`),
keywords: []string{"FLWSECK_TEST-", "FLWPUBK_TEST-"},
caseSensitive: true,
},
{
// flutterwave-enc-key
expression: asSecretGroup(`?P<secret>FLWSECK_TEST[a-h0-9]{12}`),
keywords: []string{"FLWSECK_TEST"},
caseSensitive: true,
},
{
// frameio-api-token
expression: `fio-u-(?i)[a-z0-9\-_=]{64}`,
keywords: []string{"fio-u-"},
},
{
// gocardless-api-token
expression: `['\"]live_(?i)[a-z0-9\-_=]{40}['\"]`,
keywords: []string{"live_"},
},
{
// grafana-api-token
expression: `['\"]?eyJrIjoi(?i)[a-z0-9\-_=]{72,92}['\"]?`,
keywords: []string{"eyJrIjoi"},
caseSensitive: true,
},
{
// hashicorp-tf-api-token. The `<14 chars>.atlasv1.<body>`
// shape is documented and unique to Terraform Cloud, so the
// quote anchors that the upstream rule used aren't needed.
expression: `(?i)[a-z0-9]{14}\.atlasv1\.[a-z0-9\-_=]{60,70}`,
keywords: []string{"atlasv1."},
},
{
// hubspot-api-token
expression: `(?i)(?P<key>hubspot[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-h0-9]{8}-[a-h0-9]{4}-[a-h0-9]{4}-[a-h0-9]{4}-[a-h0-9]{12})['\"]`,
keywords: []string{"hubspot"},
},
{
// intercom-api-token
expression: `(?i)(?P<key>intercom[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9=_]{60})['\"]`,
keywords: []string{"intercom"},
},
{
// intercom-client-secret
expression: `(?i)(?P<key>intercom[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-h0-9]{8}-[a-h0-9]{4}-[a-h0-9]{4}-[a-h0-9]{4}-[a-h0-9]{12})['\"]`,
keywords: []string{"intercom"},
},
{
// ionic-api-token
expression: `(?i)(ionic[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](ion_[a-z0-9]{42})['\"]`,
keywords: []string{"ionic"},
},
{
// jwt-token. Regex catches compact JWT-looking text; the
// validator then base64url-decodes header and payload, requires
// JSON in both segments, and rejects unsigned `alg=none` JWTs.
expression: asSecretGroup(`?P<secret>ey[a-zA-Z0-9_-]{17,}\.ey[a-zA-Z0-9_-]{17,}\.[a-zA-Z0-9_-]{10,}`),
keywords: []string{".eyJ"},
caseSensitive: true,
validator: validJWT,
},
{
// linear-api-token
expression: `lin_api_(?i)[a-z0-9]{40}`,
keywords: []string{"lin_api_"},
},
{
// linear-client-secret
expression: `(?i)(?P<key>linear[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-f0-9]{32})['\"]`,
keywords: []string{"linear"},
},
{
// lob-api-key
expression: `(?i)(?P<key>lob[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>(live|test)_[a-f0-9]{35})['\"]`,
keywords: []string{"lob"},
},
{
// lob-pub-api-key
expression: `(?i)(?P<key>lob[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>(test|live)_pub_[a-f0-9]{31})['\"]`,
keywords: []string{"lob"},
},
{
// mailchimp-api-key
expression: `(?i)(?P<key>mailchimp[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-f0-9]{32}-us20)['\"]`,
keywords: []string{"mailchimp"},
},
{
// mailgun-token
expression: `(?i)(?P<key>mailgun[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>(pub)?key-[a-f0-9]{32})['\"]`,
keywords: []string{"mailgun"},
},
{
// mailgun-signing-key
expression: `(?i)(?P<key>mailgun[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-h0-9]{32}-[a-h0-9]{8}-[a-h0-9]{8})['\"]`,
keywords: []string{"mailgun"},
},
{
// mapbox-api-token
expression: `(?i)(pk\.[a-z0-9]{60}\.[a-z0-9]{22})`,
keywords: []string{"pk."},
},
{
// messagebird-api-token
expression: `(?i)(?P<key>messagebird[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9]{25})['\"]`,
keywords: []string{"messagebird"},
},
{
// messagebird-client-id
expression: `(?i)(?P<key>messagebird[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-h0-9]{8}-[a-h0-9]{4}-[a-h0-9]{4}-[a-h0-9]{4}-[a-h0-9]{12})['\"]`,
keywords: []string{"messagebird"},
},
{
// new-relic-user-api-key. `NRAK-` is the documented prefix;
// combined with the fixed 27-char body it stays specific
// without the surrounding quotes.
expression: `NRAK-[A-Z0-9]{27}`,
keywords: []string{"NRAK-"},
caseSensitive: true,
},
{
// new-relic-user-api-id
expression: `(?i)(?P<key>newrelic[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[A-Z0-9]{64})['\"]`,
keywords: []string{"newrelic"},
},
{
// new-relic-browser-api-token. `NRJS-` is the documented
// prefix; the fixed 19-char body keeps the rule specific.
expression: `NRJS-[a-f0-9]{19}`,
keywords: []string{"NRJS-"},
caseSensitive: true,
},
{
// npm-access-token. The `npm_` prefix + fixed 36-char body is
// unique enough that we don't anchor on surrounding quotes —
// this catches CLI output (`npm token list`) and `.npmrc`
// shapes (`//registry.npmjs.org/:_authToken=npm_…`) alongside
// the JSON/YAML config form the upstream rule expected.
expression: `npm_(?i)[a-z0-9]{36}`,
keywords: []string{"npm_"},
},
{
// planetscale-password
expression: `pscale_pw_(?i)[a-z0-9\-_\.]{43}`,
keywords: []string{"pscale_pw_"},
},
{
// planetscale-api-token
expression: `pscale_tkn_(?i)[a-z0-9\-_\.]{43}`,
keywords: []string{"pscale_tkn_"},
},
{
// private-packagist-token. Packagist documents three token
// shapes: `packagist_uut_` (user), `packagist_out_` (org),
// and `packagist_ort_` (org read-only). The previous regex
// used `[ou][ru]` which also matched a non-existent
// `packagist_urt_` shape that wasn't in the keyword list,
// creating a regex/keyword mismatch.
expression: `packagist_(uut|out|ort)_(?i)[a-f0-9]{68}`,
keywords: []string{"packagist_uut_", "packagist_out_", "packagist_ort_"},
},
{
// postman-api-token
expression: `PMAK-(?i)[a-f0-9]{24}\-[a-f0-9]{34}`,
keywords: []string{"PMAK-"},
caseSensitive: true,
},
{
// pulumi-api-token
expression: `pul-[a-f0-9]{40}`,
keywords: []string{"pul-"},
},
{
// rubygems-api-token
expression: `rubygems_[a-f0-9]{48}`,
keywords: []string{"rubygems_"},
},
{
// sendgrid-api-token
expression: `SG\.(?i)[a-z0-9_\-\.]{66}`,
keywords: []string{"SG."},
caseSensitive: true,
},
{
// sendinblue-api-token
expression: `xkeysib-[a-f0-9]{64}\-(?i)[a-z0-9]{16}`,
keywords: []string{"xkeysib-"},
},
{
// shippo-api-token
expression: `shippo_(live|test)_[a-f0-9]{40}`,
keywords: []string{"shippo_live_", "shippo_test_"},
},
{
// linkedin-client-secret
expression: `(?i)(?P<key>linkedin[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z]{16})['\"]`,
keywords: []string{"linkedin"},
},
{
// linkedin-client-id
expression: `(?i)(?P<key>linkedin[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9]{14})['\"]`,
keywords: []string{"linkedin"},
},
{
// twitch-api-token
expression: `(?i)(?P<key>twitch[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"](?P<secret>[a-z0-9]{30})['\"]`,
keywords: []string{"twitch"},
},
{
// typeform-api-token
expression: `(?i)(?P<key>typeform[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}(?P<secret>tfp_[a-z0-9\-_\.=]{59})`,
keywords: []string{"typeform"},
},
{
// dockerconfig-secret. Kubernetes docker config secret data
// is base64-encoded Docker config JSON with an `auths` object.
expression: `(?i)(\.(dockerconfigjson|dockercfg):\s*\|*\s*(?P<secret>(ey|ew)+[A-Za-z0-9/+=]+))`,
keywords: []string{"dockerc"},
validator: validDockerConfigSecret,
},
{
// docker-hub-personal-access-token. The `dckr_pat_` prefix
// is followed by a strictly alphanumeric 27-char body —
// per Docker's PAT issuance, no dashes / dots / underscores
// appear in the body, so the char class stays tight.
expression: `dckr_pat_[A-Za-z0-9]{27}`,
keywords: []string{"dckr_pat_"},
},
{
// docker-hub-organization-access-token. Issued from the
// Docker Hub admin console for organization-scoped API
// access; same `<prefix>_<27 alnum>` shape as the PAT but
// with the `dckr_oat_` prefix.
expression: `dckr_oat_[A-Za-z0-9]{27}`,
keywords: []string{"dckr_oat_"},
},
// --- Patterns added on top of the upstream Trivy / mcp-gateway
// catalogue. Each one targets a credential format whose prefix
// is unique enough to keep the keyword pre-filter cheap and the
// regex's false-positive rate low.
{
// openai-api-key. Every modern OpenAI key (project keys
// `sk-proj-…`, service-account keys `sk-svcacct-…`, admin
// keys `sk-admin-…`, and the original `sk-…` keys reissued
// after May 2024) embeds the literal substring "T3BlbkFJ"
// (base64 for "OpenAI") between two long alphanumeric runs.
// That marker keeps both the keyword filter and the regex
// extremely specific.
expression: `sk-[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,}`,
keywords: []string{"T3BlbkFJ"},
caseSensitive: true,
},
{
// anthropic-api-key. Claude keys follow
// `sk-ant-(api|sid|admin)NN-<base64url>` and are ~108 chars
// long; the trailing "AA" is the standard base64 padding.
// `admin01` keys grant org-wide management access (key issuance,
// usage limits) so leakage is at least as serious as a regular
// `api01` key.
expression: `sk-ant-(api|sid|admin)\d{2}-[A-Za-z0-9_-]{93}AA`,
keywords: []string{"sk-ant-"},
},
{
// google-api-key. Used by Maps, Cloud, Firebase, Gemini and
// most other Google REST APIs. The `AIza` prefix is fixed.
expression: `AIza[0-9A-Za-z_-]{35}`,
keywords: []string{"AIza"},
caseSensitive: true,
},
{
// google-oauth-client-secret. Issued in the Google Cloud
// Console for OAuth 2.0 clients; always 35 chars total.
expression: `GOCSPX-[A-Za-z0-9_-]{28}`,
keywords: []string{"GOCSPX-"},
caseSensitive: true,
},
{
// digitalocean-token. v1 personal-access tokens (`dop_v1_`),
// OAuth tokens (`doo_v1_`), and OAuth refresh tokens
// (`dor_v1_`) all share the 71-char total shape: 7-char
// prefix + 64 lowercase hex.
expression: `do[opr]_v1_[a-f0-9]{64}`,
keywords: []string{"dop_v1_", "doo_v1_", "dor_v1_"},
},
{
// stripe-webhook-signing-secret. Used to verify incoming
// webhook payloads; leakage lets attackers forge events.
expression: `whsec_[A-Za-z0-9]{32,}`,
keywords: []string{"whsec_"},
},
{
// jfrog-artifactory-api-key. Distinct from access tokens;
// the `AKCp` prefix is documented and the body is between
// 69 and 73 alphanumeric characters depending on when the
// key was issued.
expression: `AKCp[A-Za-z0-9]{69,73}`,
keywords: []string{"AKCp"},
caseSensitive: true,
},
{
// sentry-user-auth-token. The `sntrys_` prefix is followed
// by a base64url-encoded JWT-style payload that always
// starts with `eyJ` (the base64 of `{"`).
expression: `sntrys_eyJ[A-Za-z0-9+/=_-]{40,}`,
keywords: []string{"sntrys_"},
},
{
// stripe-restricted-key. Restricted API keys (introduced
// alongside the publishable / secret keys) follow the same
// `<prefix>_<env>_<body>` shape as `pk_` / `sk_`. Leakage of
// a restricted key still grants the scoped Stripe permissions
// it was issued with, so it must be redacted. The `prod` env
// tag was added in 2024 alongside the `test` / `live` modes.
expression: `(?i)rk_(test|live|prod)_[0-9a-z]{10,32}`,
keywords: []string{"rk_test_", "rk_live_", "rk_prod_"},
},
{
// notion-integration-token. The `ntn_` prefix is the modern
// (post-2023) format for internal-integration tokens; the
// 46-character body is fixed.
expression: `ntn_[A-Za-z0-9]{46}`,
keywords: []string{"ntn_"},
},
{
// gitlab-pipeline-trigger-token. `glptt-` is the documented
// prefix for trigger tokens; body is 40 lowercase hex.
expression: `glptt-[a-f0-9]{40}`,
keywords: []string{"glptt-"},
},
{
// vault-service-token. HashiCorp Vault service tokens issued
// by recent Vault versions carry the `hvs.` prefix and a
// base64url body whose length varies with the policies /
// metadata encoded inside the CBOR payload. The lower bound
// of 90 chars covers a default-policy token; the upper bound
// of 200 covers tokens carrying multiple policies and
// namespace metadata (matching the looser bound Trivy uses
// for similar Vault formats).
expression: `hvs\.[A-Za-z0-9_-]{90,200}`,
keywords: []string{"hvs."},
},
{
// slack-rotating-token. Modern Slack OAuth issues refresh
// tokens (`xoxe-…`) and rotating bot/user tokens
// (`xoxe.xoxb-…` / `xoxe.xoxp-…`) whose bodies include dashes
// and dots — a shape the legacy `slack-access-token` rule
// (locked to `xox[baprs]-`) does not cover.
//
// The body class `[A-Za-z0-9.-]` happens to overlap with
// neighbouring URL / hostname text (e.g. `api.slack.com`)
// so we cap the quantifier at 300 — comfortably above the
// longest observed Slack rotating-token body — to keep an
// adjacent dotted identifier from being swallowed into the
// redaction span when the token isn't separated from it by
// whitespace or punctuation.
expression: `xoxe(\.xox[bp])?-[A-Za-z0-9.-]{40,300}`,
keywords: []string{"xoxe-", "xoxe.xox"},
},
{
// replicate-api-token. Replicate keys carry the `r8_`
// prefix; the fixed 37-char body keeps the rule specific.
expression: `r8_[A-Za-z0-9]{37}`,
keywords: []string{"r8_"},
},
{
// atlassian-api-token (Cloud). Atlassian Cloud API tokens
// carry the very distinctive `ATATT3xFfGF0` prefix followed
// by a long base64url body and an 8-char hex CRC. The
// existing `atlassian-api-token` rule only catches values
// preceded by an `atlassian` keyword — this rule fills the
// gap for bare leakage in CLI output / logs.
expression: `ATATT3xFfGF0[A-Za-z0-9_=-]{180,250}`,
keywords: []string{"ATATT3xFfGF0"},
caseSensitive: true,
},
// --- Second batch of additions, focused on credentials whose
// shapes are documented by the issuing vendor and whose
// prefixes (or framing structure) are unique enough to keep
// the keyword pre-filter useful and the false-positive rate
// low. Each rule cites the format it targets in its comment.
{
// discord-bot-token. Three-part dotted format issued for bot
// applications: `<base64 snowflake>.<6-char timestamp>.<27+
// char HMAC>`. The first segment is the bot's user-ID base64
// encoded; current Discord IDs (2018 onwards) base64 to a
// leading `MT`/`Mz`/`ND`/`NT`/`Nz`/`OD` byte pair, which we
// list as keywords so the AC pre-filter still skips inputs
// without a plausible token prefix. The structural shape
// (M-or-N-or-O-prefixed body, two literal dots, fixed segment
// widths) keeps the regex itself specific.
//
// caseSensitive is critical here: the lower-cased forms of
// these two-letter prefixes (`mt`, `nd`, `od`, …) are common
// English bigrams and trip the keyword pre-filter on ~30% of
// arbitrary text files. Pinning the case lets the AC pass
// drop the regex run on those files.
expression: `[MNO][A-Za-z\d_-]{23,25}\.[\w-]{6,7}\.[\w-]{27,38}`,
keywords: []string{"MT", "Mz", "ND", "NT", "Nz", "OD"},
caseSensitive: true,
},
{
// discord-webhook-url. The URL itself is a bearer credential:
// anyone holding it can post arbitrary content to the channel.
// `discord.com/api/webhooks/<webhook id>/<token>` (and the
// `discordapp.com` legacy alias plus the `canary.`/`ptb.`
// release-channel hosts) is the documented shape. The validator
// rejects webhook IDs whose Discord snowflake timestamp is
// impossible.
expression: `https://(?:canary\.|ptb\.)?discord(?:app)?\.com/api/webhooks/\d+/[\w-]+`,
keywords: []string{"discord.com/api/webhooks", "discordapp.com/api/webhooks"},
validator: validDiscordWebhookURL,
},
{
// telegram-bot-token. BotFather issues tokens shaped
// `<8-10 digit bot id>:AA<33 char base64url>`; the literal
// `:AA` byte pair starts the second segment for every token
// the BotFather has ever issued.
expression: `\d{8,10}:AA[A-Za-z0-9_-]{33}`,
keywords: []string{":AA"},
caseSensitive: true,
},
{
// flyio-macaroon. Fly.io API tokens are macaroons whose
// printable form always starts with the literal `FlyV1 fm2_`
// prefix followed by a long base64url body. The space inside
// the prefix is part of the token (Fly's CLI emits it
// verbatim). Capping the body at 400 chars stops the regex
// from swallowing arbitrary trailing text when the token is
// not separated from following content by whitespace.
expression: `FlyV1 fm2_[A-Za-z0-9_=-]{40,400}`,
keywords: []string{"FlyV1 fm2_"},
caseSensitive: true,
},
{
// groq-api-key. Groq Cloud API keys carry the `gsk_` prefix
// followed by a fixed 52-character alphanumeric body.
expression: `gsk_[A-Za-z0-9]{52}`,
keywords: []string{"gsk_"},
},
{
// perplexity-api-key. Perplexity API keys carry the `pplx-`
// prefix followed by a 48-56 char alphanumeric body (length
// has shifted slightly between issuance epochs).
expression: `pplx-[A-Za-z0-9]{48,56}`,
keywords: []string{"pplx-"},
},
{
// xai-api-key. xAI / Grok API keys carry the `xai-` prefix
// followed by an 80-character alphanumeric body.
expression: `xai-[A-Za-z0-9]{80}`,
keywords: []string{"xai-"},
},
{
// cohere-api-key. Cohere's modern API keys carry the `co_`
// prefix and a 40-char alphanumeric body. Older trial keys
// without the prefix are unfortunately too generic to
// match without a `cohere` keyword anchor.
expression: `co_[A-Za-z0-9]{40}`,
keywords: []string{"co_"},
},
{
// buildkite-agent-token. Agent registration tokens carry the
// `bkua_` prefix and a 40-character alphanumeric body. Leakage
// lets attackers register fake agents in a Buildkite cluster.
expression: `bkua_[a-zA-Z0-9]{40}`,
keywords: []string{"bkua_"},
},
{
// circleci-project-token. Project-scoped CircleCI API tokens
// carry the `CCIPRJ_` prefix followed by `<vcs-org>_<token>`.
// User-scoped personal tokens are 40-char hex without a
// prefix and are too generic to match safely on their own.
expression: `CCIPRJ_[A-Za-z0-9_-]+_[A-Za-z0-9_-]{32,}`,
keywords: []string{"CCIPRJ_"},
caseSensitive: true,
},
{
// cloudinary-url. Cloudinary SDK credentials are passed as a
// single URL whose userinfo segment carries the API key and
// secret. The `cloudinary://` scheme is unique to this
// product so we redact the whole URL.
expression: `cloudinary://\d+:[A-Za-z0-9_-]+@[A-Za-z0-9_-]+`,
keywords: []string{"cloudinary://"},
},
{
// mongodb-connection-string. The userinfo of a `mongodb://` /
// `mongodb+srv://` URI carries the database password. We
// preserve the scheme + username + host so log readers can
// still tell which cluster was being addressed, and only
// scrub the password span. The 200-char upper bound stops
// the regex from consuming arbitrary trailing content if a
// connection string is missing the `@` terminator.
//
// The leading character class excludes `${…}`, `{{…}}`,
// `<…>`, `%…%` and bare `$VAR` template references that
// CI / Helm / Kustomize emit verbatim into committed config
// files — they aren't real passwords, just placeholders.
expression: `mongodb(?:\+srv)?://[^\s:/?#@]+:(?P<secret>[^\s@${<%][^\s@]{0,199})@`,
keywords: []string{"mongodb://", "mongodb+srv://"},
},
{
// postgres-connection-string. Same design as the MongoDB
// rule: only the URI password is redacted so the surrounding
// `postgresql://user@host/db` framing stays readable. The
// password's first character must be non-template (see
// mongodb-connection-string) so unresolved `${PASSWORD}`
// placeholders in templated YAML / Helm values don't fire.
expression: `postgres(?:ql)?://[^\s:/?#@]+:(?P<secret>[^\s@${<%][^\s@]{0,199})@`,
keywords: []string{"postgres://", "postgresql://"},
},
{
// azure-storage-connection-string. The `AccountKey=` field is
// the actual secret; the surrounding `DefaultEndpointsProtocol`
// / `AccountName` framing is only metadata. Azure account keys
// are 512-bit symmetric keys, base64-encoded to 88 chars.
expression: `DefaultEndpointsProtocol=https?;AccountName=[^;]+;AccountKey=(?P<secret>[A-Za-z0-9+/=]{88})`,
keywords: []string{"DefaultEndpointsProtocol="},
caseSensitive: true,
validator: validAzureStorageAccountKey,
},
{
// mapbox-secret-key. Mapbox publishable keys (`pk.<60>.<22>`)
// are already covered; secret keys share the same shape with
// the `sk.` prefix and grant write access to the Mapbox
// account.
expression: `(?i)sk\.[a-z0-9]{60}\.[a-z0-9]{22}`,
keywords: []string{"sk."},
},
{
// vault-batch-token. HashiCorp Vault batch tokens follow the
// same `<prefix>.<base64url body>` shape as service tokens
// but use the `hvb.` prefix.
expression: `hvb\.[A-Za-z0-9_-]{90,200}`,
keywords: []string{"hvb."},
},
{
// vault-recovery-token. Recovery tokens are issued during
// initialisation of an auto-unsealed Vault and carry the
// `hvr.` prefix; full root-equivalent if leaked.
expression: `hvr\.[A-Za-z0-9_-]{90,200}`,
keywords: []string{"hvr."},
},
{
// netlify-pat. Netlify personal access tokens carry the
// `nfp_` prefix and a 40-character alphanumeric body.
expression: `nfp_[A-Za-z0-9]{40}`,
keywords: []string{"nfp_"},
},
{
// asana-pat. Asana personal access tokens are shaped
// `1/<numeric workspace id>:<32 hex>`. The numeric workspace
// id is at least 14 digits in practice, which keeps the rule
// from firing on innocuous `1/<short>` substrings (page
// numbers, fractions, paths). The existing `asana-*` rules
// only fire when the literal word `asana` appears nearby; this
// rule fills the gap for bare leakage in CLI output / logs.
expression: `1/\d{14,}:[a-f0-9]{32}`,
keywords: []string{"1/"},
},
{
// cloudflare-origin-ca-key. Cloudflare's Origin CA keys are
// printed as `v1.0-<32 hex>-<146 base64>` and grant the
// ability to issue certificates for any zone in the account.
expression: `v1\.0-[a-f0-9]{32}-[A-Za-z0-9+/=]{146}`,
keywords: []string{"v1.0-"},
},
// --- Third batch of additions: vendor-prefixed credentials
// confirmed against gitleaks default rules and vendor docs.
// Each format embeds a unique prefix that keeps the keyword
// pre-filter cheap and the regex tight enough to match without
// surrounding-context anchors.
{
// 1password-service-account-token. Service-account tokens
// always start with `ops_eyJ` — the `eyJ` is the base64
// prefix of `{"`, since the body is a JWT-style envelope
// over a 1Password macaroon. The literal `ops_eyJ` keyword
// keeps the AC pre-filter extremely selective. The 1000-char
// upper bound covers the longest 1Password tokens observed
// in the wild (and is RE2's hard cap on a single quantifier)
// while preventing the regex from absorbing arbitrary
// trailing alphanumeric content if a token is not
// whitespace-terminated.
expression: `ops_eyJ[A-Za-z0-9+/=_-]{250,1000}`,
keywords: []string{"ops_eyJ"},
caseSensitive: true,
},
{
// openrouter-api-key. OpenRouter (LLM router) keys carry
// the documented `sk-or-v1-` prefix followed by a 64-char
// lowercase-hex body.
expression: `sk-or-v1-[a-f0-9]{64}`,
keywords: []string{"sk-or-v1-"},
},
{
// sonar-token. SonarQube / SonarCloud user (`squ_`),
// project (`sqp_`), and global-analysis (`sqa_`) tokens
// share a 40-char hex body. The prefix is mandatory —
// gitleaks' upstream rule treats it as optional, but doing
// so flags any 40-char hex blob and is too noisy for our
// agent-output use case.
expression: `(squ|sqp|sqa)_[a-f0-9]{40}`,
keywords: []string{"squ_", "sqp_", "sqa_"},
},
{
// pinecone-api-key. Pinecone vector-DB keys carry the
// `pckey_` prefix; the body is a `<label>_<token>` pair of
// base64url-ish segments. Both segments are bounded so the
// regex can't swallow neighbouring identifiers when a key
// abuts other text without a separator.
expression: `pckey_[A-Za-z0-9]{1,40}_[A-Za-z0-9_-]{24,80}`,
keywords: []string{"pckey_"},
},
{
// supabase-secret-key. The 2024 `sb_publishable_` /
// `sb_secret_` rotation introduced prefixed keys; only the
// secret variant bypasses Row-Level Security and is worth
// redacting. Body is base64url-ish, observed at ~56 chars;
// the 80-char ceiling keeps the regex from absorbing trailing
// text when the key isn't whitespace-terminated.
expression: `sb_secret_[A-Za-z0-9_-]{40,80}`,
keywords: []string{"sb_secret_"},
},
{
// tailscale-auth-key. Used to enroll new nodes into a
// Tailnet without an interactive login. The `tskey-auth-`
// prefix is documented; the body is a `<id>-<secret>` pair
// of alphanumeric segments. Both segments are bounded so
// the regex can't swallow adjacent text.
expression: `tskey-auth-[A-Za-z0-9]{10,30}-[A-Za-z0-9]{20,80}`,
keywords: []string{"tskey-auth-"},
},
{
// tailscale-api-access-token. Grants programmatic access
// to the Tailscale control plane (devices, ACLs, keys).
// Same `<id>-<secret>` body shape as the auth-key form
// with the same upper bounds.