@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 CHANGED
@@ -1,8 +1,11 @@
1
1
  # @sanity-labs/secret-scan
2
2
 
3
- Detect and redact secrets in strings. Works in browser and Node.js. Zero runtime dependencies.
3
+ Detect and redact secrets in strings. Designed for **chat and paste contexts** where secrets appear without surrounding code context.
4
4
 
5
- Rules derived from [gitleaks](https://github.com/gitleaks/gitleaks) (MIT licensed) — 221 rules covering API keys, tokens, passwords, and credentials from 100+ providers.
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
- const secrets = scan('OPENAI_API_KEY=sk-proj-abc123...\nMODE=production')
21
- // [
22
- // {
23
- // rule: 'openai-api-key',
24
- // label: 'OpenAI API Key',
25
- // text: 'sk-proj-abc123...',
26
- // confidence: 'high',
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
- ### `redact` find and replace secrets
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
- ```typescript
36
- import { redact } from '@sanity-labs/secret-scan'
54
+ ## How it works
37
55
 
38
- const secrets = new Map()
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
- const redacted = redact(pastedText, (secret) => {
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
- // redacted: "OPENAI_API_KEY=[secret:0]\nSTRIPE_KEY=[secret:1]"
48
- // secrets: Map { '[secret:0]' => { text: 'sk-proj-...' }, ... }
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 an array of every secret found in the input.
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 // gitleaks rule ID, e.g. 'openai-api-key'
66
- label: string // human-readable, e.g. 'OpenAI API Key'
67
- text: string // the matched secret value
68
- confidence: 'high' | 'medium' // provider pattern vs entropy-based
69
- start: number // start index in input
70
- end: number // end index (exclusive) in input
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
- ### `shannonEntropy(s: string): number`
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
- 221 rules from gitleaks covering:
87
+ Finds and replaces all secrets. Replacements applied right-to-left to preserve indices.
110
88
 
111
- - **Cloud providers**: AWS, GCP, Azure, DigitalOcean, Heroku, Fly.io, etc.
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
- Plus the `generic-api-key` rule which catches `KEY=value` patterns with entropy thresholds and 1,446 stopwords.
91
+ Calculate Shannon entropy of a string. Used internally for entropy-based filtering.
120
92
 
121
93
  ## License
122
94
 
123
- MIT includes gitleaks copyright notice per their license terms.
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 secrets = [];
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
- secrets.push({
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
  }