@envseal/detector 0.1.2 → 0.1.4

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.
@@ -1,3 +1,4 @@
1
+ import { charsetClasses, shannonEntropy } from './entropy.js';
1
2
  import { WORDLIST_LOWER } from './wordlist.js';
2
3
  const WORDLIST_SET = new Set(WORDLIST_LOWER.trim().split('\n'));
3
4
  export function exclusionReason(candidate, context) {
@@ -55,6 +56,8 @@ export function isExcluded(candidate, context) {
55
56
  * never consults the exclusion list.
56
57
  */
57
58
  const MIN_GENERIC_LENGTH = 24;
59
+ const MIN_ENTROPY = 3.5;
60
+ const MIN_CHARSET_CLASSES = 2;
58
61
  function isNonSecretAssignment(candidate) {
59
62
  const match = /^[A-Z][A-Z0-9_]{2,}=(.*)$/.exec(candidate);
60
63
  if (match === null)
@@ -73,34 +76,95 @@ function isUUID(candidate) {
73
76
  const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
74
77
  return uuidPattern.test(candidate);
75
78
  }
79
+ /**
80
+ * Audit fix: these buckets previously excluded on PREFIX alone (`sha256:` +
81
+ * anything), letting attacker-controlled context park a real credential
82
+ * behind an algorithm label or an unvalidated `integrity=` shape. Each now
83
+ * demands a base64/hex body of plausible digest length for its algorithm.
84
+ */
85
+ const DIGEST_BODY_FLOOR = {
86
+ 'sha256:': 43,
87
+ 'sha256-': 43,
88
+ 'sha512-': 86,
89
+ 'sha1-': 27,
90
+ 'md5-': 22,
91
+ };
76
92
  function isDigest(candidate, context) {
77
- if (candidate.startsWith('sha256:') || candidate.startsWith('sha256-') ||
78
- candidate.startsWith('sha512-') || candidate.startsWith('sha1-') ||
79
- candidate.startsWith('md5-')) {
80
- return true;
93
+ // Standard-base64/hex charset only: SRI and docker-style digest bodies
94
+ // never contain `-` or `_`, but vendor key shapes (sk-proj-*, ghp-*) do.
95
+ // That charset distinction — plus the length floor — is what keeps a real
96
+ // credential from hiding behind an algorithm label.
97
+ const DIGEST_BODY = /^[A-Za-z0-9+/=]+$/;
98
+ for (const [prefix, floor] of Object.entries(DIGEST_BODY_FLOOR)) {
99
+ if (candidate.startsWith(prefix)) {
100
+ const body = candidate.slice(prefix.length);
101
+ return (DIGEST_BODY.test(body) && body.length >= floor && body.length <= 88);
102
+ }
81
103
  }
82
104
  if (context.before.endsWith('integrity=') || context.before.endsWith('integrity="')) {
83
- return /^[A-Za-z0-9+/=]+$/.test(candidate);
105
+ // SRI values carry their own algorithm prefix; bare base64 in this
106
+ // position is not an SRI hash and gets no exclusion.
107
+ return /^sha(256|384|512)-[A-Za-z0-9+/=]+$/.test(candidate);
84
108
  }
85
109
  return false;
86
110
  }
87
111
  function isDataUri(candidate, context) {
88
- if (candidate.startsWith('data:'))
112
+ // Audit fix: was prefix-only. A data: URI must now look like one — a MIME
113
+ // type, and if base64 is claimed, a decodable-length body — before its
114
+ // content can hide from detection.
115
+ const match = /^data:([a-z0-9.+-]+\/[a-z0-9.+-]+)?(;?[a-z0-9-]+=[a-z0-9-]+)*(;base64,)?(.*)$/i.exec(candidate);
116
+ if (match !== null) {
117
+ const [, mime, , base64Marker, body] = match;
118
+ if ((mime ?? '') === '')
119
+ return false; // `data:` alone proves nothing
120
+ if (base64Marker !== undefined) {
121
+ return /^[A-Za-z0-9+/=]*$/.test(body ?? '') && (body ?? '').length % 4 === 0 && (body ?? '').length > 0;
122
+ }
89
123
  return true;
90
- return /data:[^/]*\/[^;]*;base64,$/.test(context.before);
124
+ }
125
+ return /data:[a-z0-9.+-]+\/[a-z0-9.+-]+(;[a-z0-9-]+=[a-z0-9-]+)*;base64,[A-Za-z0-9+/=]+$/.test(context.before) || ((context.before.endsWith(';base64,') || context.before.endsWith('base64,')) &&
126
+ /^[A-Za-z0-9+/=]*$/.test(candidate) &&
127
+ candidate.length % 4 === 0 &&
128
+ candidate.length > 0);
91
129
  }
92
130
  function isFilesystemPath(candidate) {
131
+ if (/^\([^)]*[/\\][^)]*\)$/.test(candidate))
132
+ return true;
133
+ if (/\/[a-zA-Z0-9_./-]+:\d+(?::\d+)?$/.test(candidate))
134
+ return true;
93
135
  const hasPathSep = candidate.includes('/') || candidate.includes(String.fromCharCode(92));
94
136
  if (!hasPathSep)
95
137
  return false;
96
138
  const hasCredential = /[a-z][a-z0-9+.-]*:\/\/[^@]*:.*@/.test(candidate);
97
- return !hasCredential;
139
+ if (hasCredential)
140
+ return false;
141
+ // Audit fix: any slash-containing string used to be excluded, but ~27% of
142
+ // random base64 credentials contain `/`. Require actual path structure:
143
+ // an anchored form or a path-like extension — not merely a slash.
144
+ const pathlike = /^\.\.?[/\\]/.test(candidate) || // ./x, ../x
145
+ /^[/\\]/.test(candidate) || // absolute POSIX
146
+ /^[A-Za-z]:[/\\]/.test(candidate) || // drive path
147
+ /\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(candidate); // ends in an extension
148
+ return pathlike;
98
149
  }
99
150
  function isPlainUrl(candidate) {
151
+ if (/^(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^@\s]+/i.test(candidate) && !candidate.includes('@')) {
152
+ return true;
153
+ }
100
154
  try {
101
155
  const url = new URL(candidate);
102
- return (url.protocol === 'http:' || url.protocol === 'https:') &&
103
- url.username === '' && url.password === '';
156
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
157
+ return false;
158
+ }
159
+ if (url.username !== '' || url.password !== '') {
160
+ return false;
161
+ }
162
+ // Audit fix: URLs whose query carries credential-shaped parameters are a
163
+ // real delivery channel for keys (Google-style ?key=...), not plain links.
164
+ if (/[?&](key|token|secret|password|passwd|api[-_]?key|access[-_]?token|sig|signature|credential)s?=/i.test(url.search)) {
165
+ return false;
166
+ }
167
+ return true;
104
168
  }
105
169
  catch {
106
170
  return false;
@@ -139,20 +203,58 @@ function splitOnBoundaries(candidate) {
139
203
  });
140
204
  return result.filter((s) => s.length > 0);
141
205
  }
142
- function isCodeAdjacent(candidate, context) {
143
- const codeChars = /[{}();,:]/;
206
+ /**
207
+ * High-entropy `[A-Za-z0-9]{32,}` blobs in JSON/JS (`{"key":"<secret>"}` or
208
+ * `{secret:<value>}`) are credentials, not identifier tokens. The generic
209
+ * scanner uses the same entropy/charset gates.
210
+ */
211
+ function qualifiesAsGenericHighEntropy(candidate) {
212
+ if (!/^[A-Za-z0-9]{32,}$/.test(candidate))
213
+ return false;
214
+ if (isIdentifierLike(candidate))
215
+ return false;
216
+ if (shannonEntropy(candidate) < MIN_ENTROPY)
217
+ return false;
218
+ if (charsetClasses(candidate) < MIN_CHARSET_CLASSES)
219
+ return false;
220
+ return true;
221
+ }
222
+ function isIdentifierLike(candidate) {
223
+ if (candidate.startsWith('$'))
224
+ return true;
225
+ if (isDictionaryText(candidate))
226
+ return true;
227
+ // Digits in a token point at key material, not a source-code identifier.
228
+ if (/\d/.test(candidate))
229
+ return false;
230
+ if (/^[a-z_$][\w$]*$/i.test(candidate) && /[a-z][A-Z]|[A-Z][a-z]|_/.test(candidate)) {
231
+ return true;
232
+ }
233
+ return /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/i.test(candidate);
234
+ }
235
+ function isAdjacentToCodeStructure(context) {
236
+ const structural = /[{}(),:]/;
144
237
  if (context.before.length > 0) {
145
238
  const lastChar = context.before.at(-1);
146
- if (lastChar !== undefined && codeChars.test(lastChar) && !/\s/.test(lastChar))
239
+ if (lastChar !== undefined && structural.test(lastChar) && !/\s/.test(lastChar)) {
147
240
  return true;
241
+ }
148
242
  }
149
243
  if (context.after.length > 0) {
150
244
  const firstChar = context.after.at(0);
151
- if (firstChar !== undefined && codeChars.test(firstChar) && !/\s/.test(firstChar))
245
+ if (firstChar !== undefined && structural.test(firstChar) && !/\s/.test(firstChar)) {
152
246
  return true;
247
+ }
153
248
  }
154
249
  return false;
155
250
  }
251
+ function isCodeAdjacent(candidate, context) {
252
+ if (!isAdjacentToCodeStructure(context))
253
+ return false;
254
+ if (qualifiesAsGenericHighEntropy(candidate))
255
+ return false;
256
+ return isIdentifierLike(candidate);
257
+ }
156
258
  function isNumeric(candidate) {
157
259
  return /^\d+$/.test(candidate);
158
260
  }
@@ -5,5 +5,14 @@ export interface SecretPattern {
5
5
  confidence: 'high' | 'medium';
6
6
  label: string;
7
7
  }
8
+ /** Registry patterns must be bounded before compilation (ReDoS guard). */
9
+ export declare function isBoundedRegistryPattern(source: string): boolean;
10
+ /**
11
+ * Skip compiling single-segment `[charset]{n,m}` patterns — they match git SHAs,
12
+ * UUID-shaped noise, and other corpus negatives. Patterns with literal structure
13
+ * outside char classes (Discord's dot-separated segments, Clerk's `sk_live_`, …)
14
+ * are specific enough to register as high-confidence detectors.
15
+ */
16
+ export declare function isSpecificRegistryPattern(source: string): boolean;
8
17
  export declare function allPatterns(): SecretPattern[];
9
18
  //# sourceMappingURL=patterns.d.ts.map
package/dist/patterns.js CHANGED
@@ -39,29 +39,116 @@ const HAND_PATTERNS = [
39
39
  { id: 'jwt', label: 'JWT token', source: `eyJ[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+` },
40
40
  { id: 'conn-string', label: 'database connection string', source: `(?:postgres|postgresql|mysql|mongodb(?:\\+srv)?|redis|amqp)://[^:\\s/]+:[^@\\s]+@` },
41
41
  ];
42
+ /** Registry patterns must be bounded before compilation (ReDoS guard). */
43
+ export function isBoundedRegistryPattern(source) {
44
+ if (source.length >= 256)
45
+ return false;
46
+ if (/\([^)]*[+*][^)]*\)[+*]/.test(source))
47
+ return false;
48
+ if (!/\{\d+(?:,\d*)?\}/.test(source))
49
+ return false;
50
+ for (const match of source.matchAll(/\{(\d+)(?:,(\d*))?\}/g)) {
51
+ const low = Number(match[1]);
52
+ const high = match[2] === undefined || match[2] === '' ? low : Number(match[2]);
53
+ if (low > 256 || high > 256)
54
+ return false;
55
+ }
56
+ return true;
57
+ }
58
+ /**
59
+ * Skip compiling single-segment `[charset]{n,m}` patterns — they match git SHAs,
60
+ * UUID-shaped noise, and other corpus negatives. Patterns with literal structure
61
+ * outside char classes (Discord's dot-separated segments, Clerk's `sk_live_`, …)
62
+ * are specific enough to register as high-confidence detectors.
63
+ */
64
+ export function isSpecificRegistryPattern(source) {
65
+ const body = stripPatternAnchors(source);
66
+ const withoutClasses = body.replace(/\[[^\]]*\]/g, '\0');
67
+ if (/[^\0\d{},+?*|\\]/.test(withoutClasses))
68
+ return true;
69
+ return (body.match(/\{\d+(?:,\d*)?\}/g) ?? []).length > 1;
70
+ }
71
+ function shouldUseRegistryPattern(entry) {
72
+ if (entry.pattern === undefined)
73
+ return false;
74
+ if (!isBoundedRegistryPattern(entry.pattern))
75
+ return false;
76
+ if (isUuidValidationPattern(entry.pattern))
77
+ return false;
78
+ return isSpecificRegistryPattern(entry.pattern);
79
+ }
80
+ function stripPatternAnchors(source) {
81
+ let body = source;
82
+ if (body.startsWith('^'))
83
+ body = body.slice(1);
84
+ if (body.endsWith('$'))
85
+ body = body.slice(0, -1);
86
+ return body;
87
+ }
88
+ function isUuidValidationPattern(source) {
89
+ const normalized = stripPatternAnchors(source)
90
+ .toLowerCase()
91
+ .replace(/\[[0-9a-f-]+\]/g, '[hex]');
92
+ return normalized === '[hex]{8}-[hex]{4}-[hex]{4}-[hex]{4}-[hex]{12}';
93
+ }
94
+ function withLeadingBoundary(source) {
95
+ return source.startsWith('(?') ? source : `(?<![A-Za-z0-9_-])${source}`;
96
+ }
97
+ function registryPatternToRegex(source) {
98
+ return new RegExp(withLeadingBoundary(stripPatternAnchors(source)), 'g');
99
+ }
100
+ function synthesizePrefixBody(pattern) {
101
+ if (pattern === undefined)
102
+ return '[A-Za-z0-9]{16,}';
103
+ if (/\[A-Za-z0-9_\\-]/.test(pattern) || /\[A-Za-z0-9_-]/.test(pattern)) {
104
+ return '[A-Za-z0-9_\\-]{16,}';
105
+ }
106
+ if (/\[A-Za-z0-9_]/.test(pattern))
107
+ return '[A-Za-z0-9_]{16,}';
108
+ return '[A-Za-z0-9]{16,}';
109
+ }
110
+ function registryEntryPattern(entry) {
111
+ const id = `registry:${entry.providerId}:${entry.envVar}`;
112
+ const label = `${entry.providerId} ${entry.envVar}`;
113
+ if (entry.pattern !== undefined && shouldUseRegistryPattern(entry)) {
114
+ try {
115
+ return {
116
+ id,
117
+ regex: registryPatternToRegex(entry.pattern),
118
+ providerId: entry.providerId,
119
+ confidence: 'high',
120
+ label,
121
+ };
122
+ }
123
+ catch {
124
+ // Fall through to prefix synthesis when compilation fails.
125
+ }
126
+ }
127
+ if (entry.prefix === undefined)
128
+ return null;
129
+ // Two guards, both load-bearing for registry-derived patterns synthesised
130
+ // from a bare prefix when no bounded pattern is available:
131
+ //
132
+ // 1. A leading boundary. Without it a short prefix matches mid-identifier —
133
+ // Twilio's `AC` matched inside `REACT_APP_FEATURE_FLAG_ENABLED`, turning
134
+ // an ordinary env-var name into a "high confidence" credential hit.
135
+ // 2. A body charset inferred from the registry pattern when present so keys
136
+ // that allow `_` (Clerk `sk_test_…`) are not forced through `[A-Za-z0-9]`.
137
+ const body = synthesizePrefixBody(entry.pattern);
138
+ return {
139
+ id,
140
+ regex: new RegExp(`(?<![A-Za-z0-9_-])${escapeRegExp(entry.prefix)}${body}`, 'g'),
141
+ providerId: entry.providerId,
142
+ confidence: 'high',
143
+ label,
144
+ };
145
+ }
42
146
  export function allPatterns() {
43
147
  const patterns = [];
44
148
  for (const entry of allPrefixPatterns()) {
45
- if (entry.prefix === undefined) {
46
- continue;
47
- }
48
- // Two guards, both load-bearing for registry-derived patterns, which are
49
- // synthesised from a bare prefix and so are far weaker than the hand-written
50
- // ones:
51
- //
52
- // 1. A leading boundary. Without it a short prefix matches mid-identifier —
53
- // Twilio's `AC` matched inside `REACT_APP_FEATURE_FLAG_ENABLED`, turning
54
- // an ordinary env-var name into a "high confidence" credential hit.
55
- // 2. An alphanumeric-only body. Real key material is random alphanumerics;
56
- // SCREAMING_SNAKE identifiers are not. Excluding `_` and `-` from the body
57
- // stops the pattern from running through word separators.
58
- patterns.push({
59
- id: `registry:${entry.providerId}:${entry.envVar}`,
60
- regex: new RegExp(`(?<![A-Za-z0-9_-])${escapeRegExp(entry.prefix)}[A-Za-z0-9]{16,}`, 'g'),
61
- providerId: entry.providerId,
62
- confidence: 'high',
63
- label: `${entry.providerId} ${entry.envVar}`,
64
- });
149
+ const compiled = registryEntryPattern(entry);
150
+ if (compiled !== null)
151
+ patterns.push(compiled);
65
152
  }
66
153
  for (const hp of HAND_PATTERNS) {
67
154
  patterns.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envseal/detector",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "main": "./dist/index.js",
@@ -20,7 +20,7 @@
20
20
  "provenance": true
21
21
  },
22
22
  "dependencies": {
23
- "@envseal/registry": "0.1.2"
23
+ "@envseal/registry": "0.1.4"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",