@clear-capabilities/agentic-security-scanner 0.124.1 → 0.128.1

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/bin/agentic-security.js +75 -2
  3. package/dist/11.index.js +353 -0
  4. package/dist/113.index.js +525 -0
  5. package/dist/178.index.js +1 -1
  6. package/dist/220.index.js +193 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/435.index.js +2406 -0
  9. package/dist/449.index.js +135 -0
  10. package/dist/637.index.js +1 -1
  11. package/dist/752.index.js +7 -4
  12. package/dist/801.index.js +87 -0
  13. package/dist/826.index.js +4 -1
  14. package/dist/838.index.js +1 -1
  15. package/dist/agentic-security.mjs +1 -2
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +6 -6
  18. package/src/engine.js +31 -1
  19. package/src/integrations/tickets.js +9 -3
  20. package/src/ir/CLAUDE.md +22 -17
  21. package/src/llm-validator/index.js +47 -12
  22. package/src/mcp/tools.js +108 -3
  23. package/src/posture/CLAUDE.md +10 -1
  24. package/src/posture/cache-economics.js +7 -4
  25. package/src/posture/deterministic-fix.js +65 -0
  26. package/src/posture/entrypoint-inventory.js +248 -0
  27. package/src/posture/falsification.js +121 -0
  28. package/src/posture/fix-honesty-gate.js +175 -0
  29. package/src/posture/fix-verify.js +18 -3
  30. package/src/posture/model-routing.js +126 -0
  31. package/src/posture/mttr.js +25 -0
  32. package/src/posture/provider-catalog.js +108 -0
  33. package/src/posture/root-cause-sweep.js +262 -0
  34. package/src/posture/secret-live-check.js +71 -0
  35. package/src/pr-comment.js +3 -1
  36. package/src/sast/CLAUDE.md +1 -1
  37. package/src/sast/api-authz.js +36 -0
  38. package/src/sast/file-upload.js +118 -0
  39. package/src/sast/llm-cost-advisor.js +88 -0
  40. package/src/util/untrusted.js +148 -0
@@ -34,6 +34,23 @@ function mk(r, kind, api, cwe, why) {
34
34
  };
35
35
  }
36
36
 
37
+ // #9 — CWE-306 missing authentication for a state-changing route.
38
+ function mk306(r) {
39
+ return {
40
+ id: `api-authz:missing-auth:${r.file}:${r.line}`,
41
+ severity: 'high',
42
+ file: r.file,
43
+ line: r.line || 0,
44
+ vuln: `Missing authentication for a state-changing route (${r.method} ${r.path})`,
45
+ cwe: '306',
46
+ family: 'broken-access-control',
47
+ parser: 'API-AUTHZ',
48
+ subfamily: 'missing-auth',
49
+ description: `${r.method} ${r.path} performs a destructive/state-changing operation with no authentication, while other routes in this app DO enforce it — so auth-detection demonstrably works here. An unauthenticated ${r.method} lets anyone invoke it (delete/modify another user's data).`,
50
+ remediation: 'Require authentication on this route (the same middleware/guard the app uses elsewhere) and, for object routes, verify the caller owns the target object.',
51
+ };
52
+ }
53
+
37
54
  /**
38
55
  * Cross-route analysis over the aggregated route inventory (aR).
39
56
  * Pure: takes routes[], returns Finding[].
@@ -67,5 +84,24 @@ export function scanApiBrokenAuthz(routes) {
67
84
  }
68
85
  }
69
86
  }
87
+
88
+ // #9 — CWE-306 app-level pass. If the app authenticates SOME route (so our
89
+ // auth-detection works for it), a destructive route left unauthenticated is a
90
+ // missing-auth bug even when its file-local siblings are also public (the
91
+ // in-file inconsistency rule above cannot see that case). Scoped to DELETE and
92
+ // id-taking PUT/PATCH — the least-ambiguous "should never be public" shapes —
93
+ // so intentionally-public POSTs (login / signup / webhooks) don't false-fire.
94
+ // `push` dedupes by file:line, so a route already flagged BFLA/BOLA above is
95
+ // not double-reported here.
96
+ const appHasAuth = routes.some((r) => r && r.hasAuth);
97
+ if (appHasAuth) {
98
+ for (const r of routes) {
99
+ if (!r || r.hasAuth || !r.file || r.path === '(file-based)') continue;
100
+ const destructive = r.method === 'DELETE'
101
+ || ((r.method === 'PUT' || r.method === 'PATCH') && ID_PARAM.test(r.path || ''));
102
+ if (destructive) push(mk306(r));
103
+ }
104
+ }
105
+
70
106
  return findings;
71
107
  }
@@ -0,0 +1,118 @@
1
+ // CWE-434 — Unrestricted file upload (#6). A whole CWE that had NO detector.
2
+ //
3
+ // Bread-and-butter for the vibecoder stacks (Next.js / Express / Supabase /
4
+ // Firebase / FastAPI / Flask): an upload endpoint that writes an attacker-
5
+ // supplied file without restricting its type/extension/size, or that uses the
6
+ // client-supplied filename as the on-disk destination (also CWE-22 path
7
+ // traversal — `../../x` in the filename escapes the upload dir).
8
+ //
9
+ // Precision is the whole game here (uploads are everywhere). Each rule fires
10
+ // only on a clear unrestricted shape and is suppressed by the standard guard:
11
+ // - Multer configured with NEITHER fileFilter NOR limits → unrestricted.
12
+ // - A write whose destination is built from the CLIENT filename
13
+ // (file.originalname / req.files.*.name / UploadFile.filename) with no
14
+ // sanitizer (basename / uuid / randomUUID / sanitize / whitelist) nearby.
15
+ // A validated upload (fileFilter+limits, or a generated/sanitized name) does
16
+ // NOT match.
17
+ import { blankComments } from './_comment-strip.js';
18
+
19
+ const JS_EXT = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
20
+ const PY_EXT = /\.py$/i;
21
+
22
+ const _lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
23
+ const _snip = (raw, line) => (raw.split('\n')[line - 1] || '').trim().slice(0, 200);
24
+ // A sanitizer for the destination filename anywhere in the ±6-line window.
25
+ const NAME_SANITIZER = /\b(?:basename|randomUUID|uuidv4|uuid4|uuid\.v4|nanoid|sanitize[-_]?filename|sanitizeFilename|slugify|crypto\.random|secure_filename|werkzeug)\b/i;
26
+
27
+ function _window(raw, line, half = 6) {
28
+ const lines = raw.split('\n');
29
+ const start = Math.max(0, line - 1 - half);
30
+ const end = Math.min(lines.length, line - 1 + half);
31
+ return lines.slice(start, end).join('\n');
32
+ }
33
+
34
+ function mk(file, raw, line, sub, severity, vuln, description, remediation) {
35
+ return {
36
+ id: `file-upload:${sub}:${file}:${line}`,
37
+ severity, file, line,
38
+ vuln, cwe: 'CWE-434',
39
+ family: 'unrestricted-file-upload',
40
+ parser: 'FILE-UPLOAD',
41
+ subfamily: sub,
42
+ snippet: _snip(raw, line),
43
+ description,
44
+ remediation,
45
+ };
46
+ }
47
+
48
+ function scanJs(file, raw, code, out, seen) {
49
+ const push = (line, mkr) => { const k = `${line}`; if (seen.has(k)) return; seen.add(k); out.push(mkr); };
50
+
51
+ // 1) Multer with neither fileFilter nor limits → unrestricted upload config.
52
+ // Matches `multer()` and `multer({ ... })` whose options lack both guards.
53
+ const multerRe = /\bmulter\s*\(\s*(?:\)|\{([\s\S]*?)\}\s*\))/g;
54
+ let m;
55
+ while ((m = multerRe.exec(code))) {
56
+ const opts = m[1] || '';
57
+ if (/\bfileFilter\b/.test(opts) || /\blimits\b/.test(opts)) continue; // guarded
58
+ const line = _lineOf(raw, m.index);
59
+ push(line, mk(file, raw, line, 'multer-unrestricted', 'medium',
60
+ 'Unrestricted file upload — Multer configured with no fileFilter and no limits',
61
+ 'This Multer instance accepts any file of any size. An attacker can upload an executable, oversized, or malicious file (web shell, zip bomb).',
62
+ 'Add a `fileFilter` that allow-lists MIME types / extensions and a `limits: { fileSize }` cap. Store uploads outside the web root and never serve them with their uploaded name.'));
63
+ }
64
+
65
+ // 2) Write whose destination is built from the CLIENT-supplied filename.
66
+ // e.g. path.join(dir, file.originalname) / req.files.x.mv('...'+req.files.x.name)
67
+ // Also CWE-22: `../../` in the filename escapes the upload dir.
68
+ const clientName = /\b(?:\w+\.originalname|req\.files(?:\.\w+|\[[^\]]+\])?\.name)\b/;
69
+ const writeSinks = [
70
+ /\.mv\s*\(/, // express-fileupload
71
+ /\b(?:fs\.)?(?:writeFile|writeFileSync|createWriteStream)\s*\(/,
72
+ /\bpath\.join\s*\(/, // building the dest path
73
+ ];
74
+ const lines = code.split('\n');
75
+ for (let i = 0; i < lines.length; i++) {
76
+ if (!clientName.test(lines[i])) continue;
77
+ if (!writeSinks.some(re => re.test(lines[i]))) continue;
78
+ const line = i + 1;
79
+ if (NAME_SANITIZER.test(_window(raw, line))) continue; // sanitized/generated name → safe
80
+ push(line, mk(file, raw, line, 'client-filename-dest', 'high',
81
+ 'Unrestricted file upload — client-supplied filename used as the write destination',
82
+ 'The uploaded file is written using its client-controlled name. An attacker can choose the extension (upload `shell.php`) or embed path traversal (`../../etc/x`) to escape the upload directory.',
83
+ 'Never trust the uploaded filename. Generate a server-side name (uuid/nanoid) and validate the extension against an allow-list; write with path.basename() into a fixed directory outside the web root.'));
84
+ }
85
+ }
86
+
87
+ function scanPy(file, raw, code, out, seen) {
88
+ const push = (line, mkr) => { const k = `${line}`; if (seen.has(k)) return; seen.add(k); out.push(mkr); };
89
+ // Flask: request.files['x'].save(os.path.join(dir, file.filename))
90
+ // FastAPI: open(file.filename, ...) / shutil.copyfileobj(upload.file, open(upload.filename))
91
+ const clientName = /\b\w+\.filename\b/;
92
+ const writeSinks = [/\.save\s*\(/, /\bopen\s*\(/, /\bos\.path\.join\s*\(/, /\bcopyfileobj\s*\(/];
93
+ const lines = code.split('\n');
94
+ for (let i = 0; i < lines.length; i++) {
95
+ if (!clientName.test(lines[i])) continue;
96
+ if (!writeSinks.some(re => re.test(lines[i]))) continue;
97
+ const line = i + 1;
98
+ if (NAME_SANITIZER.test(_window(raw, line))) continue; // secure_filename / uuid → safe
99
+ push(line, mk(file, raw, line, 'client-filename-dest', 'high',
100
+ 'Unrestricted file upload — client-supplied filename used as the write destination',
101
+ 'The uploaded file is saved under its client-controlled name. An attacker can choose the extension or embed path traversal to escape the upload directory.',
102
+ 'Use werkzeug secure_filename() (Flask) or generate a uuid name; validate the extension against an allow-list and write into a fixed directory outside the web root.'));
103
+ }
104
+ }
105
+
106
+ export function scanFileUpload(fp, raw) {
107
+ if (!raw || raw.length > 500_000) return [];
108
+ const isJs = JS_EXT.test(fp), isPy = PY_EXT.test(fp);
109
+ if (!isJs && !isPy) return [];
110
+ // Cheap relevance gate — skip files with no upload surface.
111
+ if (!/\b(?:multer|originalname|req\.files|UploadFile|\.filename|createWriteStream|\.mv\s*\()/i.test(raw)) return [];
112
+ const code = blankComments(raw, isPy ? 'py' : null);
113
+ const out = [];
114
+ const seen = new Set();
115
+ try { if (isJs) scanJs(fp, raw, code, out, seen); } catch { /* per-file best-effort */ }
116
+ try { if (isPy) scanPy(fp, raw, code, out, seen); } catch { /* per-file best-effort */ }
117
+ return out;
118
+ }
@@ -0,0 +1,88 @@
1
+ // LLM cost + prompt-cache advisor (PRD CACHE_ECONOMICS_V2 — F1 cache-hygiene +
2
+ // P3 per-provider model/depth recommendation), as a SAST detector over the
3
+ // user's own LLM-calling code.
4
+ //
5
+ // Two rules, both gated on detecting an LLM provider in the file (low FP), both
6
+ // emitted at ADVISORY severity so they never inflate security counts:
7
+ //
8
+ // 1. cache-killer: a non-deterministic value (Date.now / uuid / datetime.now)
9
+ // interpolated into a prompt/system string — defeats prompt caching for the
10
+ // whole prefix after it (every provider).
11
+ // 2. over-provisioned: a flagship model used at a high reasoning depth — the
12
+ // catalog suggests a cheaper model + lower depth WITHIN the same provider.
13
+ //
14
+ // Provider/model/cache facts come from posture/provider-catalog.js (P1/P2).
15
+ import { blankComments } from './_comment-strip.js';
16
+ import { detectProvider, PROVIDERS, modelEntry, cheaperModel, depthAxis, cacheModel, SOURCED_AT } from '../posture/provider-catalog.js';
17
+
18
+ // How this provider's cache behaves — shapes the cache-killer remediation.
19
+ const CACHE_HINT = {
20
+ explicit: "Claude's prompt cache (set on a stable prefix via cache_control)",
21
+ automatic: 'the provider\'s automatic prompt cache (matches a ≥1024-token static prefix)',
22
+ 'implicit-explicit': "Gemini's implicit/explicit context cache",
23
+ };
24
+
25
+ const NONDET = /\b(?:Date\.now|new\s+Date|datetime\.now|datetime\.utcnow|time\.time|uuid4|uuidv4|crypto\.randomUUID|uuid\.uuid4|secrets\.token_hex|os\.urandom)\s*\(/;
26
+ const PROMPT_CTX = /\b(?:system|instructions?|developer|messages|prompt)\b|["']role["']\s*:\s*["'](?:system|developer)["']/i;
27
+ const EXPENSIVE_DEPTH = /(?:reasoning_effort|["']?effort["']?)\s*[:=]\s*["']?(?:high|xhigh|max)["']?|thinking[_]?budget\s*[:=]\s*\(?\s*(?:[1-9]\d{4,})/i;
28
+
29
+ const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
30
+ const snippetAt = (raw, line) => (raw.split('\n')[line - 1] || '').trim().slice(0, 200);
31
+
32
+ export function scanLlmCost(fp, raw) {
33
+ if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py|rb)$/i.test(fp)) return [];
34
+ if (!raw || raw.length > 500_000) return [];
35
+ const provider = detectProvider(raw);
36
+ if (!provider) return [];
37
+ const code = blankComments(raw);
38
+ const lines = code.split('\n');
39
+ const findings = [];
40
+ const seen = new Set();
41
+ const push = (f) => { if (!seen.has(f.id)) { seen.add(f.id); findings.push(f); } };
42
+ const pLabel = PROVIDERS[provider].label;
43
+
44
+ // ── Rule 1: non-deterministic content in a prompt-building string ──────────
45
+ for (let i = 0; i < lines.length; i++) {
46
+ if (!NONDET.test(lines[i])) continue;
47
+ // Require a prompt-context marker on the SAME line — the volatile value is
48
+ // being built into a system/prompt/messages string. (Same-line keeps FP low:
49
+ // a `datetime.now()` in a nearby log line must not trip on a `SYSTEM =` const
50
+ // three lines up.)
51
+ if (!PROMPT_CTX.test(lines[i])) continue;
52
+ const line = i + 1;
53
+ push({
54
+ id: `llm-cache-nondeterminism:${fp}:${line}`,
55
+ file: fp, line,
56
+ vuln: `Prompt-cache killer (cost advisory) — non-deterministic value in a ${pLabel} prompt prefix`,
57
+ severity: 'low', cwe: 'CWE-400', family: 'llm-cache', parser: 'LLM-COST', confidence: 0.6,
58
+ snippet: snippetAt(raw, line),
59
+ remediation: `A timestamp / UUID / random value in the cached prefix changes its bytes every request, so ${CACHE_HINT[cacheModel(provider)?.kind] || 'the prompt cache'} never hits and you pay full input price every call. Move the volatile value AFTER the stable prefix (or out of the prompt entirely) so the long shared prefix stays byte-identical and cacheable.`,
60
+ });
61
+ }
62
+
63
+ // ── Rule 2: flagship model at high depth → recommend a cheaper option ──────
64
+ const expensiveModels = PROVIDERS[provider].models.filter(m => m.tier >= 2);
65
+ const dax = depthAxis(provider);
66
+ for (let i = 0; i < lines.length; i++) {
67
+ const m = expensiveModels.find(em => em.match.test(lines[i]));
68
+ if (!m) continue;
69
+ // Look for an expensive depth setting in the same call window (±6 lines).
70
+ const win = lines.slice(Math.max(0, i - 6), i + 7).join('\n');
71
+ if (!EXPENSIVE_DEPTH.test(win)) continue;
72
+ const line = i + 1;
73
+ const cheaper = cheaperModel(provider, m.id);
74
+ const alt = cheaper
75
+ ? `${cheaper.id} at ${dax?.knob}=${dax?.cheap}`
76
+ : `a lower ${dax?.knob || 'reasoning depth'}`;
77
+ push({
78
+ id: `llm-overprovisioned:${fp}:${line}`,
79
+ file: fp, line,
80
+ vuln: `Over-provisioned model (cost advisory) — ${pLabel} flagship at high depth`,
81
+ severity: 'info', cwe: 'CWE-400', family: 'llm-cost', parser: 'LLM-COST', confidence: 0.5,
82
+ snippet: snippetAt(raw, line),
83
+ remediation: `This call pairs a flagship model (${m.id}) with a high ${dax?.knob || 'reasoning'} setting — the most expensive combination in ${pLabel}. If the task isn't intelligence-critical, try ${alt} first and measure: it can cut cost several-fold with little quality loss. Keep the flagship+high only where correctness clearly needs it. (Catalog pricing as of ${SOURCED_AT}.)`,
84
+ });
85
+ }
86
+
87
+ return findings;
88
+ }
@@ -0,0 +1,148 @@
1
+ // Untrusted-content hardening primitives (addition #4: meta-security —
2
+ // self-hardening the agent surface).
3
+ //
4
+ // Attacker-authored code and finding text reach several LLM prompts, several
5
+ // rendered outputs (issue / PR / ticket bodies), and audit writers. This module
6
+ // is the single, tested place that neutralizes that content before it crosses a
7
+ // trust boundary. See docs/AGENT_THREAT_MODEL.md for the path→CWE map.
8
+ //
9
+ // Design notes:
10
+ // - Pure + dependency-light (node:crypto, node:fs only). No network, no state.
11
+ // - Fail-closed: unknown/adversarial input degrades to the safe value ('' or
12
+ // `false`), never throws.
13
+ // - Deterministic: fenceUntrusted derives its nonce from the content hash so
14
+ // the wrapping is reproducible and testable (no Date.now / random source).
15
+ import { createHash } from 'node:crypto';
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+
19
+ // ─── escapeMarkdown ──────────────────────────────────────────────────────────
20
+ // Neutralize markdown/HTML control characters so attacker-controlled finding
21
+ // text cannot inject markup, links, or code spans when interpolated into an
22
+ // issue / PR / ticket body. HTML-dangerous chars (& < >) are entity-encoded so
23
+ // no raw tag can render in any markdown flavour; markdown-structural chars
24
+ // (backtick [ ] ! and backslash) are backslash-escaped.
25
+ //
26
+ // Non-strings collapse to '' (fail-closed — a null vuln never becomes "null").
27
+ //
28
+ // Order is load-bearing: escape `&` before we emit `&amp;`/`&lt;`/`&gt;`, and
29
+ // escape literal `\` before we introduce our own backslashes, so nothing is
30
+ // double-consumed.
31
+ export function escapeMarkdown(s) {
32
+ if (typeof s !== 'string') return '';
33
+ return s
34
+ .replace(/&/g, '&amp;')
35
+ .replace(/</g, '&lt;')
36
+ .replace(/>/g, '&gt;')
37
+ .replace(/\\/g, '\\\\')
38
+ .replace(/`/g, '\\`')
39
+ .replace(/\[/g, '\\[')
40
+ .replace(/\]/g, '\\]')
41
+ .replace(/!/g, '\\!');
42
+ }
43
+
44
+ // ─── fenceUntrusted ──────────────────────────────────────────────────────────
45
+ // Wrap untrusted text in a clearly-delimited block whose delimiter carries a
46
+ // per-call nonce, so an injected close-delimiter inside the text cannot
47
+ // terminate the fence early (the classic prompt-injection "break out of the
48
+ // data block" move). Intended for the LLM-prompt paths (triage / dedup / fix)
49
+ // where the model must treat the wrapped span as inert data.
50
+ //
51
+ // The nonce is derived deterministically from a sha256 of the content (first 8
52
+ // hex). That makes it (a) reproducible/testable and (b) unguessable by the
53
+ // author of the content — an attacker cannot pre-compute the resulting nonce to
54
+ // forge a matching close-delimiter, because the nonce depends on the very bytes
55
+ // they would have to write.
56
+ //
57
+ // Returns { text, nonce }.
58
+ export function fenceUntrusted(s, label = 'untrusted') {
59
+ const content = typeof s === 'string' ? s : '';
60
+ const lbl = String(label || 'untrusted').replace(/[^A-Za-z0-9_-]/g, '');
61
+ const nonce = createHash('sha256').update(content, 'utf8').digest('hex').slice(0, 8);
62
+ const open = `<<BEGIN ${lbl} ${nonce}>>`;
63
+ const close = `<<END ${lbl} ${nonce}>>`;
64
+ return { text: `${open}\n${content}\n${close}`, nonce };
65
+ }
66
+
67
+ // ─── isAllowedFetchHost ──────────────────────────────────────────────────────
68
+ // Guard for any outbound fetch whose URL can be influenced by untrusted finding
69
+ // data (e.g. a metadata/advisory URL lifted from a dependency manifest). Blocks
70
+ // SSRF against link-local / loopback / RFC1918 targets AND enforces an explicit
71
+ // allowlist — a host must be BOTH non-internal AND on the allowlist. Empty
72
+ // allowlist ⇒ nothing passes (fail-closed). Malformed URL ⇒ false.
73
+ export function isAllowedFetchHost(url, allowlist = []) {
74
+ let host;
75
+ try {
76
+ host = new URL(String(url)).hostname.toLowerCase();
77
+ } catch {
78
+ return false;
79
+ }
80
+ if (!host) return false;
81
+ // Strip IPv6 brackets: "[::1]" → "::1".
82
+ const h = host.replace(/^\[/, '').replace(/\]$/, '');
83
+
84
+ // Block internal / link-local / loopback destinations up front — these must
85
+ // never be reachable even if an operator mistakenly allowlists one.
86
+ if (h === 'localhost' || h.endsWith('.localhost')) return false;
87
+ if (h === '::1' || h === '0.0.0.0') return false;
88
+ if (h === '169.254.169.254' || h.startsWith('169.254.')) return false; // link-local + cloud metadata
89
+ if (h.startsWith('127.')) return false; // loopback /8
90
+ if (h.startsWith('10.')) return false; // RFC1918 /8
91
+ if (h.startsWith('192.168.')) return false; // RFC1918 /16
92
+ const m172 = h.match(/^172\.(\d{1,3})\./); // RFC1918 172.16-31/12
93
+ if (m172) {
94
+ const oct = Number(m172[1]);
95
+ if (oct >= 16 && oct <= 31) return false;
96
+ }
97
+
98
+ const allow = Array.isArray(allowlist)
99
+ ? allowlist.map((a) => String(a).toLowerCase())
100
+ : [];
101
+ if (allow.length === 0) return false; // fail-closed: no allowlist ⇒ deny all
102
+ return allow.includes(h);
103
+ }
104
+
105
+ // ─── redactSecrets ───────────────────────────────────────────────────────────
106
+ // Mask token-shaped substrings before finding-adjacent text is written to an
107
+ // audit log or handed to an LLM. The provider/scheme prefix is preserved so a
108
+ // human triager can still tell WHAT kind of credential leaked without seeing
109
+ // its value. Non-strings collapse to ''.
110
+ const _REDACTED = '***REDACTED***';
111
+ export function redactSecrets(s) {
112
+ if (typeof s !== 'string') return '';
113
+ return s
114
+ // URL basic-auth: scheme://user:password@ → keep user, mask password.
115
+ .replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)[^\s@/]+@/gi, `$1${_REDACTED}@`)
116
+ // Authorization: Bearer <token>
117
+ .replace(/\b(Authorization\s*:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, `$1${_REDACTED}`)
118
+ // ?access_token= / &token= / ?token= / &access_token=
119
+ .replace(/([?&](?:access_token|token)=)[^&\s#]+/gi, `$1${_REDACTED}`)
120
+ // Raw provider token prefixes (GitHub PATs, Anthropic keys). Keep prefix.
121
+ .replace(/\b(ghp_|gho_|ghu_|ghs_|github_pat_|sk-ant-)[A-Za-z0-9_-]+/g, `$1${_REDACTED}`);
122
+ }
123
+
124
+ // ─── secure filesystem writes ────────────────────────────────────────────────
125
+ // Audit logs, scan state, and any file that may carry finding text or secrets
126
+ // must be owner-only. openSync's mode argument is still masked by the process
127
+ // umask, so writeSecure ALSO chmods explicitly — the file is 0600 regardless of
128
+ // the ambient umask. secureDirMode is the matching 0700 for any parent dir we
129
+ // have to create.
130
+ export const secureFileMode = 0o600;
131
+ export const secureDirMode = 0o700;
132
+
133
+ export function writeSecure(filePath, data) {
134
+ const dir = path.dirname(filePath);
135
+ if (!fs.existsSync(dir)) {
136
+ fs.mkdirSync(dir, { recursive: true, mode: secureDirMode });
137
+ try { fs.chmodSync(dir, secureDirMode); } catch { /* best-effort */ }
138
+ }
139
+ const fd = fs.openSync(filePath, 'w', secureFileMode);
140
+ try {
141
+ fs.writeSync(fd, typeof data === 'string' ? data : String(data ?? ''));
142
+ } finally {
143
+ fs.closeSync(fd);
144
+ }
145
+ // Force the mode down even if umask loosened it at creation time.
146
+ fs.chmodSync(filePath, secureFileMode);
147
+ return filePath;
148
+ }