@crewhaus/tool-secure 0.7.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 +273 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +1006 -0
- package/dist/lib/entropy.d.ts +65 -0
- package/dist/lib/entropy.js +99 -0
- package/dist/lib/evidence.d.ts +55 -0
- package/dist/lib/evidence.js +134 -0
- package/dist/lib/files.d.ts +71 -0
- package/dist/lib/files.js +227 -0
- package/dist/lib/injection.d.ts +38 -0
- package/dist/lib/injection.js +396 -0
- package/dist/lib/pii.d.ts +91 -0
- package/dist/lib/pii.js +757 -0
- package/dist/lib/policy.d.ts +42 -0
- package/dist/lib/policy.js +138 -0
- package/dist/lib/redact.d.ts +41 -0
- package/dist/lib/redact.js +121 -0
- package/dist/lib/secrets.d.ts +111 -0
- package/dist/lib/secrets.js +329 -0
- package/dist/lib/text.d.ts +129 -0
- package/dist/lib/text.js +191 -0
- package/dist/lib/unicode.d.ts +106 -0
- package/dist/lib/unicode.js +431 -0
- package/dist/lib/url.d.ts +35 -0
- package/dist/lib/url.js +363 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +45 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential detection.
|
|
3
|
+
*
|
|
4
|
+
* ## The one hard rule
|
|
5
|
+
*
|
|
6
|
+
* A finding never carries the secret. Every value that reaches a result goes
|
|
7
|
+
* through `maskValue` first, and the tests assert that the serialized result
|
|
8
|
+
* of scanning a known credential does not contain that credential. A scanner
|
|
9
|
+
* that prints what it found has moved the secret from a file into a
|
|
10
|
+
* transcript, a log and a model's context window — three more places it has
|
|
11
|
+
* to be rotated out of.
|
|
12
|
+
*
|
|
13
|
+
* ## What the rules are
|
|
14
|
+
*
|
|
15
|
+
* Two kinds. VENDOR rules key on a published, distinctive prefix
|
|
16
|
+
* (`AKIA…`, `ghp_…`, `sk-ant-…`); they are precise and they go stale — a
|
|
17
|
+
* provider that changes its key format silently stops being covered here.
|
|
18
|
+
* STRUCTURAL rules key on shape: a PEM block, a JWT, a URL with a password
|
|
19
|
+
* in the authority, an assignment to something called `password`.
|
|
20
|
+
*
|
|
21
|
+
* ## What it cannot do
|
|
22
|
+
*
|
|
23
|
+
* It cannot tell a live key from a revoked one, a real key from an example
|
|
24
|
+
* in documentation, or a missing rule from a clean file. `SecretScan`
|
|
25
|
+
* reports the rule list it ran precisely so that "no findings" reads as
|
|
26
|
+
* "none of THESE matched", which is the only claim it can support.
|
|
27
|
+
*
|
|
28
|
+
* The high-entropy rule is deliberately context-gated by default: commit
|
|
29
|
+
* hashes, UUIDs, base64 images and minified code are all high entropy, and a
|
|
30
|
+
* scanner that cries wolf on them gets switched off.
|
|
31
|
+
*/
|
|
32
|
+
import { classifyCharset, looksHighEntropy, roundBits, shannonEntropy, } from "./entropy";
|
|
33
|
+
import { groupSpan, maskValue, matchAll, sortFindings, withPositions } from "./text";
|
|
34
|
+
/**
|
|
35
|
+
* The rule table, in scan order. Ids are stable: an operator's allow-list of
|
|
36
|
+
* accepted findings keys on them.
|
|
37
|
+
*/
|
|
38
|
+
export const SECRET_RULES = [
|
|
39
|
+
{
|
|
40
|
+
id: "aws.access-key-id",
|
|
41
|
+
kind: "vendor",
|
|
42
|
+
description: "AWS access key id (AKIA/ASIA/… followed by 16 upper-case base32 characters)",
|
|
43
|
+
severity: "high",
|
|
44
|
+
pattern: /\b(?:AKIA|ASIA|AIDA|AROA|AIPA|ANPA|ANVA|ABIA|ACCA)[0-9A-Z]{16}\b/g,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "aws.secret-access-key",
|
|
48
|
+
kind: "vendor",
|
|
49
|
+
description: "AWS secret access key assigned to an aws_secret_access_key-style name",
|
|
50
|
+
severity: "critical",
|
|
51
|
+
pattern: /aws_?secret_?access_?key["']?\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})/gi,
|
|
52
|
+
group: 1,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "github.token",
|
|
56
|
+
kind: "vendor",
|
|
57
|
+
description: "GitHub personal access, OAuth, user, server or refresh token (gh[pousr]_…)",
|
|
58
|
+
severity: "critical",
|
|
59
|
+
pattern: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "github.fine-grained-pat",
|
|
63
|
+
kind: "vendor",
|
|
64
|
+
description: "GitHub fine-grained personal access token (github_pat_…)",
|
|
65
|
+
severity: "critical",
|
|
66
|
+
pattern: /\bgithub_pat_[A-Za-z0-9_]{60,}\b/g,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "gitlab.token",
|
|
70
|
+
kind: "vendor",
|
|
71
|
+
description: "GitLab personal or project access token (glpat-…)",
|
|
72
|
+
severity: "critical",
|
|
73
|
+
pattern: /\bglpat-[A-Za-z0-9_-]{20,}\b/g,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "slack.token",
|
|
77
|
+
kind: "vendor",
|
|
78
|
+
description: "Slack bot, user, app or refresh token (xox[abposr]-…)",
|
|
79
|
+
severity: "critical",
|
|
80
|
+
pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/g,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "slack.webhook",
|
|
84
|
+
kind: "vendor",
|
|
85
|
+
description: "Slack incoming-webhook URL, which is itself the credential",
|
|
86
|
+
severity: "high",
|
|
87
|
+
pattern: /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/+_-]{20,}/g,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "stripe.secret-key",
|
|
91
|
+
kind: "vendor",
|
|
92
|
+
description: "Stripe secret or restricted key (sk_live/sk_test/rk_…)",
|
|
93
|
+
severity: "critical",
|
|
94
|
+
pattern: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "stripe.publishable-key",
|
|
98
|
+
kind: "vendor",
|
|
99
|
+
description: "Stripe publishable key — public by design, reported so it is not mistaken for a secret",
|
|
100
|
+
severity: "low",
|
|
101
|
+
pattern: /\bpk_(?:live|test)_[A-Za-z0-9]{16,}\b/g,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "anthropic.api-key",
|
|
105
|
+
kind: "vendor",
|
|
106
|
+
description: "Anthropic API key (sk-ant-…)",
|
|
107
|
+
severity: "critical",
|
|
108
|
+
pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "openai.api-key",
|
|
112
|
+
kind: "vendor",
|
|
113
|
+
description: "OpenAI API key (sk-… / sk-proj-…)",
|
|
114
|
+
severity: "critical",
|
|
115
|
+
pattern: /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}\b/g,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: "google.api-key",
|
|
119
|
+
kind: "vendor",
|
|
120
|
+
description: "Google API key (AIza followed by 35 characters)",
|
|
121
|
+
severity: "high",
|
|
122
|
+
pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "sendgrid.api-key",
|
|
126
|
+
kind: "vendor",
|
|
127
|
+
description: "SendGrid API key (SG.<id>.<secret>)",
|
|
128
|
+
severity: "critical",
|
|
129
|
+
pattern: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "twilio.api-key",
|
|
133
|
+
kind: "vendor",
|
|
134
|
+
description: "Twilio API key SID (SK followed by 32 hex characters)",
|
|
135
|
+
severity: "high",
|
|
136
|
+
pattern: /\bSK[0-9a-fA-F]{32}\b/g,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "npm.token",
|
|
140
|
+
kind: "vendor",
|
|
141
|
+
description: "npm access token (npm_…)",
|
|
142
|
+
severity: "critical",
|
|
143
|
+
pattern: /\bnpm_[A-Za-z0-9]{36}\b/g,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: "pypi.token",
|
|
147
|
+
kind: "vendor",
|
|
148
|
+
description: "PyPI upload token (pypi-…)",
|
|
149
|
+
severity: "critical",
|
|
150
|
+
pattern: /\bpypi-[A-Za-z0-9_-]{50,}\b/g,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: "private-key.pem-block",
|
|
154
|
+
kind: "structural",
|
|
155
|
+
description: "PEM private-key header; the key body follows it",
|
|
156
|
+
severity: "critical",
|
|
157
|
+
pattern: /-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: "jwt.compact",
|
|
161
|
+
kind: "structural",
|
|
162
|
+
description: "JWT in compact serialization; a bearer token unless it is a public id token",
|
|
163
|
+
severity: "medium",
|
|
164
|
+
pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "url.credentials",
|
|
168
|
+
kind: "structural",
|
|
169
|
+
description: "Connection string or URL carrying a password in the authority (scheme://user:pass@host)",
|
|
170
|
+
severity: "high",
|
|
171
|
+
pattern: /\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:@/]{1,64}:([^\s@/]{1,128})@[^\s/]{1,255}/gi,
|
|
172
|
+
group: 1,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: "http.basic-auth-header",
|
|
176
|
+
kind: "structural",
|
|
177
|
+
description: "HTTP Basic Authorization header; the base64 decodes to user:password",
|
|
178
|
+
severity: "high",
|
|
179
|
+
pattern: /authorization\s*:\s*basic\s+([A-Za-z0-9+/=]{8,})/gi,
|
|
180
|
+
group: 1,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "generic.assignment",
|
|
184
|
+
kind: "structural",
|
|
185
|
+
description: "A value assigned to a password/secret/token/api-key-shaped name",
|
|
186
|
+
severity: "medium",
|
|
187
|
+
pattern: /\b(?:password|passwd|pwd|secret|client_secret|token|access_token|auth_token|api[_-]?key|apikey|private[_-]?key|credential)["']?\s*[:=]\s*["']([^"'\n]{8,200})["']/gi,
|
|
188
|
+
group: 1,
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
const RULES_BY_ID = new Map(SECRET_RULES.map((r) => [r.id, r]));
|
|
192
|
+
/**
|
|
193
|
+
* Values that match a rule but are placeholders, not credentials. Kept short
|
|
194
|
+
* and literal; anything cleverer would start guessing.
|
|
195
|
+
*/
|
|
196
|
+
const PLACEHOLDER = /^(?:x{3,}|\*{3,}|\.{3,}|<[^>]*>|\$\{[^}]*\}|\{\{[^}]*\}\}|(?:your|my|the|some|example|sample|dummy|fake|test|placeholder|redacted|changeme|insert)[_a-z0-9-]*)$/i;
|
|
197
|
+
/** Context that makes a high-entropy string worth reporting. */
|
|
198
|
+
const CREDENTIAL_CONTEXT = /(?:pass(?:word|wd)?|secret|token|api[_-]?key|apikey|auth|bearer|credential|private[_-]?key|access[_-]?key|session|signature)/i;
|
|
199
|
+
const HIGH_ENTROPY_CANDIDATE = /[A-Za-z0-9+/=_-]{20,120}/g;
|
|
200
|
+
export class SecretRuleError extends Error {
|
|
201
|
+
name = "SecretRuleError";
|
|
202
|
+
}
|
|
203
|
+
/** The id the context-gated entropy heuristic reports under. */
|
|
204
|
+
export const HIGH_ENTROPY_RULE = "generic.high-entropy";
|
|
205
|
+
/**
|
|
206
|
+
* Every rule id a given options object will report under, including the
|
|
207
|
+
* entropy heuristic when it is enabled. `SecretScan` prints this so an empty
|
|
208
|
+
* result reads as a scope rather than a verdict, and `secretFindings` below
|
|
209
|
+
* runs exactly this set — the list and the scan cannot drift apart.
|
|
210
|
+
*/
|
|
211
|
+
export function rulesRunFor(options = {}) {
|
|
212
|
+
const ids = selectRules(options).map((r) => r.id);
|
|
213
|
+
return options.highEntropy === false ? ids : [...ids, HIGH_ENTROPY_RULE];
|
|
214
|
+
}
|
|
215
|
+
/** The rules a given options object will actually run, in scan order. */
|
|
216
|
+
export function selectRules(options = {}) {
|
|
217
|
+
if (options.rules === undefined)
|
|
218
|
+
return [...SECRET_RULES];
|
|
219
|
+
const unknown = options.rules.filter((id) => !RULES_BY_ID.has(id));
|
|
220
|
+
if (unknown.length > 0) {
|
|
221
|
+
throw new SecretRuleError(`unknown secret rule id(s): ${unknown.join(", ")} — known ids are ${SECRET_RULES.map((r) => r.id).join(", ")}`);
|
|
222
|
+
}
|
|
223
|
+
return SECRET_RULES.filter((r) => options.rules?.includes(r.id));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Every located secret in the text, values included, in document order.
|
|
227
|
+
*
|
|
228
|
+
* This is the ONE implementation. `scanSecrets` masks it for a result and
|
|
229
|
+
* `secretSpans` hands it to a redactor unmasked; there is no second copy of
|
|
230
|
+
* the rule loop to fall out of step with this one, which is exactly what let
|
|
231
|
+
* `RedactForExport` report a high-entropy finding and then not remove it.
|
|
232
|
+
*/
|
|
233
|
+
export function secretFindings(text, options = {}) {
|
|
234
|
+
const rules = selectRules(options);
|
|
235
|
+
const raw = [];
|
|
236
|
+
for (const rule of rules) {
|
|
237
|
+
for (const { match } of matchAll(text, rule.pattern)) {
|
|
238
|
+
const group = rule.group ?? 0;
|
|
239
|
+
const value = match[group];
|
|
240
|
+
if (value === undefined || value.length === 0)
|
|
241
|
+
continue;
|
|
242
|
+
if (PLACEHOLDER.test(value))
|
|
243
|
+
continue;
|
|
244
|
+
// The group's REAL offset. Searching the match for the group's own text
|
|
245
|
+
// finds the wrong copy when the value also appears earlier in the match,
|
|
246
|
+
// which puts the span on a username and leaves the password in place.
|
|
247
|
+
const span = groupSpan(match, group);
|
|
248
|
+
if (span === undefined)
|
|
249
|
+
continue;
|
|
250
|
+
raw.push({
|
|
251
|
+
type: rule.id,
|
|
252
|
+
rule: rule.id,
|
|
253
|
+
confidence: rule.kind === "vendor" ? "likely" : "possible",
|
|
254
|
+
start: span.start,
|
|
255
|
+
end: span.end,
|
|
256
|
+
value,
|
|
257
|
+
detail: { severity: rule.severity },
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (options.highEntropy !== false) {
|
|
262
|
+
const requireContext = options.requireContext !== false;
|
|
263
|
+
for (const { index, match } of matchAll(text, HIGH_ENTROPY_CANDIDATE)) {
|
|
264
|
+
const value = match[0];
|
|
265
|
+
if (PLACEHOLDER.test(value))
|
|
266
|
+
continue;
|
|
267
|
+
const { high } = looksHighEntropy(value, options.minEntropy);
|
|
268
|
+
if (!high)
|
|
269
|
+
continue;
|
|
270
|
+
if (requireContext) {
|
|
271
|
+
const before = text.slice(Math.max(0, index - 60), index);
|
|
272
|
+
if (!CREDENTIAL_CONTEXT.test(before))
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
// A vendor rule already covering this span is the better finding.
|
|
276
|
+
if (raw.some((f) => index < f.end && f.start < index + value.length))
|
|
277
|
+
continue;
|
|
278
|
+
raw.push({
|
|
279
|
+
type: HIGH_ENTROPY_RULE,
|
|
280
|
+
rule: HIGH_ENTROPY_RULE,
|
|
281
|
+
confidence: "possible",
|
|
282
|
+
start: index,
|
|
283
|
+
end: index + value.length,
|
|
284
|
+
value,
|
|
285
|
+
detail: { severity: "low" },
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return sortFindings(withPositions(text, raw));
|
|
290
|
+
}
|
|
291
|
+
/** Scan text. The returned hits are already masked. */
|
|
292
|
+
export function scanSecrets(text, options = {}) {
|
|
293
|
+
return secretFindings(text, options).map((f) => {
|
|
294
|
+
const rule = RULES_BY_ID.get(f.rule);
|
|
295
|
+
const { bits } = shannonEntropy(f.value);
|
|
296
|
+
return {
|
|
297
|
+
rule: f.rule,
|
|
298
|
+
kind: rule?.kind ?? "structural",
|
|
299
|
+
severity: (rule?.severity ?? "low"),
|
|
300
|
+
description: rule?.description ??
|
|
301
|
+
"opaque high-entropy string near a credential-shaped word (heuristic, not a vendor rule)",
|
|
302
|
+
line: f.line,
|
|
303
|
+
column: f.column,
|
|
304
|
+
start: f.start,
|
|
305
|
+
length: f.value.length,
|
|
306
|
+
masked: maskValue(f.value),
|
|
307
|
+
entropyBits: roundBits(bits),
|
|
308
|
+
charset: classifyCharset(f.value),
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The spans a redactor should replace. Same rules, same options, same spans
|
|
314
|
+
* as `scanSecrets` — including the entropy heuristic, so a document cannot be
|
|
315
|
+
* reported as holding a secret and handed back with that secret still in it.
|
|
316
|
+
*
|
|
317
|
+
* `Finding`s carry their value because the caller is about to remove them; it
|
|
318
|
+
* is the scanner's OUTPUT that must never carry one.
|
|
319
|
+
*/
|
|
320
|
+
export function secretSpans(text, options = {}) {
|
|
321
|
+
return secretFindings(text, options);
|
|
322
|
+
}
|
|
323
|
+
/** Summarize hits by severity, highest first, for a one-line verdict. */
|
|
324
|
+
export function severityCounts(hits) {
|
|
325
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
326
|
+
for (const hit of hits)
|
|
327
|
+
counts[hit.severity] += 1;
|
|
328
|
+
return counts;
|
|
329
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared spine of this package: located spans, the size caps every
|
|
3
|
+
* entry point applies, and the masking helper that keeps a finding from
|
|
4
|
+
* becoming a leak.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here is a detector. Detectors live next door and all of them
|
|
7
|
+
* speak in `Finding`s, so one renderer, one de-duplicator and one redactor
|
|
8
|
+
* serve every scan.
|
|
9
|
+
*/
|
|
10
|
+
/** A located, classified hit. `value` is NEVER put in a tool result verbatim. */
|
|
11
|
+
export type Finding = {
|
|
12
|
+
/** What kind of thing this is, e.g. `email`, `aws.access-key-id`. */
|
|
13
|
+
readonly type: string;
|
|
14
|
+
/** The named rule that matched, so a reviewer can look it up. */
|
|
15
|
+
readonly rule: string;
|
|
16
|
+
/** How much the rule proves. See `Confidence`. */
|
|
17
|
+
readonly confidence: Confidence;
|
|
18
|
+
/** UTF-16 code-unit offset of the first character. */
|
|
19
|
+
readonly start: number;
|
|
20
|
+
/** UTF-16 code-unit offset one past the last character. */
|
|
21
|
+
readonly end: number;
|
|
22
|
+
/** 1-based line of `start`. */
|
|
23
|
+
readonly line: number;
|
|
24
|
+
/** 1-based column of `start`, counted in code units. */
|
|
25
|
+
readonly column: number;
|
|
26
|
+
/** The matched text. Internal only — mask or drop it before returning. */
|
|
27
|
+
readonly value: string;
|
|
28
|
+
/** Optional rule-specific detail (brand, country, entropy, …). */
|
|
29
|
+
readonly detail?: Readonly<Record<string, string | number | boolean>>;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* What a match is worth.
|
|
33
|
+
*
|
|
34
|
+
* - `verified` — a check digit passed (Luhn, ISO 7064 mod-97). The string is
|
|
35
|
+
* a structurally valid instance of the format. It still says nothing about
|
|
36
|
+
* whether the number was ever issued, or to whom.
|
|
37
|
+
* - `likely` — a distinctive shape plus corroborating context (a labelled
|
|
38
|
+
* field, a vendor-specific prefix).
|
|
39
|
+
* - `possible` — shape alone. Expect false positives.
|
|
40
|
+
*
|
|
41
|
+
* No level means "this is personal data" or "this is a live credential".
|
|
42
|
+
* Those are judgements about the world; these rules only see bytes.
|
|
43
|
+
*/
|
|
44
|
+
export type Confidence = "verified" | "likely" | "possible";
|
|
45
|
+
/**
|
|
46
|
+
* The ceiling on any text a tool in this package will examine.
|
|
47
|
+
*
|
|
48
|
+
* Every scanner is linear in the input, but a caller that hands over a
|
|
49
|
+
* 200 MB string has already lost: the string, the findings and the redacted
|
|
50
|
+
* copy all sit in memory at once. Refusing is the honest answer.
|
|
51
|
+
*/
|
|
52
|
+
export declare const MAX_TEXT_CHARS = 2000000;
|
|
53
|
+
/** Raised for a caller mistake. Tools catch it and return the message. */
|
|
54
|
+
export declare class SecureInputError extends Error {
|
|
55
|
+
readonly name = "SecureInputError";
|
|
56
|
+
}
|
|
57
|
+
/** Refuse an over-large string before any scanning work begins. */
|
|
58
|
+
export declare function assertTextSize(text: string, field: string, limit?: number): void;
|
|
59
|
+
/** Offsets at which each line starts; `[0]` is always 0. */
|
|
60
|
+
export declare function lineStarts(text: string): number[];
|
|
61
|
+
/** 1-based line and column for an offset, against a prepared `lineStarts`. */
|
|
62
|
+
export declare function locate(starts: ReadonlyArray<number>, offset: number): {
|
|
63
|
+
line: number;
|
|
64
|
+
column: number;
|
|
65
|
+
};
|
|
66
|
+
/** Locale-independent ordering. `localeCompare` would make results host-dependent. */
|
|
67
|
+
export declare function compareStrings(a: string, b: string): number;
|
|
68
|
+
/**
|
|
69
|
+
* Mask a value so a finding can be reviewed without the value leaking.
|
|
70
|
+
*
|
|
71
|
+
* The rule: at most `keep` leading and `keep` trailing characters survive,
|
|
72
|
+
* and only when the value is long enough that those characters are a small
|
|
73
|
+
* fraction of it. Anything shorter is masked whole. The length is reported
|
|
74
|
+
* separately, because length is useful and is not the secret.
|
|
75
|
+
*
|
|
76
|
+
* This is the one function standing between `SecretScan` and a tool result
|
|
77
|
+
* that contains a live credential. It is tested directly.
|
|
78
|
+
*/
|
|
79
|
+
export declare function maskValue(value: string, keep?: number): string;
|
|
80
|
+
/**
|
|
81
|
+
* Drop findings swallowed by a stronger one.
|
|
82
|
+
*
|
|
83
|
+
* Overlap is common and mostly noise: the digit run inside an IBAN also
|
|
84
|
+
* looks like a phone number, and a JWT is three high-entropy blobs. The
|
|
85
|
+
* survivor is chosen by confidence, then by length, then by earliest start,
|
|
86
|
+
* then by rule name — a total order, so the same input always yields the
|
|
87
|
+
* same set.
|
|
88
|
+
*/
|
|
89
|
+
export declare function dedupeOverlaps(findings: ReadonlyArray<Finding>): Finding[];
|
|
90
|
+
/** Reading order, with a deterministic tiebreak. */
|
|
91
|
+
export declare function sortFindings(findings: ReadonlyArray<Finding>): Finding[];
|
|
92
|
+
/** Attach line/column to raw offsets in one pass over the text. */
|
|
93
|
+
export declare function withPositions(text: string, raw: ReadonlyArray<Omit<Finding, "line" | "column">>): Finding[];
|
|
94
|
+
/**
|
|
95
|
+
* Replace each span with the string `replace` returns for it.
|
|
96
|
+
*
|
|
97
|
+
* Spans must not overlap (`dedupeOverlaps` guarantees that); they are
|
|
98
|
+
* applied right to left so earlier offsets stay valid.
|
|
99
|
+
*/
|
|
100
|
+
export declare function applySpans(text: string, findings: ReadonlyArray<Finding>, replace: (finding: Finding) => string): string;
|
|
101
|
+
/** Tally findings by `type`, sorted by type so the record is stable. */
|
|
102
|
+
export declare function countByType(findings: ReadonlyArray<Finding>): Record<string, number>;
|
|
103
|
+
/**
|
|
104
|
+
* Run a global regex over text, yielding matches with their offsets.
|
|
105
|
+
*
|
|
106
|
+
* The pattern is cloned so a caller's `lastIndex` cannot leak between calls,
|
|
107
|
+
* and a zero-length match advances the cursor rather than spinning forever.
|
|
108
|
+
*
|
|
109
|
+
* The clone always carries `g` and `d`. `d` matters: a rule that captures the
|
|
110
|
+
* secret in a group needs the group's REAL offset, and searching the match
|
|
111
|
+
* text for the group's own text finds the wrong copy whenever the value also
|
|
112
|
+
* appears earlier in the match — `scheme://admin_hunter2:hunter2@host` is the
|
|
113
|
+
* shape that does it. `groupSpan` below is how a caller gets that offset.
|
|
114
|
+
*/
|
|
115
|
+
export declare function matchAll(text: string, pattern: RegExp): Generator<{
|
|
116
|
+
index: number;
|
|
117
|
+
match: RegExpExecArray;
|
|
118
|
+
}>;
|
|
119
|
+
/**
|
|
120
|
+
* The absolute `[start, end)` of one capture group of a match produced by
|
|
121
|
+
* `matchAll`, or `undefined` when the group did not participate.
|
|
122
|
+
*
|
|
123
|
+
* Group 0 is the whole match. Any other group's offsets come from the `d`
|
|
124
|
+
* flag's indices, never from searching for the group's text inside the match.
|
|
125
|
+
*/
|
|
126
|
+
export declare function groupSpan(match: RegExpExecArray, group: number): {
|
|
127
|
+
start: number;
|
|
128
|
+
end: number;
|
|
129
|
+
} | undefined;
|
package/dist/lib/text.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared spine of this package: located spans, the size caps every
|
|
3
|
+
* entry point applies, and the masking helper that keeps a finding from
|
|
4
|
+
* becoming a leak.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here is a detector. Detectors live next door and all of them
|
|
7
|
+
* speak in `Finding`s, so one renderer, one de-duplicator and one redactor
|
|
8
|
+
* serve every scan.
|
|
9
|
+
*/
|
|
10
|
+
const CONFIDENCE_RANK = {
|
|
11
|
+
verified: 3,
|
|
12
|
+
likely: 2,
|
|
13
|
+
possible: 1,
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The ceiling on any text a tool in this package will examine.
|
|
17
|
+
*
|
|
18
|
+
* Every scanner is linear in the input, but a caller that hands over a
|
|
19
|
+
* 200 MB string has already lost: the string, the findings and the redacted
|
|
20
|
+
* copy all sit in memory at once. Refusing is the honest answer.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_TEXT_CHARS = 2_000_000;
|
|
23
|
+
/** Raised for a caller mistake. Tools catch it and return the message. */
|
|
24
|
+
export class SecureInputError extends Error {
|
|
25
|
+
name = "SecureInputError";
|
|
26
|
+
}
|
|
27
|
+
/** Refuse an over-large string before any scanning work begins. */
|
|
28
|
+
export function assertTextSize(text, field, limit = MAX_TEXT_CHARS) {
|
|
29
|
+
if (text.length > limit) {
|
|
30
|
+
throw new SecureInputError(`${field} is ${text.length} characters, over this package's ${limit} limit — split the input and scan the pieces`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Offsets at which each line starts; `[0]` is always 0. */
|
|
34
|
+
export function lineStarts(text) {
|
|
35
|
+
const starts = [0];
|
|
36
|
+
for (let i = 0; i < text.length; i++) {
|
|
37
|
+
if (text.charCodeAt(i) === 10)
|
|
38
|
+
starts.push(i + 1);
|
|
39
|
+
}
|
|
40
|
+
return starts;
|
|
41
|
+
}
|
|
42
|
+
/** 1-based line and column for an offset, against a prepared `lineStarts`. */
|
|
43
|
+
export function locate(starts, offset) {
|
|
44
|
+
let lo = 0;
|
|
45
|
+
let hi = starts.length - 1;
|
|
46
|
+
while (lo < hi) {
|
|
47
|
+
const mid = (lo + hi + 1) >> 1;
|
|
48
|
+
if ((starts[mid] ?? 0) <= offset)
|
|
49
|
+
lo = mid;
|
|
50
|
+
else
|
|
51
|
+
hi = mid - 1;
|
|
52
|
+
}
|
|
53
|
+
return { line: lo + 1, column: offset - (starts[lo] ?? 0) + 1 };
|
|
54
|
+
}
|
|
55
|
+
/** Locale-independent ordering. `localeCompare` would make results host-dependent. */
|
|
56
|
+
export function compareStrings(a, b) {
|
|
57
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Mask a value so a finding can be reviewed without the value leaking.
|
|
61
|
+
*
|
|
62
|
+
* The rule: at most `keep` leading and `keep` trailing characters survive,
|
|
63
|
+
* and only when the value is long enough that those characters are a small
|
|
64
|
+
* fraction of it. Anything shorter is masked whole. The length is reported
|
|
65
|
+
* separately, because length is useful and is not the secret.
|
|
66
|
+
*
|
|
67
|
+
* This is the one function standing between `SecretScan` and a tool result
|
|
68
|
+
* that contains a live credential. It is tested directly.
|
|
69
|
+
*/
|
|
70
|
+
export function maskValue(value, keep = 2) {
|
|
71
|
+
const n = value.length;
|
|
72
|
+
if (n === 0)
|
|
73
|
+
return "";
|
|
74
|
+
// Below this a prefix+suffix would expose too much of the value: at n = 12
|
|
75
|
+
// and keep = 2, four of twelve characters survive, and that is the floor.
|
|
76
|
+
if (keep <= 0 || n < 4 * keep + 4)
|
|
77
|
+
return "*".repeat(Math.min(n, 12));
|
|
78
|
+
const head = value.slice(0, keep);
|
|
79
|
+
const tail = value.slice(n - keep);
|
|
80
|
+
return `${head}${"*".repeat(Math.min(n - 2 * keep, 12))}${tail}`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Drop findings swallowed by a stronger one.
|
|
84
|
+
*
|
|
85
|
+
* Overlap is common and mostly noise: the digit run inside an IBAN also
|
|
86
|
+
* looks like a phone number, and a JWT is three high-entropy blobs. The
|
|
87
|
+
* survivor is chosen by confidence, then by length, then by earliest start,
|
|
88
|
+
* then by rule name — a total order, so the same input always yields the
|
|
89
|
+
* same set.
|
|
90
|
+
*/
|
|
91
|
+
export function dedupeOverlaps(findings) {
|
|
92
|
+
const ranked = [...findings].sort((a, b) => {
|
|
93
|
+
const byConfidence = CONFIDENCE_RANK[b.confidence] - CONFIDENCE_RANK[a.confidence];
|
|
94
|
+
if (byConfidence !== 0)
|
|
95
|
+
return byConfidence;
|
|
96
|
+
const byLength = b.end - b.start - (a.end - a.start);
|
|
97
|
+
if (byLength !== 0)
|
|
98
|
+
return byLength;
|
|
99
|
+
if (a.start !== b.start)
|
|
100
|
+
return a.start - b.start;
|
|
101
|
+
return compareStrings(a.rule, b.rule);
|
|
102
|
+
});
|
|
103
|
+
const kept = [];
|
|
104
|
+
for (const candidate of ranked) {
|
|
105
|
+
const clashes = kept.some((k) => candidate.start < k.end && k.start < candidate.end);
|
|
106
|
+
if (!clashes)
|
|
107
|
+
kept.push(candidate);
|
|
108
|
+
}
|
|
109
|
+
return sortFindings(kept);
|
|
110
|
+
}
|
|
111
|
+
/** Reading order, with a deterministic tiebreak. */
|
|
112
|
+
export function sortFindings(findings) {
|
|
113
|
+
return [...findings].sort((a, b) => a.start - b.start ||
|
|
114
|
+
a.end - b.end ||
|
|
115
|
+
compareStrings(a.type, b.type) ||
|
|
116
|
+
compareStrings(a.rule, b.rule));
|
|
117
|
+
}
|
|
118
|
+
/** Attach line/column to raw offsets in one pass over the text. */
|
|
119
|
+
export function withPositions(text, raw) {
|
|
120
|
+
const starts = lineStarts(text);
|
|
121
|
+
return raw.map((f) => ({ ...f, ...locate(starts, f.start) }));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Replace each span with the string `replace` returns for it.
|
|
125
|
+
*
|
|
126
|
+
* Spans must not overlap (`dedupeOverlaps` guarantees that); they are
|
|
127
|
+
* applied right to left so earlier offsets stay valid.
|
|
128
|
+
*/
|
|
129
|
+
export function applySpans(text, findings, replace) {
|
|
130
|
+
const ordered = sortFindings(findings);
|
|
131
|
+
let out = text;
|
|
132
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
133
|
+
const f = ordered[i];
|
|
134
|
+
if (!f)
|
|
135
|
+
continue;
|
|
136
|
+
out = out.slice(0, f.start) + replace(f) + out.slice(f.end);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
/** Tally findings by `type`, sorted by type so the record is stable. */
|
|
141
|
+
export function countByType(findings) {
|
|
142
|
+
const counts = new Map();
|
|
143
|
+
for (const f of findings)
|
|
144
|
+
counts.set(f.type, (counts.get(f.type) ?? 0) + 1);
|
|
145
|
+
const out = {};
|
|
146
|
+
for (const key of [...counts.keys()].sort(compareStrings))
|
|
147
|
+
out[key] = counts.get(key) ?? 0;
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Run a global regex over text, yielding matches with their offsets.
|
|
152
|
+
*
|
|
153
|
+
* The pattern is cloned so a caller's `lastIndex` cannot leak between calls,
|
|
154
|
+
* and a zero-length match advances the cursor rather than spinning forever.
|
|
155
|
+
*
|
|
156
|
+
* The clone always carries `g` and `d`. `d` matters: a rule that captures the
|
|
157
|
+
* secret in a group needs the group's REAL offset, and searching the match
|
|
158
|
+
* text for the group's own text finds the wrong copy whenever the value also
|
|
159
|
+
* appears earlier in the match — `scheme://admin_hunter2:hunter2@host` is the
|
|
160
|
+
* shape that does it. `groupSpan` below is how a caller gets that offset.
|
|
161
|
+
*/
|
|
162
|
+
export function* matchAll(text, pattern) {
|
|
163
|
+
let flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
|
164
|
+
if (!flags.includes("d"))
|
|
165
|
+
flags = `${flags}d`;
|
|
166
|
+
const re = new RegExp(pattern.source, flags);
|
|
167
|
+
for (;;) {
|
|
168
|
+
const m = re.exec(text);
|
|
169
|
+
if (m === null)
|
|
170
|
+
return;
|
|
171
|
+
yield { index: m.index, match: m };
|
|
172
|
+
if (m[0].length === 0)
|
|
173
|
+
re.lastIndex += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The absolute `[start, end)` of one capture group of a match produced by
|
|
178
|
+
* `matchAll`, or `undefined` when the group did not participate.
|
|
179
|
+
*
|
|
180
|
+
* Group 0 is the whole match. Any other group's offsets come from the `d`
|
|
181
|
+
* flag's indices, never from searching for the group's text inside the match.
|
|
182
|
+
*/
|
|
183
|
+
export function groupSpan(match, group) {
|
|
184
|
+
const whole = match[0];
|
|
185
|
+
if (group === 0)
|
|
186
|
+
return { start: match.index, end: match.index + whole.length };
|
|
187
|
+
const span = match.indices?.[group];
|
|
188
|
+
if (!span)
|
|
189
|
+
return undefined;
|
|
190
|
+
return { start: span[0], end: span[1] };
|
|
191
|
+
}
|