@cirvix_ai/agent-control 0.1.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/LICENSE +202 -0
- package/NOTICE +42 -0
- package/README.md +341 -0
- package/action/README.md +100 -0
- package/action/action.yml +134 -0
- package/action/report.mjs +144 -0
- package/bin/cirvix.mjs +1073 -0
- package/package.json +60 -0
- package/src/commands/demo.mjs +315 -0
- package/src/commands/init.mjs +558 -0
- package/src/commands/policy.mjs +345 -0
- package/src/commands/sarif.mjs +176 -0
- package/src/commands/scan.mjs +210 -0
- package/src/commands/status.mjs +208 -0
- package/src/commands/upgrade.mjs +162 -0
- package/src/core/approvals.mjs +388 -0
- package/src/core/audit.mjs +181 -0
- package/src/core/canonical.mjs +316 -0
- package/src/core/daemon.mjs +352 -0
- package/src/core/decisions.mjs +253 -0
- package/src/core/delegation.mjs +658 -0
- package/src/core/detect.mjs +337 -0
- package/src/core/entitlement-gate.mjs +100 -0
- package/src/core/entitlements.mjs +285 -0
- package/src/core/format.mjs +33 -0
- package/src/core/gateway.mjs +959 -0
- package/src/core/guard.mjs +568 -0
- package/src/core/http-transport.mjs +505 -0
- package/src/core/journal.mjs +419 -0
- package/src/core/jsonrpc.mjs +152 -0
- package/src/core/meter.mjs +225 -0
- package/src/core/normalize.mjs +516 -0
- package/src/core/notices.mjs +80 -0
- package/src/core/pipeline.mjs +629 -0
- package/src/core/policy-dsl.mjs +611 -0
- package/src/core/policy.mjs +710 -0
- package/src/core/prompts.mjs +146 -0
- package/src/core/risk.mjs +509 -0
- package/src/core/sanitize.mjs +279 -0
- package/src/core/secret-detect.mjs +533 -0
- package/src/core/secrets.mjs +312 -0
- package/src/core/uds.mjs +383 -0
- package/src/core/vault.mjs +530 -0
- package/src/index.mjs +143 -0
- package/src/testing.mjs +145 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret detection — v1.
|
|
3
|
+
*
|
|
4
|
+
* Finds credential material in a string or a JSON structure: tool arguments on
|
|
5
|
+
* the way out, tool results on the way back, a file an agent just read.
|
|
6
|
+
*
|
|
7
|
+
* THE RULE THIS MODULE OBEYS, ABOVE ALL OTHERS
|
|
8
|
+
*
|
|
9
|
+
* A finding NEVER contains the secret. Not in a field, not in a preview, not
|
|
10
|
+
* "just the first few characters plus the length". Findings are written to the
|
|
11
|
+
* audit chain, shipped to the control plane, and printed in terminals that
|
|
12
|
+
* scroll into CI logs — a detector that carries the value it detected has
|
|
13
|
+
* copied every credential it ever found into three new places, and it will be
|
|
14
|
+
* the highest-severity finding in its own next scan.
|
|
15
|
+
*
|
|
16
|
+
* So each finding carries: what kind of secret, where it was, how long it was,
|
|
17
|
+
* a masked shape (`AKIA••••••••••••EXMP`), and a SHA-256 fingerprint. The
|
|
18
|
+
* fingerprint is enough to answer "is this the same secret as the one in that
|
|
19
|
+
* other finding" — which is the only question anyone actually asks — without
|
|
20
|
+
* ever holding the answer.
|
|
21
|
+
*
|
|
22
|
+
* DETECTION IS PATTERN PLUS SHAPE, NOT ENTROPY ALONE
|
|
23
|
+
*
|
|
24
|
+
* Pure entropy scanning finds minified JavaScript, base64 images, UUIDs, git
|
|
25
|
+
* SHAs, and lockfile integrity hashes. On a real repository it is mostly false
|
|
26
|
+
* positives, and a detector an operator learns to ignore is worse than none.
|
|
27
|
+
*
|
|
28
|
+
* So the strong signal is a *prefixed* pattern — `AKIA`, `ghp_`, `sk-ant-`,
|
|
29
|
+
* `xoxb-` — where the issuer stamped the credential with something unambiguous.
|
|
30
|
+
* Those fire on their own. Generic high-entropy strings only fire when they sit
|
|
31
|
+
* next to an assignment whose *name* says credential (`API_KEY=`,
|
|
32
|
+
* `"password":`), and even then they must clear an entropy floor. That pairing
|
|
33
|
+
* is what keeps the false-positive rate low enough that the tool stays on.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
|
|
38
|
+
/** Below this, a random-looking string is usually a word, a path, or a hash. */
|
|
39
|
+
const ENTROPY_FLOOR = 3.6;
|
|
40
|
+
|
|
41
|
+
/** A generic candidate shorter than this is noise. */
|
|
42
|
+
const MIN_GENERIC_LENGTH = 16;
|
|
43
|
+
|
|
44
|
+
/** Depth cap on the structure walk. */
|
|
45
|
+
const MAX_DEPTH = 12;
|
|
46
|
+
|
|
47
|
+
/** Cap on scanned length — a multi-megabyte file must not stall the hot path. */
|
|
48
|
+
const MAX_SCAN_BYTES = 1_000_000;
|
|
49
|
+
|
|
50
|
+
export const SEVERITY = { CRITICAL: "critical", HIGH: "high", MEDIUM: "medium" };
|
|
51
|
+
|
|
52
|
+
/* -------------------------------------------------------------------------- */
|
|
53
|
+
/* Detectors */
|
|
54
|
+
/* -------------------------------------------------------------------------- */
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Each detector is `{ id, name, severity, pattern, group?, validate? }`.
|
|
58
|
+
*
|
|
59
|
+
* `pattern` must be global. `group` names the capture holding the secret when
|
|
60
|
+
* the match includes surrounding context (an assignment, a URL). `validate`
|
|
61
|
+
* gets the candidate and rejects obvious non-secrets — placeholders, examples,
|
|
62
|
+
* and the literal string `REDACTED`, which appears in exactly the documents
|
|
63
|
+
* people scan.
|
|
64
|
+
*/
|
|
65
|
+
export const DETECTORS = [
|
|
66
|
+
/* ------------------------------------------------------------------- cloud */
|
|
67
|
+
{
|
|
68
|
+
id: "aws-access-key-id",
|
|
69
|
+
name: "AWS access key ID",
|
|
70
|
+
severity: SEVERITY.CRITICAL,
|
|
71
|
+
pattern: /\b((?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16})\b/g,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "aws-secret-access-key",
|
|
75
|
+
name: "AWS secret access key",
|
|
76
|
+
severity: SEVERITY.CRITICAL,
|
|
77
|
+
// Only with the name adjacent: 40 chars of base64 alone is far too common.
|
|
78
|
+
pattern:
|
|
79
|
+
/aws_?secret_?access_?key["'\s:=]+["']?([A-Za-z0-9/+=]{40})["']?/gi,
|
|
80
|
+
group: 1,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "aws-session-token",
|
|
84
|
+
name: "AWS session token",
|
|
85
|
+
severity: SEVERITY.CRITICAL,
|
|
86
|
+
pattern: /aws_?session_?token["'\s:=]+["']?([A-Za-z0-9/+=]{100,})["']?/gi,
|
|
87
|
+
group: 1,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "gcp-api-key",
|
|
91
|
+
name: "Google API key",
|
|
92
|
+
severity: SEVERITY.HIGH,
|
|
93
|
+
pattern: /\b(AIza[0-9A-Za-z_-]{35})\b/g,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "gcp-service-account",
|
|
97
|
+
name: "GCP service-account private key",
|
|
98
|
+
severity: SEVERITY.CRITICAL,
|
|
99
|
+
pattern: /"type"\s*:\s*"service_account"[\s\S]{0,400}?"private_key"\s*:\s*"(-----BEGIN[^"]+)"/g,
|
|
100
|
+
group: 1,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "azure-storage-key",
|
|
104
|
+
name: "Azure storage account key",
|
|
105
|
+
severity: SEVERITY.CRITICAL,
|
|
106
|
+
pattern: /AccountKey=([A-Za-z0-9+/=]{86}==)/g,
|
|
107
|
+
group: 1,
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/* ------------------------------------------------------------------- vcs */
|
|
111
|
+
{
|
|
112
|
+
id: "github-token",
|
|
113
|
+
name: "GitHub token",
|
|
114
|
+
severity: SEVERITY.CRITICAL,
|
|
115
|
+
pattern: /\b((?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255})\b/g,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: "github-fine-grained-pat",
|
|
119
|
+
name: "GitHub fine-grained PAT",
|
|
120
|
+
severity: SEVERITY.CRITICAL,
|
|
121
|
+
pattern: /\b(github_pat_[A-Za-z0-9_]{60,255})\b/g,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: "gitlab-token",
|
|
125
|
+
name: "GitLab token",
|
|
126
|
+
severity: SEVERITY.CRITICAL,
|
|
127
|
+
pattern: /\b(glpat-[A-Za-z0-9_-]{20,})\b/g,
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/* ------------------------------------------------------------------- llm */
|
|
131
|
+
{
|
|
132
|
+
id: "openai-api-key",
|
|
133
|
+
name: "OpenAI API key",
|
|
134
|
+
severity: SEVERITY.CRITICAL,
|
|
135
|
+
pattern: /\b(sk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,})\b/g,
|
|
136
|
+
// `sk-ant-` is Anthropic and has its own detector; do not double-report.
|
|
137
|
+
validate: (v) => !v.startsWith("sk-ant-"),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "anthropic-api-key",
|
|
141
|
+
name: "Anthropic API key",
|
|
142
|
+
severity: SEVERITY.CRITICAL,
|
|
143
|
+
pattern: /\b(sk-ant-[A-Za-z0-9_-]{20,})\b/g,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: "huggingface-token",
|
|
147
|
+
name: "Hugging Face token",
|
|
148
|
+
severity: SEVERITY.HIGH,
|
|
149
|
+
pattern: /\b(hf_[A-Za-z0-9]{30,})\b/g,
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/* ---------------------------------------------------------------- payments */
|
|
153
|
+
{
|
|
154
|
+
id: "stripe-secret-key",
|
|
155
|
+
name: "Stripe secret key",
|
|
156
|
+
severity: SEVERITY.CRITICAL,
|
|
157
|
+
pattern: /\b((?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,})\b/g,
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/* ------------------------------------------------------------------- comms */
|
|
161
|
+
{
|
|
162
|
+
id: "slack-token",
|
|
163
|
+
name: "Slack token",
|
|
164
|
+
severity: SEVERITY.HIGH,
|
|
165
|
+
pattern: /\b(xox[abposr]-[A-Za-z0-9-]{10,})\b/g,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: "slack-webhook",
|
|
169
|
+
name: "Slack webhook URL",
|
|
170
|
+
severity: SEVERITY.HIGH,
|
|
171
|
+
pattern: /(https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9_+/-]{8,})/g,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: "sendgrid-key",
|
|
175
|
+
name: "SendGrid API key",
|
|
176
|
+
severity: SEVERITY.HIGH,
|
|
177
|
+
pattern: /\b(SG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,})\b/g,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: "twilio-key",
|
|
181
|
+
name: "Twilio account SID",
|
|
182
|
+
severity: SEVERITY.HIGH,
|
|
183
|
+
pattern: /\b(AC[a-f0-9]{32})\b/g,
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/* ------------------------------------------------------------------ crypto */
|
|
187
|
+
{
|
|
188
|
+
id: "private-key",
|
|
189
|
+
name: "Private key",
|
|
190
|
+
severity: SEVERITY.CRITICAL,
|
|
191
|
+
pattern:
|
|
192
|
+
/(-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END[^-]*-----)/g,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: "ssh-private-key-header",
|
|
196
|
+
name: "SSH private key",
|
|
197
|
+
severity: SEVERITY.CRITICAL,
|
|
198
|
+
// A truncated key is still a disclosure and still worth blocking.
|
|
199
|
+
pattern: /(-----BEGIN OPENSSH PRIVATE KEY-----)/g,
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: "jwt",
|
|
203
|
+
name: "JSON Web Token",
|
|
204
|
+
severity: SEVERITY.MEDIUM,
|
|
205
|
+
pattern: /\b(eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b/g,
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
/* ---------------------------------------------------------------- database */
|
|
209
|
+
{
|
|
210
|
+
id: "database-url",
|
|
211
|
+
name: "Database connection string with password",
|
|
212
|
+
severity: SEVERITY.CRITICAL,
|
|
213
|
+
pattern:
|
|
214
|
+
/\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp|mssql|clickhouse):\/\/[^\s:@/]+:[^\s@/]{3,}@[^\s/"']+)/gi,
|
|
215
|
+
validate: (v) => !/:(password|pass|secret|changeme|xxx+|\*+|<[^>]*>)@/i.test(v),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "npm-token",
|
|
219
|
+
name: "npm token",
|
|
220
|
+
severity: SEVERITY.HIGH,
|
|
221
|
+
pattern: /\b(npm_[A-Za-z0-9]{36})\b/g,
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/* ----------------------------------------------------------------- generic */
|
|
225
|
+
{
|
|
226
|
+
id: "credential-assignment",
|
|
227
|
+
name: "Credential in an assignment",
|
|
228
|
+
severity: SEVERITY.HIGH,
|
|
229
|
+
/**
|
|
230
|
+
* The one detector that needs entropy. It fires on `NAME = value` where the
|
|
231
|
+
* *name* claims credential and the *value* looks random. Both halves are
|
|
232
|
+
* required: `API_KEY=changeme` is not a leak and neither is a random string
|
|
233
|
+
* assigned to `hash`.
|
|
234
|
+
*/
|
|
235
|
+
pattern:
|
|
236
|
+
/\b([A-Za-z0-9_.-]*(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|AUTH|CREDENTIAL)[A-Za-z0-9_.-]*)\s*[:=]\s*["']?([^\s"',;}]{12,})["']?/gi,
|
|
237
|
+
group: 2,
|
|
238
|
+
nameGroup: 1,
|
|
239
|
+
validate: (v) => isHighEntropy(v) && !isPlaceholder(v),
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
id: "authorization-header",
|
|
243
|
+
name: "Authorization header",
|
|
244
|
+
severity: SEVERITY.HIGH,
|
|
245
|
+
pattern: /authorization["'\s:=]+["']?(?:Bearer|Basic|Token)\s+([A-Za-z0-9._~+/=-]{16,})["']?/gi,
|
|
246
|
+
group: 1,
|
|
247
|
+
validate: (v) => !isPlaceholder(v),
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
/* -------------------------------------------------------------------------- */
|
|
252
|
+
/* Heuristics */
|
|
253
|
+
/* -------------------------------------------------------------------------- */
|
|
254
|
+
|
|
255
|
+
/** Shannon entropy in bits per character. */
|
|
256
|
+
export function entropy(value) {
|
|
257
|
+
const s = String(value);
|
|
258
|
+
if (!s.length) return 0;
|
|
259
|
+
const freq = new Map();
|
|
260
|
+
for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
261
|
+
let bits = 0;
|
|
262
|
+
for (const n of freq.values()) {
|
|
263
|
+
const p = n / s.length;
|
|
264
|
+
bits -= p * Math.log2(p);
|
|
265
|
+
}
|
|
266
|
+
return bits;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function isHighEntropy(value, floor = ENTROPY_FLOOR) {
|
|
270
|
+
const s = String(value);
|
|
271
|
+
if (s.length < MIN_GENERIC_LENGTH) return false;
|
|
272
|
+
return entropy(s) >= floor;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Strings that look like secrets and are not.
|
|
277
|
+
*
|
|
278
|
+
* This list is the difference between a tool developers keep on and one they
|
|
279
|
+
* turn off in week two. Every entry here was a false positive somebody hit.
|
|
280
|
+
*/
|
|
281
|
+
const PLACEHOLDER =
|
|
282
|
+
/^(?:x{3,}|\*{3,}|\.{3,}|-{3,}|0{6,}|<[^>]*>|\$\{[^}]*\}|%[A-Z_]+%|\{\{[^}]*\}\}|your[-_.]?|my[-_.]?|example|sample|placeholder|dummy|fake|test|changeme|redacted|removed|hidden|secret|password|todo|tbd|null|none|undefined|insert[-_]?)/i;
|
|
283
|
+
|
|
284
|
+
const KNOWN_NON_SECRET =
|
|
285
|
+
/^(?:[0-9a-f]{7,8}|[0-9a-f]{40}|[0-9a-f]{64}|sha\d{3}-|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
286
|
+
|
|
287
|
+
export function isPlaceholder(value) {
|
|
288
|
+
const s = String(value).trim();
|
|
289
|
+
if (!s) return true;
|
|
290
|
+
if (PLACEHOLDER.test(s)) return true;
|
|
291
|
+
// A git SHA, an integrity hash, or a UUID. High entropy, not a credential.
|
|
292
|
+
if (KNOWN_NON_SECRET.test(s)) return true;
|
|
293
|
+
// A single repeated character, however long.
|
|
294
|
+
if (/^(.)\1+$/.test(s)) return true;
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* -------------------------------------------------------------------------- */
|
|
299
|
+
/* Findings */
|
|
300
|
+
/* -------------------------------------------------------------------------- */
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A stable identity for a secret that is not the secret.
|
|
304
|
+
*
|
|
305
|
+
* Truncated to 16 hex characters: enough to make a collision irrelevant at any
|
|
306
|
+
* volume a single machine produces, short enough to read in a terminal. It is
|
|
307
|
+
* an unsalted hash of a high-entropy value, which is not a reversal risk in the
|
|
308
|
+
* way an unsalted hash of a *password* would be.
|
|
309
|
+
*/
|
|
310
|
+
export function fingerprint(value) {
|
|
311
|
+
return "sha256:" + createHash("sha256").update(String(value)).digest("hex").slice(0, 16);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* A shape the operator can recognise without the value being present.
|
|
316
|
+
*
|
|
317
|
+
* Keeps the issuer prefix, because that is the part that identifies which
|
|
318
|
+
* system to go rotate, and the last four, because that is what consoles show.
|
|
319
|
+
* Everything between becomes bullets. A short value shows nothing at all.
|
|
320
|
+
*/
|
|
321
|
+
export function mask(value) {
|
|
322
|
+
const s = String(value);
|
|
323
|
+
if (s.length <= 8) return "•".repeat(s.length);
|
|
324
|
+
const prefixMatch = s.match(/^((?:AKIA|ASIA|gh[pousr]_|github_pat_|glpat-|sk-ant-|sk-proj-|sk-|rk_|pk_|xox[abposr]-|AIza|hf_|npm_|SG\.|AC)|[A-Za-z]{2,12}[_-])/);
|
|
325
|
+
const prefix = prefixMatch ? prefixMatch[1] : s.slice(0, 4);
|
|
326
|
+
const suffix = s.slice(-4);
|
|
327
|
+
const hidden = Math.max(4, s.length - prefix.length - suffix.length);
|
|
328
|
+
return `${prefix}${"•".repeat(Math.min(hidden, 24))}${suffix}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Scans a string for credential material.
|
|
333
|
+
*
|
|
334
|
+
* @returns {Array<{detector:string,name:string,severity:string,start:number,end:number,length:number,masked:string,fingerprint:string,variable?:string}>}
|
|
335
|
+
*/
|
|
336
|
+
/**
|
|
337
|
+
* Percent-decodes a string when doing so changes it.
|
|
338
|
+
*
|
|
339
|
+
* A credential in a query string is percent-encoded by construction:
|
|
340
|
+
* `postgres://admin:pw@host/db` becomes `postgres%3A%2F%2Fadmin%3Apw%40host%2Fdb`,
|
|
341
|
+
* and every detector that looks for `://` or `@` misses it. That is not an
|
|
342
|
+
* exotic evasion — it is what `encodeURIComponent` does, so it happens by
|
|
343
|
+
* accident on the way to happening on purpose.
|
|
344
|
+
*
|
|
345
|
+
* Decoded content is scanned in addition to the raw string, and offsets from
|
|
346
|
+
* the decoded pass are discarded rather than mapped back: a decoded match means
|
|
347
|
+
* the whole encoded run is the secret, so redaction targets that run.
|
|
348
|
+
*/
|
|
349
|
+
function decodeIfEncoded(value) {
|
|
350
|
+
if (!/%[0-9a-f]{2}/i.test(value)) return null;
|
|
351
|
+
try {
|
|
352
|
+
const decoded = decodeURIComponent(value);
|
|
353
|
+
return decoded === value ? null : decoded;
|
|
354
|
+
} catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function scanString(text, { detectors = DETECTORS } = {}) {
|
|
360
|
+
const s = String(text ?? "");
|
|
361
|
+
if (!s) return [];
|
|
362
|
+
const subject = s.length > MAX_SCAN_BYTES ? s.slice(0, MAX_SCAN_BYTES) : s;
|
|
363
|
+
|
|
364
|
+
const findings = [];
|
|
365
|
+
const seen = new Set();
|
|
366
|
+
|
|
367
|
+
// Second pass over the decoded form. Runs first so a decoded finding claims
|
|
368
|
+
// its span before the raw pass produces a partial match inside it.
|
|
369
|
+
const decoded = decodeIfEncoded(subject);
|
|
370
|
+
if (decoded) {
|
|
371
|
+
for (const finding of scanString(decoded, { detectors })) {
|
|
372
|
+
// The encoded run in the ORIGINAL string is what gets masked and
|
|
373
|
+
// redacted, so the offsets describe the text the caller actually holds.
|
|
374
|
+
const encodedRun = subject.match(/[A-Za-z0-9%._~:/?#[\]@!$&'()*+,;=-]{16,}/);
|
|
375
|
+
const start = encodedRun ? subject.indexOf(encodedRun[0]) : 0;
|
|
376
|
+
const end = encodedRun ? start + encodedRun[0].length : subject.length;
|
|
377
|
+
const key = `${start}:${end - start}`;
|
|
378
|
+
if (seen.has(key)) continue;
|
|
379
|
+
seen.add(key);
|
|
380
|
+
findings.push({
|
|
381
|
+
...finding,
|
|
382
|
+
start,
|
|
383
|
+
end,
|
|
384
|
+
length: end - start,
|
|
385
|
+
encoded: true,
|
|
386
|
+
masked: mask(subject.slice(start, end)),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (const detector of detectors) {
|
|
392
|
+
// A fresh regex per scan: a shared /g regex carries `lastIndex` between
|
|
393
|
+
// calls and silently skips every other match.
|
|
394
|
+
const re = new RegExp(detector.pattern.source, detector.pattern.flags);
|
|
395
|
+
let m;
|
|
396
|
+
while ((m = re.exec(subject)) !== null) {
|
|
397
|
+
if (m[0].length === 0) {
|
|
398
|
+
re.lastIndex++;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const group = detector.group ?? (m.length > 1 ? 1 : 0);
|
|
402
|
+
const value = m[group] ?? m[0];
|
|
403
|
+
if (!value) continue;
|
|
404
|
+
if (detector.validate && !detector.validate(value)) continue;
|
|
405
|
+
if (isPlaceholder(value) && detector.id !== "private-key") continue;
|
|
406
|
+
|
|
407
|
+
// Offsets of the secret itself, not of the surrounding match, so
|
|
408
|
+
// redaction replaces the credential and leaves `API_KEY=` readable.
|
|
409
|
+
const offset = group === 0 ? m.index : subject.indexOf(value, m.index);
|
|
410
|
+
const start = offset === -1 ? m.index : offset;
|
|
411
|
+
|
|
412
|
+
// The same value found twice by two detectors is one leak.
|
|
413
|
+
const key = `${start}:${value.length}`;
|
|
414
|
+
if (seen.has(key)) continue;
|
|
415
|
+
seen.add(key);
|
|
416
|
+
|
|
417
|
+
findings.push({
|
|
418
|
+
detector: detector.id,
|
|
419
|
+
name: detector.name,
|
|
420
|
+
severity: detector.severity,
|
|
421
|
+
start,
|
|
422
|
+
end: start + value.length,
|
|
423
|
+
length: value.length,
|
|
424
|
+
masked: mask(value),
|
|
425
|
+
fingerprint: fingerprint(value),
|
|
426
|
+
...(detector.nameGroup && m[detector.nameGroup]
|
|
427
|
+
? { variable: m[detector.nameGroup] }
|
|
428
|
+
: {}),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return findings.sort((a, b) => a.start - b.start);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Scans a JSON-shaped value, reporting the path to each finding.
|
|
438
|
+
*
|
|
439
|
+
* Paths are dotted with bracketed indices (`arguments.headers.authorization`,
|
|
440
|
+
* `content[0].text`) so an operator can point at the exact field rather than
|
|
441
|
+
* being told "somewhere in this payload".
|
|
442
|
+
*/
|
|
443
|
+
export function scan(value, { detectors = DETECTORS } = {}) {
|
|
444
|
+
const findings = [];
|
|
445
|
+
|
|
446
|
+
const walk = (node, path, depth) => {
|
|
447
|
+
if (depth > MAX_DEPTH) return;
|
|
448
|
+
if (typeof node === "string") {
|
|
449
|
+
for (const f of scanString(node, { detectors })) findings.push({ ...f, path });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (Array.isArray(node)) {
|
|
453
|
+
node.forEach((item, i) => walk(item, `${path}[${i}]`, depth + 1));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (node && typeof node === "object") {
|
|
457
|
+
for (const [k, v] of Object.entries(node)) {
|
|
458
|
+
walk(v, path ? `${path}.${k}` : k, depth + 1);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
walk(value, "", 0);
|
|
464
|
+
return findings;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** True when anything at or above `floor` severity was found. */
|
|
468
|
+
export function hasSecrets(value, floor = SEVERITY.MEDIUM) {
|
|
469
|
+
const rank = { [SEVERITY.MEDIUM]: 0, [SEVERITY.HIGH]: 1, [SEVERITY.CRITICAL]: 2 };
|
|
470
|
+
return scan(value).some((f) => rank[f.severity] >= rank[floor]);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/* -------------------------------------------------------------------------- */
|
|
474
|
+
/* Redaction */
|
|
475
|
+
/* -------------------------------------------------------------------------- */
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Replaces every detected secret in a string.
|
|
479
|
+
*
|
|
480
|
+
* `replace` receives the finding and returns what goes in its place. The
|
|
481
|
+
* default puts the masked shape back rather than a bare `[REDACTED]`, because
|
|
482
|
+
* an agent that sees `AKIA••••••••••••EXMP` knows an AWS key was there and can
|
|
483
|
+
* ask for a handle; one that sees `[REDACTED]` knows only that something was
|
|
484
|
+
* taken away.
|
|
485
|
+
*
|
|
486
|
+
* Applied right-to-left so earlier offsets stay valid as the string changes
|
|
487
|
+
* length underneath them.
|
|
488
|
+
*/
|
|
489
|
+
export function redactString(text, { detectors = DETECTORS, replace = (f) => f.masked } = {}) {
|
|
490
|
+
const s = String(text ?? "");
|
|
491
|
+
const findings = scanString(s, { detectors });
|
|
492
|
+
if (!findings.length) return { text: s, findings };
|
|
493
|
+
|
|
494
|
+
let out = s;
|
|
495
|
+
for (const f of [...findings].sort((a, b) => b.start - a.start)) {
|
|
496
|
+
out = out.slice(0, f.start) + replace(f) + out.slice(f.end);
|
|
497
|
+
}
|
|
498
|
+
return { text: out, findings };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** `redactString` over a whole structure. */
|
|
502
|
+
export function redact(value, options = {}) {
|
|
503
|
+
const findings = [];
|
|
504
|
+
|
|
505
|
+
const walk = (node, path, depth) => {
|
|
506
|
+
if (depth > MAX_DEPTH) return node;
|
|
507
|
+
if (typeof node === "string") {
|
|
508
|
+
const r = redactString(node, options);
|
|
509
|
+
for (const f of r.findings) findings.push({ ...f, path });
|
|
510
|
+
return r.text;
|
|
511
|
+
}
|
|
512
|
+
if (Array.isArray(node)) return node.map((item, i) => walk(item, `${path}[${i}]`, depth + 1));
|
|
513
|
+
if (node && typeof node === "object") {
|
|
514
|
+
return Object.fromEntries(
|
|
515
|
+
Object.entries(node).map(([k, v]) => [k, walk(v, path ? `${path}.${k}` : k, depth + 1)]),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
return node;
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
return { value: walk(value, "", 0), findings };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Groups findings by detector for a summary line. */
|
|
525
|
+
export function summarize(findings) {
|
|
526
|
+
const by = new Map();
|
|
527
|
+
for (const f of findings) {
|
|
528
|
+
const entry = by.get(f.detector) ?? { detector: f.detector, name: f.name, severity: f.severity, count: 0 };
|
|
529
|
+
entry.count++;
|
|
530
|
+
by.set(f.detector, entry);
|
|
531
|
+
}
|
|
532
|
+
return [...by.values()].sort((a, b) => b.count - a.count);
|
|
533
|
+
}
|