argusqa-os 9.8.0 → 9.9.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 +32 -8
- package/glama.json +2 -2
- package/package.json +5 -3
- package/scripts/rehydrate-report.mjs +62 -0
- package/src/cli/init.js +3 -1
- package/src/cli/pr-validate.js +40 -5
- package/src/mcp-server.js +130 -16
- package/src/orchestration/dispatcher.js +28 -0
- package/src/orchestration/report-processor.js +22 -1
- package/src/utils/aegis-vault.js +251 -0
- package/src/utils/github-reporter.js +33 -4
- package/src/utils/html-reporter.js +26 -1
- package/src/utils/import-graph.js +9 -3
- package/src/utils/pr-diff-analyzer.js +0 -2
- package/src/utils/secret-patterns.js +428 -0
- package/src/utils/sensitivity-classifier.js +498 -0
- package/src/utils/session-persistence.js +6 -1
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aegis — secret / PII pattern core (Step 1 of REDACTION_BOUNDARY_MAX_PLAN.md).
|
|
3
|
+
*
|
|
4
|
+
* Pure, zero-dependency detection primitives shared by the sensitivity
|
|
5
|
+
* classifier and every egress guard. NOTHING here does I/O, touches Chrome,
|
|
6
|
+
* spawns a process, or makes a network call — it is regex + arithmetic only,
|
|
7
|
+
* so it runs in microseconds and is trivially unit-testable.
|
|
8
|
+
*
|
|
9
|
+
* SECURITY POSTURE — Aegis FAILS CLOSED (the one deliberate inversion from the
|
|
10
|
+
* rest of Argus): every primitive here is built so that ambiguity redacts MORE,
|
|
11
|
+
* never less. `findSensitiveSpans` is the union of independent layers — a miss
|
|
12
|
+
* by one is caught by another — and `scrubText` is the projector all sinks call.
|
|
13
|
+
*
|
|
14
|
+
* Layers implemented here:
|
|
15
|
+
* L2 Secret regexes — SECRET_RULES (JWT / AWS / Google / Slack / OpenAI /
|
|
16
|
+
* Anthropic / GitHub / PEM / Bearer / basic-auth URL /
|
|
17
|
+
* generic assignment).
|
|
18
|
+
* L3 Statistical rarity — Token Efficiency (BPE) when a tokenizer is injected
|
|
19
|
+
* via setTokenizer(); Shannon entropy is the zero-dep
|
|
20
|
+
* FALLBACK so the layer ALWAYS functions without a dep
|
|
21
|
+
* (js-tiktoken / gpt-tokenizer are absent by default).
|
|
22
|
+
* L4 Structured PII — PII_RULES (email / E.164 phone / Luhn-valid card /
|
|
23
|
+
* US SSN / IPv4 / IPv6 / private host).
|
|
24
|
+
* L5 Context boosting — CONTEXT_WORDS (+/-) lower/raise the L3 threshold
|
|
25
|
+
* near words like "token"/"secret" vs "example"/"test".
|
|
26
|
+
*
|
|
27
|
+
* Layer 1 (category rules) and the report walk live in sensitivity-classifier.js.
|
|
28
|
+
*
|
|
29
|
+
* `scrubSecrets` (the GitHub-token-only redactor) is RE-EXPORTED from
|
|
30
|
+
* github-api.js at the bottom so callers can import it from here while
|
|
31
|
+
* github-api.js stays byte-for-byte unchanged (its tests keep passing).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// ── Secret regexes (Layer 2) ────────────────────────────────────────────────
|
|
35
|
+
// Each rule = { name, regex }. The regex MUST be global (/g) — findSensitiveSpans
|
|
36
|
+
// iterates matchAll. When a rule wants to redact only PART of the match (e.g. the
|
|
37
|
+
// token after "Bearer "), it uses a capture group named `secret` OR group 1; the
|
|
38
|
+
// span is that group when present, else the whole match.
|
|
39
|
+
|
|
40
|
+
/** @type {{name:string, regex:RegExp}[]} */
|
|
41
|
+
export const SECRET_RULES = [
|
|
42
|
+
// JSON Web Token — three base64url segments. The header always starts `eyJ`.
|
|
43
|
+
{ name: 'jwt', regex: /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g },
|
|
44
|
+
// Anthropic key — checked BEFORE the generic OpenAI `sk-` rule (more specific).
|
|
45
|
+
{ name: 'anthropic_key', regex: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
46
|
+
// OpenAI key (incl. sk-proj-…) — negative lookahead excludes the Anthropic shape.
|
|
47
|
+
{ name: 'openai_key', regex: /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}/g },
|
|
48
|
+
// AWS access key id.
|
|
49
|
+
{ name: 'aws_access_key_id', regex: /\b(?:AKIA|ASIA|AROA|AIDA|AGPA|ANPA)[0-9A-Z]{16}\b/g },
|
|
50
|
+
// AWS secret access key — anchored on the assignment to avoid 40-char base64 FPs.
|
|
51
|
+
{ name: 'aws_secret_access_key', regex: /aws_secret_access_key\s*[=:]\s*["']?(?<secret>[A-Za-z0-9/+]{40})/gi },
|
|
52
|
+
// Google API key.
|
|
53
|
+
{ name: 'google_api_key', regex: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
54
|
+
// Slack token.
|
|
55
|
+
{ name: 'slack_token', regex: /\bxox[baprs]-[0-9A-Za-z-]{10,}/g },
|
|
56
|
+
// GitHub fine-grained PAT (has underscores) — checked before the short ghX_ rule.
|
|
57
|
+
{ name: 'github_pat', regex: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
|
|
58
|
+
{ name: 'github_token', regex: /\bgh[posru]_[A-Za-z0-9_]{10,}/g },
|
|
59
|
+
// PEM private-key header (any algorithm).
|
|
60
|
+
{ name: 'private_key_pem', regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g },
|
|
61
|
+
// Bearer token — span is the token only (keep the literal "Bearer " readable).
|
|
62
|
+
{ name: 'bearer', regex: /(?<=\bBearer\s)[A-Za-z0-9._~+/=-]{8,}/gi },
|
|
63
|
+
// basic-auth URL — span is the userinfo `user:pass` (before the @).
|
|
64
|
+
{ name: 'basic_auth_url', regex: /(?<=:\/\/)(?<secret>[^/\s:@]+:[^/\s:@]+)(?=@)/g },
|
|
65
|
+
// generic assignment: password= / api_key: / secret= / token= … — span is the value.
|
|
66
|
+
{ name: 'generic_assignment', regex: /\b(?:password|passwd|pwd|api[_-]?key|secret|access[_-]?token|auth[_-]?token|client[_-]?secret)\b\s*[=:]\s*["']?(?<secret>[^\s"'<>&]{6,})/gi },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// ── Structured PII (Layer 4) ────────────────────────────────────────────────
|
|
70
|
+
// validate? — an optional gate run over the matched span (group `secret`/1 or
|
|
71
|
+
// whole match); the span is dropped if it returns false (e.g. Luhn for cards).
|
|
72
|
+
|
|
73
|
+
/** @type {{name:string, regex:RegExp, validate?:(s:string)=>boolean}[]} */
|
|
74
|
+
export const PII_RULES = [
|
|
75
|
+
{ name: 'email', regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
|
|
76
|
+
{ name: 'phone_e164', regex: /\+[1-9]\d{7,14}\b/g },
|
|
77
|
+
{
|
|
78
|
+
name: 'credit_card',
|
|
79
|
+
regex: /\b(?:\d[ -]?){13,19}\b/g,
|
|
80
|
+
validate: (s) => luhnValid(s),
|
|
81
|
+
},
|
|
82
|
+
{ name: 'ssn_us', regex: /\b\d{3}-\d{2}-\d{4}\b/g },
|
|
83
|
+
{
|
|
84
|
+
name: 'ipv4',
|
|
85
|
+
regex: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g,
|
|
86
|
+
},
|
|
87
|
+
{ name: 'ipv6', regex: /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g },
|
|
88
|
+
{
|
|
89
|
+
name: 'private_host',
|
|
90
|
+
regex: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|[A-Za-z0-9-]+\.internal|[A-Za-z0-9-]+\.local)\b/g,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
// ── Context words (Layer 5) ─────────────────────────────────────────────────
|
|
95
|
+
export const CONTEXT_WORDS = {
|
|
96
|
+
positive: ['token', 'secret', 'password', 'passwd', 'pwd', 'authorization', 'auth',
|
|
97
|
+
'bearer', 'cookie', 'apikey', 'api_key', 'api-key', 'access_key', 'accesskey',
|
|
98
|
+
'private_key', 'privatekey', 'credential', 'credentials', 'session', 'signature'],
|
|
99
|
+
negative: ['example', 'sample', 'dummy', 'test', 'placeholder', 'your', 'fake',
|
|
100
|
+
'redacted', 'lorem', 'foobar', 'changeme', 'xxxx'],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ── Statistical-rarity tuning (Layer 3) ─────────────────────────────────────
|
|
104
|
+
const ENTROPY_BASE64 = 4.0; // bits/char default for a base64-charset token
|
|
105
|
+
const ENTROPY_HEX = 3.0; // bits/char for an all-hex token
|
|
106
|
+
const MIN_LEN_NO_CONTEXT = 20; // a bare high-entropy token must be this long…
|
|
107
|
+
const MIN_LEN_CONTEXT = 12; // …unless a positive context word sits nearby
|
|
108
|
+
const CONTEXT_WINDOW = 24; // chars before the token scanned for context
|
|
109
|
+
const TOKEN_EFFICIENCY_FLOOR = 2.5; // BPE efficiency below this ⇒ secret-shaped
|
|
110
|
+
|
|
111
|
+
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
112
|
+
const HEX_RE = /^[0-9a-fA-F]+$/;
|
|
113
|
+
// A token candidate for the entropy scan: a run of secret-shaped chars.
|
|
114
|
+
const TOKEN_SCAN_RE = /[A-Za-z0-9+/=_-]{8,}/g;
|
|
115
|
+
|
|
116
|
+
// Optional BPE tokenizer (Token Efficiency, plan §13.1). Absent by default; the
|
|
117
|
+
// production opt-in (ARGUS_REDACT_TOKEN_EFFICIENCY) wires js-tiktoken's encode in
|
|
118
|
+
// via setTokenizer(). When null, Layer 3 uses Shannon entropy — the zero-dep
|
|
119
|
+
// fallback — so the layer ALWAYS functions.
|
|
120
|
+
let _encode = null;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Inject a BPE encoder (e.g. js-tiktoken's `encode`) to switch Layer 3 to Token
|
|
124
|
+
* Efficiency. Pass null/undefined to revert to the Shannon-entropy fallback.
|
|
125
|
+
* @param {((s:string)=>unknown[])|null} encodeFn
|
|
126
|
+
*/
|
|
127
|
+
export function setTokenizer(encodeFn) {
|
|
128
|
+
_encode = typeof encodeFn === 'function' ? encodeFn : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Optional reversible-vault tokenizer (Aegis Step 8, plan §5.3). Injected by
|
|
132
|
+
// aegis-vault.js when ARGUS_REDACT_VAULT=1; turns scrubText's `token` mode into a
|
|
133
|
+
// deterministic, information-free vault token. secret-patterns.js stays I/O-free —
|
|
134
|
+
// the I/O (mapping persistence) lives entirely in the injected function. When null,
|
|
135
|
+
// `token` mode FALLS BACK TO MASK (never emits the raw value — fail closed).
|
|
136
|
+
let _vaultTokenFn = null;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Inject the vault tokenizer (aegis-vault's `tokenFor`) to enable scrubText's
|
|
140
|
+
* `token` mode. Pass null/undefined to disable it (token mode then masks).
|
|
141
|
+
* @param {((value:string)=>string)|null} fn
|
|
142
|
+
*/
|
|
143
|
+
export function setVaultTokenizer(fn) {
|
|
144
|
+
_vaultTokenFn = typeof fn === 'function' ? fn : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Token Efficiency = len(string) / len(BPE tokens). Real secrets fragment into
|
|
149
|
+
* many short tokens (low efficiency); natural language compresses (high). Returns
|
|
150
|
+
* null when no tokenizer is injected (caller falls back to Shannon entropy).
|
|
151
|
+
* @param {string} str
|
|
152
|
+
* @returns {number|null}
|
|
153
|
+
*/
|
|
154
|
+
export function tokenEfficiency(str) {
|
|
155
|
+
if (!_encode || !str) return null;
|
|
156
|
+
try {
|
|
157
|
+
const tokens = _encode(str);
|
|
158
|
+
const n = Array.isArray(tokens) ? tokens.length : Number(tokens?.length ?? 0);
|
|
159
|
+
if (!n) return null;
|
|
160
|
+
return str.length / n;
|
|
161
|
+
} catch {
|
|
162
|
+
return null; // fail toward the entropy fallback, never throw
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Shannon entropy in bits/char.
|
|
168
|
+
* @param {string} str
|
|
169
|
+
* @returns {number}
|
|
170
|
+
*/
|
|
171
|
+
export function shannonEntropy(str) {
|
|
172
|
+
if (!str) return 0;
|
|
173
|
+
const freq = Object.create(null);
|
|
174
|
+
for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
|
|
175
|
+
let H = 0;
|
|
176
|
+
const len = str.length;
|
|
177
|
+
for (const ch in freq) {
|
|
178
|
+
const p = freq[ch] / len;
|
|
179
|
+
H -= p * Math.log2(p);
|
|
180
|
+
}
|
|
181
|
+
return H;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Luhn (mod-10) checksum validator for a candidate card number (may contain
|
|
186
|
+
* spaces/hyphens). Cuts the credit-card false-positive rate dramatically.
|
|
187
|
+
* @param {string} input
|
|
188
|
+
* @returns {boolean}
|
|
189
|
+
*/
|
|
190
|
+
export function luhnValid(input) {
|
|
191
|
+
const digits = String(input ?? '').replace(/[\s-]/g, '');
|
|
192
|
+
if (!/^\d{13,19}$/.test(digits)) return false;
|
|
193
|
+
let sum = 0;
|
|
194
|
+
let alt = false;
|
|
195
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
196
|
+
let d = digits.charCodeAt(i) - 48;
|
|
197
|
+
if (alt) { d *= 2; if (d > 9) d -= 9; }
|
|
198
|
+
sum += d;
|
|
199
|
+
alt = !alt;
|
|
200
|
+
}
|
|
201
|
+
return sum % 10 === 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* True when a context word from `list` appears within CONTEXT_WINDOW chars before
|
|
206
|
+
* `start` in `text`.
|
|
207
|
+
* @param {string} text
|
|
208
|
+
* @param {number} start
|
|
209
|
+
* @param {string[]} list
|
|
210
|
+
* @returns {boolean}
|
|
211
|
+
*/
|
|
212
|
+
function hasContext(text, start, list) {
|
|
213
|
+
const before = text.slice(Math.max(0, start - CONTEXT_WINDOW), start).toLowerCase();
|
|
214
|
+
return list.some((w) => before.includes(w));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Layer-3 decision for one candidate token: is it statistically rare enough
|
|
219
|
+
* (secret-shaped) to flag? Uses Token Efficiency when a tokenizer is injected,
|
|
220
|
+
* else Shannon entropy. UUIDs and short/low-diversity tokens are suppressed
|
|
221
|
+
* (the gitleaks-style false-positive gate). Positive context lowers the length
|
|
222
|
+
* bar; negative context raises the entropy bar.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} token
|
|
225
|
+
* @param {{ entropyThreshold?: number, positiveContext?: boolean, negativeContext?: boolean }} [opts]
|
|
226
|
+
* @returns {boolean}
|
|
227
|
+
*/
|
|
228
|
+
export function isStatisticallyRare(token, opts = {}) {
|
|
229
|
+
if (!token) return false;
|
|
230
|
+
if (UUID_RE.test(token)) return false; // canonical UUID — never a secret
|
|
231
|
+
|
|
232
|
+
const len = token.length;
|
|
233
|
+
const minLen = opts.positiveContext ? MIN_LEN_CONTEXT : MIN_LEN_NO_CONTEXT;
|
|
234
|
+
if (len < minLen) return false;
|
|
235
|
+
|
|
236
|
+
// Charset-diversity gate: require ≥2 of {lower, upper, digit} OR base64 padding,
|
|
237
|
+
// so an all-lowercase English-ish run or a CSS class name isn't flagged.
|
|
238
|
+
const classes = (/[a-z]/.test(token) ? 1 : 0) + (/[A-Z]/.test(token) ? 1 : 0) +
|
|
239
|
+
(/[0-9]/.test(token) ? 1 : 0);
|
|
240
|
+
if (classes < 2 && !/[+/=]/.test(token)) return false;
|
|
241
|
+
|
|
242
|
+
// Token Efficiency first (when available), else Shannon entropy.
|
|
243
|
+
const eff = tokenEfficiency(token);
|
|
244
|
+
if (eff !== null) {
|
|
245
|
+
const floor = opts.negativeContext ? TOKEN_EFFICIENCY_FLOOR - 0.6 : TOKEN_EFFICIENCY_FLOOR;
|
|
246
|
+
return eff < floor;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const isHex = HEX_RE.test(token);
|
|
250
|
+
let threshold = opts.entropyThreshold ?? (isHex ? ENTROPY_HEX : ENTROPY_BASE64);
|
|
251
|
+
if (opts.negativeContext) threshold += 0.8; // make a "test"/"example" token harder to flag
|
|
252
|
+
if (opts.positiveContext) threshold -= 0.8; // a "token="/"secret=" neighbour promotes it
|
|
253
|
+
return shannonEntropy(token) >= threshold;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Zero-width / soft-hyphen characters an attacker can splice into a secret to
|
|
257
|
+
// break a regex without changing how it renders.
|
|
258
|
+
const ZERO_WIDTH_RE = /[\u200B-\u200D\uFEFF\u00AD\u2060]/g;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Strip zero-width / soft-hyphen chars an attacker splices into a secret to break
|
|
262
|
+
* a regex without changing how it renders. PRESERVES whitespace + word boundaries
|
|
263
|
+
* (so \b-anchored rules like email / private-host still fire). Boundary-safe.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} text
|
|
266
|
+
* @returns {string}
|
|
267
|
+
*/
|
|
268
|
+
export function stripZeroWidth(text) {
|
|
269
|
+
return (typeof text === 'string' ? text : String(text ?? '')).replace(ZERO_WIDTH_RE, '');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// NOTE: a whitespace-REJOINING normaliser (to rebuild a secret split across
|
|
273
|
+
// spaces/newlines) was prototyped and REMOVED — on natural-language blobs it
|
|
274
|
+
// fused ordinary words into spurious high-entropy tokens, over-redacting benign
|
|
275
|
+
// findings. Whitespace/newline-split secrets in benign free-text are therefore a
|
|
276
|
+
// documented limitation, defended instead by the category + body-field catch-alls
|
|
277
|
+
// (a security-category finding or any finding carrying a body field drops ALL its
|
|
278
|
+
// free text). See REDACTION_BOUNDARY_MAX_PLAN.md Progress Log.
|
|
279
|
+
|
|
280
|
+
// ── Span union scan ─────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The union of all detection layers over `text`. Returns NON-overlapping spans
|
|
284
|
+
* (overlaps merged) sorted by start, each `{start, end, kind, name}` where kind ∈
|
|
285
|
+
* {secret, pii, entropy}. This is the single source of truth that scrubText
|
|
286
|
+
* redacts and that the fuzz invariant proves redactForEgress never leaks.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} text
|
|
289
|
+
* @param {{ entropyThreshold?: number }} [opts]
|
|
290
|
+
* @returns {{start:number, end:number, kind:'secret'|'pii'|'entropy', name:string}[]}
|
|
291
|
+
*/
|
|
292
|
+
export function findSensitiveSpans(text, opts = {}) {
|
|
293
|
+
const s = typeof text === 'string' ? text : String(text ?? '');
|
|
294
|
+
if (!s) return [];
|
|
295
|
+
/** @type {{start:number,end:number,kind:string,name:string}[]} */
|
|
296
|
+
const spans = [];
|
|
297
|
+
|
|
298
|
+
const pushFromRule = (rule, kind) => {
|
|
299
|
+
rule.regex.lastIndex = 0;
|
|
300
|
+
for (const m of s.matchAll(rule.regex)) {
|
|
301
|
+
// Prefer the named `secret` group, else group 1, else the whole match.
|
|
302
|
+
const captured = (m.groups && m.groups.secret !== undefined) ? m.groups.secret : (m[1] ?? m[0]);
|
|
303
|
+
if (captured === undefined || captured === '') continue;
|
|
304
|
+
if (rule.validate && !rule.validate(captured)) continue;
|
|
305
|
+
// Locate the captured span inside the full match (handles lookbehind + groups).
|
|
306
|
+
const matchStart = m.index ?? s.indexOf(m[0]);
|
|
307
|
+
const offset = m[0].indexOf(captured);
|
|
308
|
+
const start = matchStart + (offset >= 0 ? offset : 0);
|
|
309
|
+
spans.push({ start, end: start + captured.length, kind, name: rule.name });
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
for (const rule of SECRET_RULES) pushFromRule(rule, 'secret');
|
|
314
|
+
for (const rule of PII_RULES) pushFromRule(rule, 'pii');
|
|
315
|
+
|
|
316
|
+
// Layer 3 — entropy / token-efficiency over candidate tokens.
|
|
317
|
+
TOKEN_SCAN_RE.lastIndex = 0;
|
|
318
|
+
for (const m of s.matchAll(TOKEN_SCAN_RE)) {
|
|
319
|
+
const token = m[0];
|
|
320
|
+
const start = m.index ?? 0;
|
|
321
|
+
const positiveContext = hasContext(s, start, CONTEXT_WORDS.positive);
|
|
322
|
+
const negativeContext = hasContext(s, start, CONTEXT_WORDS.negative);
|
|
323
|
+
if (isStatisticallyRare(token, { entropyThreshold: opts.entropyThreshold, positiveContext, negativeContext })) {
|
|
324
|
+
spans.push({ start, end: start + token.length, kind: 'entropy', name: 'high_entropy' });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return mergeSpans(spans);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Merge overlapping/adjacent spans into the widest covering span so scrubText
|
|
333
|
+
* replaces each region once. Keeps the first contributing span's name/kind.
|
|
334
|
+
* @param {{start:number,end:number,kind:string,name:string}[]} spans
|
|
335
|
+
*/
|
|
336
|
+
export function mergeSpans(spans) {
|
|
337
|
+
if (spans.length <= 1) return spans.slice();
|
|
338
|
+
const sorted = spans.slice().sort((a, b) => a.start - b.start || a.end - b.end);
|
|
339
|
+
const out = [sorted[0]];
|
|
340
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
341
|
+
const prev = out[out.length - 1];
|
|
342
|
+
const cur = sorted[i];
|
|
343
|
+
if (cur.start <= prev.end) {
|
|
344
|
+
prev.end = Math.max(prev.end, cur.end); // overlap → extend
|
|
345
|
+
} else {
|
|
346
|
+
out.push(cur);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── Redaction modes ─────────────────────────────────────────────────────────
|
|
353
|
+
const MASK_CHAR = '█';
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Replacement string for one span's text under the chosen mode. INVARIANT
|
|
357
|
+
* (mode=mask): the result is never LONGER than the original span and never
|
|
358
|
+
* reproduces the full secret verbatim.
|
|
359
|
+
* @param {string} value the raw span substring
|
|
360
|
+
* @param {string} kind
|
|
361
|
+
* @param {string} name
|
|
362
|
+
* @param {string} mode mask | label | hash | token | drop
|
|
363
|
+
* @returns {string}
|
|
364
|
+
*/
|
|
365
|
+
function maskValue(value, kind, name, mode) {
|
|
366
|
+
switch (mode) {
|
|
367
|
+
case 'drop':
|
|
368
|
+
return '';
|
|
369
|
+
case 'label':
|
|
370
|
+
return `[REDACTED_${String(name || kind).toUpperCase()}]`;
|
|
371
|
+
case 'token': {
|
|
372
|
+
// Reversible vault token (plan §5.2/§5.3). The token is an HMAC — information-
|
|
373
|
+
// free, so the secret never crosses; the token→original mapping lives only in
|
|
374
|
+
// the local 0600 vault. FAILS CLOSED: no vault wired ⇒ fall back to mask.
|
|
375
|
+
if (_vaultTokenFn) {
|
|
376
|
+
try { return _vaultTokenFn(value); } catch { /* fall through to mask */ }
|
|
377
|
+
}
|
|
378
|
+
const keepT = Math.min(4, Math.floor(value.length / 3));
|
|
379
|
+
return value.slice(0, keepT) + MASK_CHAR.repeat(value.length - keepT);
|
|
380
|
+
}
|
|
381
|
+
case 'hash': {
|
|
382
|
+
// Length-stable, collision-resistant-enough correlation tag (no crypto dep).
|
|
383
|
+
let h = 0;
|
|
384
|
+
for (let i = 0; i < value.length; i++) h = (Math.imul(31, h) + value.charCodeAt(i)) | 0;
|
|
385
|
+
const hex = (h >>> 0).toString(16).padStart(8, '0');
|
|
386
|
+
return `«${name}:${hex}»`;
|
|
387
|
+
}
|
|
388
|
+
case 'mask':
|
|
389
|
+
default: {
|
|
390
|
+
// Keep a SHORT prefix (≤4 chars, never ≥ half the secret) for debuggability;
|
|
391
|
+
// mask the rest. Same length ⇒ never lengthens; full secret never survives.
|
|
392
|
+
const keep = Math.min(4, Math.floor(value.length / 3));
|
|
393
|
+
return value.slice(0, keep) + MASK_CHAR.repeat(value.length - keep);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Replace every sensitive span in `text` per `mode`. Idempotent
|
|
400
|
+
* (scrub(scrub(x)) === scrub(x)) and, for mode=mask, never lengthens the input.
|
|
401
|
+
* This SUPERSEDES the GitHub-only scrubSecrets for free-text egress.
|
|
402
|
+
*
|
|
403
|
+
* @param {string} text
|
|
404
|
+
* @param {{ mode?: string, entropyThreshold?: number }} [opts]
|
|
405
|
+
* @returns {string}
|
|
406
|
+
*/
|
|
407
|
+
export function scrubText(text, opts = {}) {
|
|
408
|
+
const s = typeof text === 'string' ? text : (text == null ? '' : String(text));
|
|
409
|
+
if (!s) return s;
|
|
410
|
+
const mode = opts.mode || 'mask';
|
|
411
|
+
const spans = findSensitiveSpans(s, opts);
|
|
412
|
+
if (spans.length === 0) return s;
|
|
413
|
+
|
|
414
|
+
let out = '';
|
|
415
|
+
let cursor = 0;
|
|
416
|
+
for (const sp of spans) {
|
|
417
|
+
if (sp.start < cursor) continue; // defensive (merged spans shouldn't overlap)
|
|
418
|
+
out += s.slice(cursor, sp.start);
|
|
419
|
+
out += maskValue(s.slice(sp.start, sp.end), sp.kind, sp.name, mode);
|
|
420
|
+
cursor = sp.end;
|
|
421
|
+
}
|
|
422
|
+
out += s.slice(cursor);
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Re-export the GitHub-token-only redactor so callers can import it from here
|
|
427
|
+
// while github-api.js stays byte-for-byte unchanged (its existing tests pass).
|
|
428
|
+
export { scrubSecrets } from './github-api.js';
|