@envseal/detector 0.1.3 → 0.1.5

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)
@@ -119,9 +122,16 @@ function isDataUri(candidate, context) {
119
122
  }
120
123
  return true;
121
124
  }
122
- return /data:[a-z0-9.+-]+\/[a-z0-9.+-]+(;[a-z0-9-]+=[a-z0-9-]+)*;base64,[A-Za-z0-9+/=]+$/.test(context.before);
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);
123
129
  }
124
130
  function isFilesystemPath(candidate) {
131
+ if (/^\([^)]*[/\\][^)]*\)$/.test(candidate))
132
+ return true;
133
+ if (/\/[a-zA-Z0-9_./-]+:\d+(?::\d+)?$/.test(candidate))
134
+ return true;
125
135
  const hasPathSep = candidate.includes('/') || candidate.includes(String.fromCharCode(92));
126
136
  if (!hasPathSep)
127
137
  return false;
@@ -138,6 +148,9 @@ function isFilesystemPath(candidate) {
138
148
  return pathlike;
139
149
  }
140
150
  function isPlainUrl(candidate) {
151
+ if (/^(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^@\s]+/i.test(candidate) && !candidate.includes('@')) {
152
+ return true;
153
+ }
141
154
  try {
142
155
  const url = new URL(candidate);
143
156
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
@@ -190,20 +203,58 @@ function splitOnBoundaries(candidate) {
190
203
  });
191
204
  return result.filter((s) => s.length > 0);
192
205
  }
193
- function isCodeAdjacent(candidate, context) {
194
- 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 = /[{}(),:]/;
195
237
  if (context.before.length > 0) {
196
238
  const lastChar = context.before.at(-1);
197
- if (lastChar !== undefined && codeChars.test(lastChar) && !/\s/.test(lastChar))
239
+ if (lastChar !== undefined && structural.test(lastChar) && !/\s/.test(lastChar)) {
198
240
  return true;
241
+ }
199
242
  }
200
243
  if (context.after.length > 0) {
201
244
  const firstChar = context.after.at(0);
202
- if (firstChar !== undefined && codeChars.test(firstChar) && !/\s/.test(firstChar))
245
+ if (firstChar !== undefined && structural.test(firstChar) && !/\s/.test(firstChar)) {
203
246
  return true;
247
+ }
204
248
  }
205
249
  return false;
206
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
+ }
207
258
  function isNumeric(candidate) {
208
259
  return /^\d+$/.test(candidate);
209
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.3",
3
+ "version": "0.1.5",
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.3"
23
+ "@envseal/registry": "0.1.5"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",