@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.
- package/dist/exclusions.js +56 -5
- package/dist/patterns.d.ts +9 -0
- package/dist/patterns.js +107 -20
- package/package.json +2 -2
package/dist/exclusions.js
CHANGED
|
@@ -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
|
-
|
|
194
|
-
|
|
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 &&
|
|
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 &&
|
|
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
|
}
|
package/dist/patterns.d.ts
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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
|
+
"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.
|
|
23
|
+
"@envseal/registry": "0.1.5"
|
|
24
24
|
},
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|