@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.
- package/dist/exclusions.js +116 -14
- 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)
|
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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 &&
|
|
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 &&
|
|
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
|
}
|
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.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.
|
|
23
|
+
"@envseal/registry": "0.1.4"
|
|
24
24
|
},
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|