@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,42 @@
|
|
|
1
|
+
export declare const POLICY_RULE_KINDS: readonly ["forbidden_pattern", "forbidden_phrase", "required_pattern", "required_phrase", "review_pattern"];
|
|
2
|
+
export type PolicyRuleKind = (typeof POLICY_RULE_KINDS)[number];
|
|
3
|
+
export type PolicyRule = {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly kind: PolicyRuleKind;
|
|
6
|
+
/** A literal for `*_phrase` rules; a JavaScript regex source for `*_pattern`. */
|
|
7
|
+
readonly value: string;
|
|
8
|
+
readonly caseSensitive?: boolean;
|
|
9
|
+
readonly description?: string;
|
|
10
|
+
};
|
|
11
|
+
export type PolicyMatch = {
|
|
12
|
+
readonly line: number;
|
|
13
|
+
readonly column: number;
|
|
14
|
+
readonly excerpt: string;
|
|
15
|
+
};
|
|
16
|
+
export type PolicyOutcome = {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly kind: PolicyRuleKind;
|
|
19
|
+
readonly status: "pass" | "fail" | "review" | "error";
|
|
20
|
+
readonly message: string;
|
|
21
|
+
readonly matches: ReadonlyArray<PolicyMatch>;
|
|
22
|
+
readonly matchCount: number;
|
|
23
|
+
};
|
|
24
|
+
export type PolicyResult = {
|
|
25
|
+
/**
|
|
26
|
+
* True when no rule produced `fail` AND none produced `error`. A rule that
|
|
27
|
+
* did not compile was not evaluated, so the check cannot claim to have
|
|
28
|
+
* passed. `review` outcomes do not fail; they queue for a person.
|
|
29
|
+
*/
|
|
30
|
+
readonly pass: boolean;
|
|
31
|
+
readonly outcomes: ReadonlyArray<PolicyOutcome>;
|
|
32
|
+
readonly counts: Readonly<Record<PolicyOutcome["status"], number>>;
|
|
33
|
+
};
|
|
34
|
+
/** Bounds the rule set: a policy is a page of rules, not a corpus. */
|
|
35
|
+
export declare const MAX_POLICY_RULES = 200;
|
|
36
|
+
/** Locations reported per rule. The count is always exact. */
|
|
37
|
+
export declare const MAX_MATCHES_PER_RULE = 20;
|
|
38
|
+
export declare class PolicyError extends Error {
|
|
39
|
+
readonly name = "PolicyError";
|
|
40
|
+
}
|
|
41
|
+
/** Evaluate every rule against the text. One pass per rule, no early exit. */
|
|
42
|
+
export declare function evaluatePolicy(text: string, rules: ReadonlyArray<PolicyRule>): PolicyResult;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator-declared content rules.
|
|
3
|
+
*
|
|
4
|
+
* Unlike every other module here, this one has no opinions: the rules come
|
|
5
|
+
* from the operator, and the evaluation is mechanical. "Does the disclaimer
|
|
6
|
+
* appear?", "does the word 'guaranteed' appear?", "does anything match this
|
|
7
|
+
* claim pattern?" — all answerable without judgement, which is exactly why
|
|
8
|
+
* they should not cost a model call.
|
|
9
|
+
*
|
|
10
|
+
* What it cannot do is decide whether a document COMPLIES with a policy.
|
|
11
|
+
* A required disclaimer can be present and wrong; a forbidden phrase can be
|
|
12
|
+
* absent while the same claim is made in other words. `review_pattern` is
|
|
13
|
+
* there for that: it is the rule kind that says "a person looks at this".
|
|
14
|
+
*
|
|
15
|
+
* A rule whose regular expression does not compile is reported as `error`
|
|
16
|
+
* for that rule alone, and an `error` outcome fails the check. The
|
|
17
|
+
* alternatives — refusing the whole evaluation, or silently treating the rule
|
|
18
|
+
* as no-match — either block work over a typo or quietly turn a policy off.
|
|
19
|
+
*
|
|
20
|
+
* ## The regex is the operator's, and it runs untimed
|
|
21
|
+
*
|
|
22
|
+
* `*_pattern` rules compile caller-supplied regex sources and run them over
|
|
23
|
+
* text up to the package's 2,000,000-character cap. JavaScript's engine
|
|
24
|
+
* backtracks, so a pattern like `(a+)+$` can take time exponential in the
|
|
25
|
+
* length of the text it fails on. There is no timeout here, because there is
|
|
26
|
+
* no way to interrupt a regex mid-match in this runtime. The rules are
|
|
27
|
+
* operator-written, not attacker-written, which is what makes this
|
|
28
|
+
* acceptable — but an operator who pastes a pattern from untrusted content
|
|
29
|
+
* has handed that content a stall. Write anchored patterns with no nested
|
|
30
|
+
* unbounded quantifier.
|
|
31
|
+
*/
|
|
32
|
+
import { compareStrings, matchAll } from "./text";
|
|
33
|
+
import { lineStarts, locate } from "./text";
|
|
34
|
+
export const POLICY_RULE_KINDS = [
|
|
35
|
+
"forbidden_pattern",
|
|
36
|
+
"forbidden_phrase",
|
|
37
|
+
"required_pattern",
|
|
38
|
+
"required_phrase",
|
|
39
|
+
"review_pattern",
|
|
40
|
+
];
|
|
41
|
+
/** Bounds the rule set: a policy is a page of rules, not a corpus. */
|
|
42
|
+
export const MAX_POLICY_RULES = 200;
|
|
43
|
+
/** Locations reported per rule. The count is always exact. */
|
|
44
|
+
export const MAX_MATCHES_PER_RULE = 20;
|
|
45
|
+
const EXCERPT_CHARS = 120;
|
|
46
|
+
export class PolicyError extends Error {
|
|
47
|
+
name = "PolicyError";
|
|
48
|
+
}
|
|
49
|
+
function escapeRegex(literal) {
|
|
50
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
51
|
+
}
|
|
52
|
+
/** Evaluate every rule against the text. One pass per rule, no early exit. */
|
|
53
|
+
export function evaluatePolicy(text, rules) {
|
|
54
|
+
if (rules.length > MAX_POLICY_RULES) {
|
|
55
|
+
throw new PolicyError(`${rules.length} rules, over the ${MAX_POLICY_RULES} limit`);
|
|
56
|
+
}
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const rule of rules) {
|
|
59
|
+
if (seen.has(rule.id)) {
|
|
60
|
+
throw new PolicyError(`duplicate rule id "${rule.id}" — ids identify a rule in the result`);
|
|
61
|
+
}
|
|
62
|
+
seen.add(rule.id);
|
|
63
|
+
}
|
|
64
|
+
const starts = lineStarts(text);
|
|
65
|
+
const outcomes = [];
|
|
66
|
+
for (const rule of rules) {
|
|
67
|
+
const flags = rule.caseSensitive === true ? "g" : "gi";
|
|
68
|
+
const isPattern = rule.kind.endsWith("_pattern");
|
|
69
|
+
let re;
|
|
70
|
+
try {
|
|
71
|
+
re = new RegExp(isPattern ? rule.value : escapeRegex(rule.value), flags);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
outcomes.push({
|
|
75
|
+
id: rule.id,
|
|
76
|
+
kind: rule.kind,
|
|
77
|
+
status: "error",
|
|
78
|
+
message: `rule not evaluated: invalid regular expression /${rule.value}/ — ${err.message}`,
|
|
79
|
+
matches: [],
|
|
80
|
+
matchCount: 0,
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const matches = [];
|
|
85
|
+
let matchCount = 0;
|
|
86
|
+
for (const { index, match } of matchAll(text, re)) {
|
|
87
|
+
matchCount += 1;
|
|
88
|
+
if (matches.length < MAX_MATCHES_PER_RULE) {
|
|
89
|
+
const { line, column } = locate(starts, index);
|
|
90
|
+
matches.push({ line, column, excerpt: match[0].slice(0, EXCERPT_CHARS) });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const present = matchCount > 0;
|
|
94
|
+
const describe = rule.description === undefined ? "" : ` (${rule.description})`;
|
|
95
|
+
if (rule.kind === "required_phrase" || rule.kind === "required_pattern") {
|
|
96
|
+
outcomes.push({
|
|
97
|
+
id: rule.id,
|
|
98
|
+
kind: rule.kind,
|
|
99
|
+
status: present ? "pass" : "fail",
|
|
100
|
+
message: present
|
|
101
|
+
? `required text found ${matchCount} time(s)${describe}`
|
|
102
|
+
: `required text is absent${describe}`,
|
|
103
|
+
// A required rule that passed needs no locations; one that failed has none.
|
|
104
|
+
matches: [],
|
|
105
|
+
matchCount,
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (rule.kind === "review_pattern") {
|
|
110
|
+
outcomes.push({
|
|
111
|
+
id: rule.id,
|
|
112
|
+
kind: rule.kind,
|
|
113
|
+
status: present ? "review" : "pass",
|
|
114
|
+
message: present
|
|
115
|
+
? `${matchCount} passage(s) need a human decision${describe}`
|
|
116
|
+
: `nothing matched${describe}`,
|
|
117
|
+
matches,
|
|
118
|
+
matchCount,
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
outcomes.push({
|
|
123
|
+
id: rule.id,
|
|
124
|
+
kind: rule.kind,
|
|
125
|
+
status: present ? "fail" : "pass",
|
|
126
|
+
message: present
|
|
127
|
+
? `forbidden text found ${matchCount} time(s)${describe}`
|
|
128
|
+
: `forbidden text is absent${describe}`,
|
|
129
|
+
matches,
|
|
130
|
+
matchCount,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const counts = { pass: 0, fail: 0, review: 0, error: 0 };
|
|
134
|
+
for (const outcome of outcomes)
|
|
135
|
+
counts[outcome.status] += 1;
|
|
136
|
+
outcomes.sort((a, b) => compareStrings(a.id, b.id));
|
|
137
|
+
return { pass: counts.fail === 0 && counts.error === 0, outcomes, counts };
|
|
138
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type Finding } from "./text";
|
|
2
|
+
/** Bounds the mapping tools: a mapping is a document, not a database. */
|
|
3
|
+
export declare const MAX_MAPPING_ENTRIES = 5000;
|
|
4
|
+
/** Bounds the alternation a mapping compiles to. */
|
|
5
|
+
export declare const MAX_MAPPING_KEY_CHARS = 200000;
|
|
6
|
+
export declare class MappingError extends Error {
|
|
7
|
+
readonly name = "MappingError";
|
|
8
|
+
}
|
|
9
|
+
/** Keyed, unlinkable-without-the-key token material. */
|
|
10
|
+
export declare function derivePseudonym(key: string, type: string, canonical: string, length?: number): string;
|
|
11
|
+
/** Unkeyed, content-addressed token. Reversible by enumeration — see above. */
|
|
12
|
+
export declare function digestPseudonym(type: string, canonical: string, length?: number): string;
|
|
13
|
+
/** `[EMAIL]`-style placeholder for a finding type. */
|
|
14
|
+
export declare function placeholderFor(type: string): string;
|
|
15
|
+
/** `[EMAIL:9f2b…]`-style pseudonym placeholder. */
|
|
16
|
+
export declare function pseudonymPlaceholder(type: string, token: string): string;
|
|
17
|
+
/** Anything this package emits as a token, for spotting leftovers. */
|
|
18
|
+
export declare const TOKEN_SHAPE: RegExp;
|
|
19
|
+
/** Replace every finding with `replace(finding)`; spans must not overlap. */
|
|
20
|
+
export declare function redactFindings(text: string, findings: ReadonlyArray<Finding>, replace: (finding: Finding) => string): string;
|
|
21
|
+
/** Escape a literal for embedding in a regular expression. */
|
|
22
|
+
export declare function escapeRegex(literal: string): string;
|
|
23
|
+
export type MappingApplication = {
|
|
24
|
+
readonly text: string;
|
|
25
|
+
/** Replacements per mapping key, sorted by key. Zero-count keys included. */
|
|
26
|
+
readonly counts: Record<string, number>;
|
|
27
|
+
readonly total: number;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Replace each key of `mapping` with its value, longest key first.
|
|
31
|
+
*
|
|
32
|
+
* Longest-first matters: with `{"Ann": "P1", "Anna": "P2"}` a shortest-first
|
|
33
|
+
* pass would turn "Anna" into "P1a". The pass is single, so a replacement's
|
|
34
|
+
* own text is never rescanned and tokens cannot cascade.
|
|
35
|
+
*
|
|
36
|
+
* `wholeWord` anchors each key between word boundaries, which is what you
|
|
37
|
+
* want for names and what you do NOT want for email addresses.
|
|
38
|
+
*/
|
|
39
|
+
export declare function applyMapping(text: string, mapping: Readonly<Record<string, string>>, wholeWord?: boolean): MappingApplication;
|
|
40
|
+
/** Flip a mapping, refusing a value used for two different keys. */
|
|
41
|
+
export declare function invertMapping(mapping: Readonly<Record<string, string>>): Record<string, string>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning findings into removed text, and values into stable tokens.
|
|
3
|
+
*
|
|
4
|
+
* ## Two token schemes, with different properties
|
|
5
|
+
*
|
|
6
|
+
* KEYED (`derivePseudonym`) — `HMAC-SHA256(key, "<type>:<canonical>")`,
|
|
7
|
+
* truncated. The same value yields the same token in every document the same
|
|
8
|
+
* key is used for, so records can still be joined; without the key the token
|
|
9
|
+
* cannot be walked back to the value even by someone who guesses it, because
|
|
10
|
+
* the attacker cannot compute candidate tokens. This is the scheme to use.
|
|
11
|
+
*
|
|
12
|
+
* UNKEYED (`digestPseudonym`) — a plain SHA-256 prefix. Stable and
|
|
13
|
+
* convenient, and NOT unlinkable: an identifier space small enough to
|
|
14
|
+
* enumerate (every email at a company, every US SSN) can be exhaustively
|
|
15
|
+
* hashed and matched against the tokens. It is offered because a caller
|
|
16
|
+
* sometimes genuinely wants a content-addressed label, and it is labelled
|
|
17
|
+
* `reversibleByEnumeration: true` in every result that uses it so nobody
|
|
18
|
+
* mistakes it for anonymization.
|
|
19
|
+
*
|
|
20
|
+
* ## Truncation
|
|
21
|
+
*
|
|
22
|
+
* Tokens are truncated to keep documents readable. Truncation costs
|
|
23
|
+
* collision resistance: at n hex characters two different values collide
|
|
24
|
+
* with probability ~1 in 16^n. The default of 12 (48 bits) is comfortable
|
|
25
|
+
* for a document-sized corpus; the length is reported so a caller working at
|
|
26
|
+
* dataset scale can raise it.
|
|
27
|
+
*/
|
|
28
|
+
import { createHash, createHmac } from "node:crypto";
|
|
29
|
+
import { applySpans, compareStrings } from "./text";
|
|
30
|
+
/** Bounds the mapping tools: a mapping is a document, not a database. */
|
|
31
|
+
export const MAX_MAPPING_ENTRIES = 5_000;
|
|
32
|
+
/** Bounds the alternation a mapping compiles to. */
|
|
33
|
+
export const MAX_MAPPING_KEY_CHARS = 200_000;
|
|
34
|
+
export class MappingError extends Error {
|
|
35
|
+
name = "MappingError";
|
|
36
|
+
}
|
|
37
|
+
/** Keyed, unlinkable-without-the-key token material. */
|
|
38
|
+
export function derivePseudonym(key, type, canonical, length = 12) {
|
|
39
|
+
const digest = createHmac("sha256", key).update(`${type}:${canonical}`, "utf8").digest("hex");
|
|
40
|
+
return digest.slice(0, Math.max(4, Math.min(64, length)));
|
|
41
|
+
}
|
|
42
|
+
/** Unkeyed, content-addressed token. Reversible by enumeration — see above. */
|
|
43
|
+
export function digestPseudonym(type, canonical, length = 12) {
|
|
44
|
+
const digest = createHash("sha256").update(`${type}:${canonical}`, "utf8").digest("hex");
|
|
45
|
+
return digest.slice(0, Math.max(4, Math.min(64, length)));
|
|
46
|
+
}
|
|
47
|
+
/** `[EMAIL]`-style placeholder for a finding type. */
|
|
48
|
+
export function placeholderFor(type) {
|
|
49
|
+
return `[${type.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}]`;
|
|
50
|
+
}
|
|
51
|
+
/** `[EMAIL:9f2b…]`-style pseudonym placeholder. */
|
|
52
|
+
export function pseudonymPlaceholder(type, token) {
|
|
53
|
+
return `[${type.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}:${token}]`;
|
|
54
|
+
}
|
|
55
|
+
/** Anything this package emits as a token, for spotting leftovers. */
|
|
56
|
+
export const TOKEN_SHAPE = /\[[A-Z0-9_]+(?::[0-9a-f]{4,64})?\]/g;
|
|
57
|
+
/** Replace every finding with `replace(finding)`; spans must not overlap. */
|
|
58
|
+
export function redactFindings(text, findings, replace) {
|
|
59
|
+
return applySpans(text, findings, replace);
|
|
60
|
+
}
|
|
61
|
+
/** Escape a literal for embedding in a regular expression. */
|
|
62
|
+
export function escapeRegex(literal) {
|
|
63
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Replace each key of `mapping` with its value, longest key first.
|
|
67
|
+
*
|
|
68
|
+
* Longest-first matters: with `{"Ann": "P1", "Anna": "P2"}` a shortest-first
|
|
69
|
+
* pass would turn "Anna" into "P1a". The pass is single, so a replacement's
|
|
70
|
+
* own text is never rescanned and tokens cannot cascade.
|
|
71
|
+
*
|
|
72
|
+
* `wholeWord` anchors each key between word boundaries, which is what you
|
|
73
|
+
* want for names and what you do NOT want for email addresses.
|
|
74
|
+
*/
|
|
75
|
+
export function applyMapping(text, mapping, wholeWord = false) {
|
|
76
|
+
const keys = Object.keys(mapping);
|
|
77
|
+
if (keys.length > MAX_MAPPING_ENTRIES) {
|
|
78
|
+
throw new MappingError(`mapping has ${keys.length} entries, over the ${MAX_MAPPING_ENTRIES} limit — split the dataset`);
|
|
79
|
+
}
|
|
80
|
+
const totalKeyChars = keys.reduce((sum, key) => sum + key.length, 0);
|
|
81
|
+
if (totalKeyChars > MAX_MAPPING_KEY_CHARS) {
|
|
82
|
+
throw new MappingError(`mapping keys total ${totalKeyChars} characters, over the ${MAX_MAPPING_KEY_CHARS} limit`);
|
|
83
|
+
}
|
|
84
|
+
const counts = {};
|
|
85
|
+
for (const key of [...keys].sort(compareStrings))
|
|
86
|
+
counts[key] = 0;
|
|
87
|
+
const empty = keys.filter((key) => key.length === 0);
|
|
88
|
+
if (empty.length > 0)
|
|
89
|
+
throw new MappingError("mapping contains an empty key");
|
|
90
|
+
if (keys.length === 0)
|
|
91
|
+
return { text, counts, total: 0 };
|
|
92
|
+
const ordered = [...keys].sort((a, b) => b.length - a.length || compareStrings(a, b));
|
|
93
|
+
const body = ordered.map(escapeRegex).join("|");
|
|
94
|
+
const source = wholeWord ? `(?<![\\w])(?:${body})(?![\\w])` : `(?:${body})`;
|
|
95
|
+
const re = new RegExp(source, "g");
|
|
96
|
+
let total = 0;
|
|
97
|
+
const out = text.replace(re, (match) => {
|
|
98
|
+
const replacement = mapping[match];
|
|
99
|
+
if (replacement === undefined)
|
|
100
|
+
return match;
|
|
101
|
+
counts[match] = (counts[match] ?? 0) + 1;
|
|
102
|
+
total += 1;
|
|
103
|
+
return replacement;
|
|
104
|
+
});
|
|
105
|
+
return { text: out, counts, total };
|
|
106
|
+
}
|
|
107
|
+
/** Flip a mapping, refusing a value used for two different keys. */
|
|
108
|
+
export function invertMapping(mapping) {
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const key of Object.keys(mapping).sort(compareStrings)) {
|
|
111
|
+
const token = mapping[key];
|
|
112
|
+
if (token === undefined)
|
|
113
|
+
continue;
|
|
114
|
+
const existing = out[token];
|
|
115
|
+
if (existing !== undefined && existing !== key) {
|
|
116
|
+
throw new MappingError(`token "${token}" maps to both "${existing}" and "${key}" — a mapping must be invertible to be rejoinable`);
|
|
117
|
+
}
|
|
118
|
+
out[token] = key;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
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 { type Charset } from "./entropy";
|
|
33
|
+
import { type Finding } from "./text";
|
|
34
|
+
export type Severity = "critical" | "high" | "medium" | "low";
|
|
35
|
+
export type SecretRule = {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly kind: "vendor" | "structural";
|
|
38
|
+
readonly description: string;
|
|
39
|
+
readonly severity: Severity;
|
|
40
|
+
readonly pattern: RegExp;
|
|
41
|
+
/** Which capture group holds the secret itself. 0 = the whole match. */
|
|
42
|
+
readonly group?: number;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* The rule table, in scan order. Ids are stable: an operator's allow-list of
|
|
46
|
+
* accepted findings keys on them.
|
|
47
|
+
*/
|
|
48
|
+
export declare const SECRET_RULES: ReadonlyArray<SecretRule>;
|
|
49
|
+
export type SecretHit = {
|
|
50
|
+
readonly rule: string;
|
|
51
|
+
readonly kind: "vendor" | "structural";
|
|
52
|
+
readonly severity: Severity;
|
|
53
|
+
readonly description: string;
|
|
54
|
+
readonly line: number;
|
|
55
|
+
readonly column: number;
|
|
56
|
+
readonly start: number;
|
|
57
|
+
readonly length: number;
|
|
58
|
+
/** The ONLY view of the value that ever leaves this module. */
|
|
59
|
+
readonly masked: string;
|
|
60
|
+
readonly entropyBits: number;
|
|
61
|
+
readonly charset: Charset;
|
|
62
|
+
};
|
|
63
|
+
export type SecretScanOptions = {
|
|
64
|
+
/** Restrict to these rule ids. Unknown ids are an error, not a silent no-op. */
|
|
65
|
+
readonly rules?: ReadonlyArray<string>;
|
|
66
|
+
/** Also run the context-gated high-entropy rule. Default true. */
|
|
67
|
+
readonly highEntropy?: boolean;
|
|
68
|
+
/** Bits-per-character floor for the high-entropy rule. Default is per-charset. */
|
|
69
|
+
readonly minEntropy?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Require a credential-ish word near a high-entropy string. Default true;
|
|
72
|
+
* turning it off surfaces every opaque blob, hashes and UUIDs included.
|
|
73
|
+
*/
|
|
74
|
+
readonly requireContext?: boolean;
|
|
75
|
+
};
|
|
76
|
+
export declare class SecretRuleError extends Error {
|
|
77
|
+
readonly name = "SecretRuleError";
|
|
78
|
+
}
|
|
79
|
+
/** The id the context-gated entropy heuristic reports under. */
|
|
80
|
+
export declare const HIGH_ENTROPY_RULE = "generic.high-entropy";
|
|
81
|
+
/**
|
|
82
|
+
* Every rule id a given options object will report under, including the
|
|
83
|
+
* entropy heuristic when it is enabled. `SecretScan` prints this so an empty
|
|
84
|
+
* result reads as a scope rather than a verdict, and `secretFindings` below
|
|
85
|
+
* runs exactly this set — the list and the scan cannot drift apart.
|
|
86
|
+
*/
|
|
87
|
+
export declare function rulesRunFor(options?: SecretScanOptions): string[];
|
|
88
|
+
/** The rules a given options object will actually run, in scan order. */
|
|
89
|
+
export declare function selectRules(options?: SecretScanOptions): SecretRule[];
|
|
90
|
+
/**
|
|
91
|
+
* Every located secret in the text, values included, in document order.
|
|
92
|
+
*
|
|
93
|
+
* This is the ONE implementation. `scanSecrets` masks it for a result and
|
|
94
|
+
* `secretSpans` hands it to a redactor unmasked; there is no second copy of
|
|
95
|
+
* the rule loop to fall out of step with this one, which is exactly what let
|
|
96
|
+
* `RedactForExport` report a high-entropy finding and then not remove it.
|
|
97
|
+
*/
|
|
98
|
+
export declare function secretFindings(text: string, options?: SecretScanOptions): Finding[];
|
|
99
|
+
/** Scan text. The returned hits are already masked. */
|
|
100
|
+
export declare function scanSecrets(text: string, options?: SecretScanOptions): SecretHit[];
|
|
101
|
+
/**
|
|
102
|
+
* The spans a redactor should replace. Same rules, same options, same spans
|
|
103
|
+
* as `scanSecrets` — including the entropy heuristic, so a document cannot be
|
|
104
|
+
* reported as holding a secret and handed back with that secret still in it.
|
|
105
|
+
*
|
|
106
|
+
* `Finding`s carry their value because the caller is about to remove them; it
|
|
107
|
+
* is the scanner's OUTPUT that must never carry one.
|
|
108
|
+
*/
|
|
109
|
+
export declare function secretSpans(text: string, options?: SecretScanOptions): Finding[];
|
|
110
|
+
/** Summarize hits by severity, highest first, for a one-line verdict. */
|
|
111
|
+
export declare function severityCounts(hits: ReadonlyArray<SecretHit>): Record<Severity, number>;
|