@sanity-labs/secret-scan 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -88
- package/dist/index.cjs +127 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -3
- package/dist/index.d.ts +11 -3
- package/dist/index.js +127 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# @sanity-labs/secret-scan
|
|
2
2
|
|
|
3
|
-
Detect and redact secrets in strings.
|
|
3
|
+
Detect and redact secrets in strings. Designed for **chat and paste contexts** where secrets appear without surrounding code context.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- **1,100+ detection rules** extracted from [TruffleHog](https://github.com/trufflesecurity/trufflehog) detectors
|
|
6
|
+
- **Zero runtime dependencies** — works in browser and Node.js
|
|
7
|
+
- **Fast** — keyword pre-filtering means most rules are skipped for any given input (~0.15ms for short messages)
|
|
8
|
+
- **Two functions** — `scan(input)` finds secrets, `redact(input, replacer)` replaces them
|
|
6
9
|
|
|
7
10
|
## Install
|
|
8
11
|
|
|
@@ -12,114 +15,81 @@ npm install @sanity-labs/secret-scan
|
|
|
12
15
|
|
|
13
16
|
## Usage
|
|
14
17
|
|
|
15
|
-
### `scan` — find secrets
|
|
16
|
-
|
|
17
18
|
```typescript
|
|
18
|
-
import { scan } from '@sanity-labs/secret-scan'
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
// start: 15,
|
|
28
|
-
// end: 32
|
|
29
|
-
// }
|
|
30
|
-
// ]
|
|
19
|
+
import { scan, redact } from '@sanity-labs/secret-scan'
|
|
20
|
+
|
|
21
|
+
// Find secrets
|
|
22
|
+
const secrets = scan('my key is ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh1234')
|
|
23
|
+
// [{ rule: 'github-v2', label: 'Github V2', text: 'ghp_...', confidence: 'high', start: 10, end: 50 }]
|
|
24
|
+
|
|
25
|
+
// Redact secrets
|
|
26
|
+
const safe = redact(input, (secret, index) => `[secret:${index}]`)
|
|
27
|
+
// 'my key is [secret:0]'
|
|
31
28
|
```
|
|
32
29
|
|
|
33
|
-
|
|
30
|
+
## What it detects
|
|
31
|
+
|
|
32
|
+
Bare paste (no surrounding context needed):
|
|
33
|
+
|
|
34
|
+
| Provider | Prefix/Pattern | Rule ID |
|
|
35
|
+
|----------|---------------|---------|
|
|
36
|
+
| OpenAI | `sk-proj-...T3BlbkFJ...` | `openai` |
|
|
37
|
+
| Anthropic | `sk-ant-api03-...` | `anthropic` |
|
|
38
|
+
| AWS | `AKIA...` | `aws-access_keys` |
|
|
39
|
+
| GitHub | `ghp_`, `gho_`, `github_pat_` | `github-v2` |
|
|
40
|
+
| Stripe | `sk_live_`, `rk_live_` | `stripe` |
|
|
41
|
+
| Slack | `xoxb-`, `xoxp-` | `slack` |
|
|
42
|
+
| Groq | `gsk_` | `groq` |
|
|
43
|
+
| Replicate | `r8_` | `replicate` |
|
|
44
|
+
| SendGrid | `SG.` | `sendgrid` |
|
|
45
|
+
| JWT | `eyJ...` | `jwt` |
|
|
46
|
+
| GitLab | `glpat-` | `gitlab-v2` |
|
|
47
|
+
| NPM | `npm_` | `npmtokenv2` |
|
|
48
|
+
| Linear | `lin_api_` | `linearapi` |
|
|
49
|
+
| Supabase | `sbp_` | `supabasetoken` |
|
|
50
|
+
| Postman | `PMAK-` | `postman` |
|
|
51
|
+
|
|
52
|
+
Plus 850+ more providers. Connection strings (postgres://, mongodb://, redis://) and Bearer tokens are also detected.
|
|
34
53
|
|
|
35
|
-
|
|
36
|
-
import { redact } from '@sanity-labs/secret-scan'
|
|
54
|
+
## How it works
|
|
37
55
|
|
|
38
|
-
|
|
39
|
-
let nextId = 0
|
|
56
|
+
Rules are extracted from [TruffleHog's Go detectors](https://github.com/trufflesecurity/trufflehog/tree/main/pkg/detectors) and compiled to JavaScript RegExp. TruffleHog's keyword pre-filter uses strings from the **secret itself** (prefixes like `gsk_`, `T3BlbkFJ`), not surrounding context — this is why it works for bare paste in chat.
|
|
40
57
|
|
|
41
|
-
|
|
42
|
-
const key = `[secret:${nextId++}]`
|
|
43
|
-
secrets.set(key, secret)
|
|
44
|
-
return key
|
|
45
|
-
})
|
|
58
|
+
A keyword index maps each keyword to its rules. For any input, only rules whose keywords appear in the input are tested — typically <10 rules out of 1,100+.
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
|
|
60
|
+
### Updating rules
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm run update-rules
|
|
49
64
|
```
|
|
50
65
|
|
|
66
|
+
This clones/updates TruffleHog, parses all detector Go files, converts Go regex to JS, and regenerates `src/rules.ts`.
|
|
67
|
+
|
|
51
68
|
## API
|
|
52
69
|
|
|
53
70
|
### `scan(input: string): Secret[]`
|
|
54
71
|
|
|
55
|
-
Returns
|
|
56
|
-
|
|
57
|
-
### `redact(input: string, replacer: (secret: Secret) => string): string`
|
|
58
|
-
|
|
59
|
-
Calls `replacer` for each detected secret. The return value replaces the secret in the output string. The caller owns all state — `redact` just does string replacement.
|
|
60
|
-
|
|
61
|
-
### `Secret`
|
|
72
|
+
Returns all secrets found in the input string.
|
|
62
73
|
|
|
63
74
|
```typescript
|
|
64
75
|
interface Secret {
|
|
65
|
-
rule: string
|
|
66
|
-
label: string
|
|
67
|
-
text: string
|
|
68
|
-
confidence: 'high' | 'medium'
|
|
69
|
-
start: number
|
|
70
|
-
end: number
|
|
76
|
+
rule: string // Rule ID (e.g., 'openai')
|
|
77
|
+
label: string // Human-readable label
|
|
78
|
+
text: string // The matched secret value
|
|
79
|
+
confidence: 'high' | 'medium'
|
|
80
|
+
start: number // Start index in input
|
|
81
|
+
end: number // End index (exclusive)
|
|
71
82
|
}
|
|
72
83
|
```
|
|
73
84
|
|
|
74
|
-
### `
|
|
75
|
-
|
|
76
|
-
Shannon entropy calculation. Exported for advanced use cases.
|
|
77
|
-
|
|
78
|
-
## How it works
|
|
79
|
-
|
|
80
|
-
1. **Keyword pre-filter** — Each rule has keywords. Before running a regex, we check if the input contains any of its keywords (case-insensitive). This keeps scanning fast with 221 rules — most regexes are skipped for any given input.
|
|
81
|
-
|
|
82
|
-
2. **Regex matching** — Rules are compiled from [gitleaks.toml](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) with Go→JS regex conversion (named groups, inline flags, dotall).
|
|
83
|
-
|
|
84
|
-
3. **Entropy filtering** — Many rules have Shannon entropy thresholds. Low-entropy matches (like `KEY=aaaaaaa`) are filtered out.
|
|
85
|
-
|
|
86
|
-
4. **Allowlist filtering** — Global and per-rule allowlists filter false positives. Includes 1,446 stopwords for the generic-api-key rule.
|
|
87
|
-
|
|
88
|
-
## Updating rules
|
|
89
|
-
|
|
90
|
-
```bash
|
|
91
|
-
npm run update-rules
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
Fetches the latest `gitleaks.toml` from GitHub, converts Go regex → JS regex, and writes `src/rules.ts`. Run this whenever gitleaks updates their rules.
|
|
95
|
-
|
|
96
|
-
### Go → JS regex conversion
|
|
97
|
-
|
|
98
|
-
| Go pattern | JS equivalent | Notes |
|
|
99
|
-
|---|---|---|
|
|
100
|
-
| `(?P<name>...)` | `(?<name>...)` | Named groups |
|
|
101
|
-
| `(?i)` at start | `i` flag | Case-insensitive |
|
|
102
|
-
| `(?i:...)` mid-pattern | `(?:...)` + `i` flag | Promoted to global flag |
|
|
103
|
-
| `(?-i:...)` | `(?:...)` | Groups already enumerate cases |
|
|
104
|
-
| `(?s:.)` | `[\s\S]` | Dotall |
|
|
105
|
-
| `\z` | `$` | End of string |
|
|
106
|
-
|
|
107
|
-
## Rules coverage
|
|
85
|
+
### `redact(input: string, replacer: (secret: Secret) => string): string`
|
|
108
86
|
|
|
109
|
-
|
|
87
|
+
Finds and replaces all secrets. Replacements applied right-to-left to preserve indices.
|
|
110
88
|
|
|
111
|
-
|
|
112
|
-
- **AI/ML**: OpenAI, Anthropic, Cohere, HuggingFace, Perplexity
|
|
113
|
-
- **Payment**: Stripe, Square, Plaid, Coinbase, Flutterwave
|
|
114
|
-
- **DevOps**: GitHub, GitLab, Bitbucket, CircleCI, Travis CI, Jenkins
|
|
115
|
-
- **Communication**: Slack, Discord, Telegram, Twilio, SendGrid
|
|
116
|
-
- **Databases**: PlanetScale, MongoDB Atlas, ClickHouse
|
|
117
|
-
- **And 80+ more providers**
|
|
89
|
+
### `shannonEntropy(s: string): number`
|
|
118
90
|
|
|
119
|
-
|
|
91
|
+
Calculate Shannon entropy of a string. Used internally for entropy-based filtering.
|
|
120
92
|
|
|
121
93
|
## License
|
|
122
94
|
|
|
123
|
-
MIT
|
|
124
|
-
|
|
125
|
-
This package uses rules derived from [gitleaks](https://github.com/gitleaks/gitleaks), which is also MIT licensed. Copyright (c) 2019 Zachary Rice.
|
|
95
|
+
MIT. Rules derived from [TruffleHog](https://github.com/trufflesecurity/trufflehog) (Apache 2.0).
|
package/dist/index.cjs
CHANGED
|
@@ -6712,6 +6712,61 @@ var customRules = [
|
|
|
6712
6712
|
),
|
|
6713
6713
|
keywords: ["bearer"],
|
|
6714
6714
|
entropy: 3.5
|
|
6715
|
+
},
|
|
6716
|
+
// ─── Inngest signing keys ─────────────────────────────────────────
|
|
6717
|
+
// Format: signkey-prod-<hex> or signkey-test-<hex>
|
|
6718
|
+
// No TruffleHog detector exists. Distinctive prefix makes this safe.
|
|
6719
|
+
{
|
|
6720
|
+
id: "inngest-signing-key",
|
|
6721
|
+
label: "Inngest Signing Key",
|
|
6722
|
+
regex: new RegExp(
|
|
6723
|
+
"\\b(signkey-(?:prod|test|staging)-[a-f0-9]{32,})\\b",
|
|
6724
|
+
""
|
|
6725
|
+
),
|
|
6726
|
+
keywords: ["signkey-"]
|
|
6727
|
+
},
|
|
6728
|
+
// ─── Inngest event keys ───────────────────────────────────────────
|
|
6729
|
+
// Format varies, but commonly used with INNGEST_EVENT_KEY= context.
|
|
6730
|
+
// Event keys are hex strings. We detect them when the env var name
|
|
6731
|
+
// provides context.
|
|
6732
|
+
{
|
|
6733
|
+
id: "inngest-event-key",
|
|
6734
|
+
label: "Inngest Event Key",
|
|
6735
|
+
regex: new RegExp(
|
|
6736
|
+
`(?:INNGEST_EVENT_KEY\\s*[=:]\\s*)["']?([a-f0-9A-F]{16,})["']?`,
|
|
6737
|
+
""
|
|
6738
|
+
),
|
|
6739
|
+
keywords: ["inngest_event_key"]
|
|
6740
|
+
},
|
|
6741
|
+
// ─── ElevenLabs bare sk_ keys ─────────────────────────────────────
|
|
6742
|
+
// TruffleHog's elevenlabs-v2 rule matches sk_ + 48 hex chars but
|
|
6743
|
+
// requires "elevenlabs" keyword in the input. This catches bare paste
|
|
6744
|
+
// of sk_ + 48 hex chars without context. The sk_ prefix + hex-only
|
|
6745
|
+
// suffix is distinctive enough.
|
|
6746
|
+
{
|
|
6747
|
+
id: "elevenlabs-bare",
|
|
6748
|
+
label: "ElevenLabs API Key",
|
|
6749
|
+
regex: new RegExp(
|
|
6750
|
+
"\\b(sk_[a-f0-9]{48})\\b",
|
|
6751
|
+
""
|
|
6752
|
+
),
|
|
6753
|
+
keywords: ["sk_"]
|
|
6754
|
+
},
|
|
6755
|
+
// ─── Sanity bare tokens ───────────────────────────────────────────
|
|
6756
|
+
// TruffleHog's sanity rule matches sk + 79 alphanumeric chars but
|
|
6757
|
+
// requires "sanity" keyword in the input. Sanity tokens start with
|
|
6758
|
+
// sk followed by a capital letter (skE..., skR..., skS...) which
|
|
6759
|
+
// distinguishes them from other sk-prefixed keys. Real tokens are
|
|
6760
|
+
// ~80 chars total (sk + 78). Entropy threshold prevents false positives.
|
|
6761
|
+
{
|
|
6762
|
+
id: "sanity-bare",
|
|
6763
|
+
label: "Sanity API Token",
|
|
6764
|
+
regex: new RegExp(
|
|
6765
|
+
"\\b(sk[A-Z][A-Za-z0-9]{58,})\\b",
|
|
6766
|
+
""
|
|
6767
|
+
),
|
|
6768
|
+
keywords: ["sk"],
|
|
6769
|
+
entropy: 4
|
|
6715
6770
|
}
|
|
6716
6771
|
];
|
|
6717
6772
|
|
|
@@ -6750,6 +6805,59 @@ for (const rule of rules2) {
|
|
|
6750
6805
|
}
|
|
6751
6806
|
}
|
|
6752
6807
|
}
|
|
6808
|
+
var MIN_SUFFIX_ENTROPY = 1.5;
|
|
6809
|
+
var KNOWN_PREFIXES = [
|
|
6810
|
+
"sk-proj-",
|
|
6811
|
+
"sk-svcacct-",
|
|
6812
|
+
"sk-admin-",
|
|
6813
|
+
"sk-ant-api03-",
|
|
6814
|
+
"sk_live_",
|
|
6815
|
+
"sk_test_",
|
|
6816
|
+
"pk_live_",
|
|
6817
|
+
"pk_test_",
|
|
6818
|
+
"rk_live_",
|
|
6819
|
+
"rk_test_",
|
|
6820
|
+
"ghp_",
|
|
6821
|
+
"gho_",
|
|
6822
|
+
"ghu_",
|
|
6823
|
+
"ghs_",
|
|
6824
|
+
"ghr_",
|
|
6825
|
+
"github_pat_",
|
|
6826
|
+
"xoxb-",
|
|
6827
|
+
"xoxp-",
|
|
6828
|
+
"xoxa-",
|
|
6829
|
+
"xoxo-",
|
|
6830
|
+
"xapp-",
|
|
6831
|
+
"glpat-",
|
|
6832
|
+
"glsa_",
|
|
6833
|
+
"npm_",
|
|
6834
|
+
"lin_api_",
|
|
6835
|
+
"gsk_",
|
|
6836
|
+
"r8_",
|
|
6837
|
+
"sbp_",
|
|
6838
|
+
"sk_",
|
|
6839
|
+
"SG.",
|
|
6840
|
+
"dp.pt.",
|
|
6841
|
+
"PMAK-",
|
|
6842
|
+
"signkey-prod-",
|
|
6843
|
+
"signkey-test-",
|
|
6844
|
+
"signkey-staging-",
|
|
6845
|
+
"sk-",
|
|
6846
|
+
"AKIA"
|
|
6847
|
+
];
|
|
6848
|
+
function isPlaceholder(secret) {
|
|
6849
|
+
let longestPrefix = "";
|
|
6850
|
+
for (const prefix of KNOWN_PREFIXES) {
|
|
6851
|
+
if (secret.startsWith(prefix) && prefix.length > longestPrefix.length) {
|
|
6852
|
+
longestPrefix = prefix;
|
|
6853
|
+
}
|
|
6854
|
+
}
|
|
6855
|
+
if (!longestPrefix) return false;
|
|
6856
|
+
const suffix = secret.slice(longestPrefix.length);
|
|
6857
|
+
if (suffix.length === 0) return true;
|
|
6858
|
+
const entropy = shannonEntropy(suffix);
|
|
6859
|
+
return entropy < MIN_SUFFIX_ENTROPY;
|
|
6860
|
+
}
|
|
6753
6861
|
function isGlobalAllowlisted(secret) {
|
|
6754
6862
|
for (const { regex } of globalAllowlist.regexes) {
|
|
6755
6863
|
if (regex.test(secret)) return true;
|
|
@@ -6804,13 +6912,12 @@ function extractSecret(match, rule) {
|
|
|
6804
6912
|
}
|
|
6805
6913
|
return match[0];
|
|
6806
6914
|
}
|
|
6807
|
-
var GENERIC_RULE_IDS = /* @__PURE__ */ new Set(["generic-sk-secret", "bearer-token"]);
|
|
6915
|
+
var GENERIC_RULE_IDS = /* @__PURE__ */ new Set(["generic-sk-secret", "bearer-token", "sanity-bare"]);
|
|
6808
6916
|
function scan(input) {
|
|
6809
6917
|
if (!input) return [];
|
|
6810
6918
|
const inputLower = input.toLowerCase();
|
|
6811
6919
|
const candidates = getCandidateRules(inputLower);
|
|
6812
|
-
const
|
|
6813
|
-
const matchedRanges = [];
|
|
6920
|
+
const allMatches = [];
|
|
6814
6921
|
for (const rule of candidates) {
|
|
6815
6922
|
const regex = new RegExp(rule.regex.source, rule.regex.flags.replace("g", "") + "g");
|
|
6816
6923
|
let match;
|
|
@@ -6820,19 +6927,16 @@ function scan(input) {
|
|
|
6820
6927
|
const secretStart = input.indexOf(secret, match.index);
|
|
6821
6928
|
const start = secretStart >= 0 ? secretStart : match.index;
|
|
6822
6929
|
const end = start + secret.length;
|
|
6823
|
-
const overlaps = matchedRanges.some(
|
|
6824
|
-
(r) => start < r.end && end > r.start
|
|
6825
|
-
);
|
|
6826
|
-
if (overlaps) continue;
|
|
6827
6930
|
if (rule.entropy !== void 0) {
|
|
6828
6931
|
const entropy = shannonEntropy(secret);
|
|
6829
6932
|
if (entropy < rule.entropy) continue;
|
|
6830
6933
|
}
|
|
6934
|
+
if (isPlaceholder(secret)) continue;
|
|
6831
6935
|
if (isGlobalAllowlisted(secret)) continue;
|
|
6832
6936
|
const line = getLineForIndex(input, match.index);
|
|
6833
6937
|
if (isRuleAllowlisted(rule, secret, match[0], line)) continue;
|
|
6834
6938
|
const confidence = GENERIC_RULE_IDS.has(rule.id) ? "medium" : "high";
|
|
6835
|
-
|
|
6939
|
+
allMatches.push({
|
|
6836
6940
|
rule: rule.id,
|
|
6837
6941
|
label: rule.label,
|
|
6838
6942
|
text: secret,
|
|
@@ -6840,10 +6944,24 @@ function scan(input) {
|
|
|
6840
6944
|
start,
|
|
6841
6945
|
end
|
|
6842
6946
|
});
|
|
6843
|
-
matchedRanges.push({ start, end });
|
|
6844
6947
|
if (match[0].length === 0) regex.lastIndex++;
|
|
6845
6948
|
}
|
|
6846
6949
|
}
|
|
6950
|
+
allMatches.sort((a, b) => {
|
|
6951
|
+
const lenDiff = b.end - b.start - (a.end - a.start);
|
|
6952
|
+
if (lenDiff !== 0) return lenDiff;
|
|
6953
|
+
return a.start - b.start;
|
|
6954
|
+
});
|
|
6955
|
+
const secrets = [];
|
|
6956
|
+
const matchedRanges = [];
|
|
6957
|
+
for (const candidate of allMatches) {
|
|
6958
|
+
const overlaps = matchedRanges.some(
|
|
6959
|
+
(r) => candidate.start < r.end && candidate.end > r.start
|
|
6960
|
+
);
|
|
6961
|
+
if (overlaps) continue;
|
|
6962
|
+
secrets.push(candidate);
|
|
6963
|
+
matchedRanges.push({ start: candidate.start, end: candidate.end });
|
|
6964
|
+
}
|
|
6847
6965
|
secrets.sort((a, b) => a.start - b.start);
|
|
6848
6966
|
return secrets;
|
|
6849
6967
|
}
|