@clear-capabilities/agentic-security-scanner 0.142.0 → 0.144.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/bin/agentic-security.js +530 -54
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +108 -17
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +1 -1
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/435.index.js +165 -52
  11. package/dist/526.index.js +108 -17
  12. package/dist/552.index.js +97 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/730.index.js +311 -0
  15. package/dist/736.index.js +301 -0
  16. package/dist/824.index.js +7 -0
  17. package/dist/905.index.js +88 -22
  18. package/dist/920.index.js +491 -0
  19. package/dist/970.index.js +109 -0
  20. package/dist/agentic-security.mjs +13 -13
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/dist/calibration-seed.json +2 -0
  23. package/package.json +19 -11
  24. package/src/dataflow/index.js +18 -0
  25. package/src/dataflow/privacy-catalog.js +290 -0
  26. package/src/dataflow/privacy-deep-walker.js +515 -0
  27. package/src/dataflow/privacy-governance.js +126 -0
  28. package/src/dataflow/privacy-inventory.js +154 -0
  29. package/src/dataflow/privacy-sink-policy.js +125 -0
  30. package/src/dataflow/privacy-taint.js +115 -54
  31. package/src/dataflow/privacy-taxonomy.js +233 -0
  32. package/src/discovery/disprove.js +7 -3
  33. package/src/discovery/hunter.js +9 -5
  34. package/src/discovery/index.js +2 -2
  35. package/src/discovery/llm-invoke.js +69 -13
  36. package/src/egress/audit.js +147 -0
  37. package/src/egress/policy.js +313 -0
  38. package/src/egress/redact.js +180 -0
  39. package/src/engine.js +575 -288
  40. package/src/fix/apply-fix-service.js +403 -0
  41. package/src/fix/approver-registry.js +157 -0
  42. package/src/llm-validator/index.js +86 -9
  43. package/src/llm-validator/model-status.js +66 -0
  44. package/src/mcp/tools.js +157 -50
  45. package/src/pipeline/analyzer-supervisor.js +93 -0
  46. package/src/pipeline/analyzer-worker.js +26 -0
  47. package/src/pipeline/annotator-runner.js +33 -0
  48. package/src/pipeline/assurance-mode.js +91 -0
  49. package/src/pipeline/cascade-worker-pool.js +172 -0
  50. package/src/pipeline/cascade-worker.js +43 -0
  51. package/src/pipeline/coverage-ledger.js +0 -0
  52. package/src/pipeline/detector-runner.js +51 -0
  53. package/src/pipeline/enrichment-completion.js +58 -0
  54. package/src/pipeline/evidence-provenance.js +91 -0
  55. package/src/pipeline/finding-schema.js +101 -0
  56. package/src/pipeline/legacy-compat.js +101 -0
  57. package/src/pipeline/producer-collector.js +48 -0
  58. package/src/pipeline/producer-registry.js +112 -0
  59. package/src/pipeline/scan-health.js +144 -0
  60. package/src/posture/CLAUDE.md +2 -0
  61. package/src/posture/accuracy-scorecard.js +96 -1
  62. package/src/posture/adversary-agent.js +15 -3
  63. package/src/posture/artifact-registry.js +217 -0
  64. package/src/posture/auditor-walkthrough.js +70 -8
  65. package/src/posture/calibration-feedback.js +201 -0
  66. package/src/posture/calibration-seed.json +2 -0
  67. package/src/posture/calibration.js +25 -0
  68. package/src/posture/compliance-evidence-signing.js +131 -0
  69. package/src/posture/compliance-policy.js +314 -17
  70. package/src/posture/custom-rules.js +36 -0
  71. package/src/posture/deterministic.js +8 -1
  72. package/src/posture/encryption-provider.js +205 -0
  73. package/src/posture/evidence-grade-wording.js +71 -0
  74. package/src/posture/fix-history.js +113 -19
  75. package/src/posture/fix-honesty-gate.js +47 -6
  76. package/src/posture/fix-verify.js +56 -7
  77. package/src/posture/fleet.js +0 -0
  78. package/src/posture/flow-narration.js +7 -2
  79. package/src/posture/legal-hold.js +140 -0
  80. package/src/posture/llm-redteam.js +10 -1
  81. package/src/posture/material-change.js +90 -0
  82. package/src/posture/policy-bundle.js +274 -0
  83. package/src/posture/privacy-framework.js +33 -6
  84. package/src/posture/production-feedback.js +179 -0
  85. package/src/posture/retention-policy.js +132 -0
  86. package/src/posture/risk-dollars.js +216 -26
  87. package/src/posture/scan-checkpoint.js +176 -31
  88. package/src/posture/state-dir.js +36 -1
  89. package/src/posture/state-lifecycle-report.js +77 -0
  90. package/src/posture/suppressions.js +59 -3
  91. package/src/privacy/ir-adapter.js +380 -0
  92. package/src/report/index.js +83 -18
  93. package/src/report/oscal.js +635 -0
  94. package/src/sast/cpp.js +3 -14
  95. package/src/sca/llm-function-extract.js +6 -0
@@ -16,7 +16,12 @@
16
16
  import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
18
 
19
- const STATE_DIR_NAME = '.agentic-security';
19
+ // Exported so a caller that needs the state dir NAME for display purposes
20
+ // (e.g. a human-readable "this file lives at X/Y" string, not an actual
21
+ // path construction) can go through the seam too, rather than hardcoding
22
+ // the literal — see test/no-stray-state.test.js's own detector, which
23
+ // flags a quoted '.agentic-security' literal anywhere else in the tree.
24
+ export const STATE_DIR_NAME = '.agentic-security';
20
25
 
21
26
  const PROJECT_MARKERS = [
22
27
  '.git',
@@ -143,6 +148,36 @@ export function stateWritesEnabled() {
143
148
  return _stateWritesEnabled;
144
149
  }
145
150
 
151
+ /**
152
+ * Run `fn` with state writes forced off, restoring the PRIOR flag value
153
+ * afterward — for a caller (assurance-hardening PRD FR-704) that must
154
+ * guarantee ITS scan does not mutate the tree, without having to remember
155
+ * to call setStateWritesEnabled(true) again itself. A caller that disables
156
+ * writes and forgets to re-enable them silently breaks every LATER write in
157
+ * the same process — exactly `apply_fix`'s failure mode this wrapper exists
158
+ * to prevent, via `finally` rather than caller discipline.
159
+ *
160
+ * KNOWN LIMITATION: `_stateWritesEnabled` is process-global, not per-call.
161
+ * Two overlapping calls to this function (or one overlapping a direct
162
+ * setStateWritesEnabled() call) can race and leave the flag in the wrong
163
+ * state for one of them once both finish. mcp/CLAUDE.md already documents
164
+ * an accepted concurrency limitation of the same shape for fix-history.js
165
+ * ("concurrent apply_fix calls can race... today benign... a future
166
+ * stateful tool needs serialization") — this is the same class of risk, not
167
+ * a new one, and this wrapper is still a strict improvement over the
168
+ * alternative it replaces (a caller that writes state UNCONDITIONALLY on
169
+ * every call, with no opt-out at all).
170
+ */
171
+ export async function withStateWritesDisabled(fn) {
172
+ const prior = _stateWritesEnabled;
173
+ _stateWritesEnabled = false;
174
+ try {
175
+ return await fn();
176
+ } finally {
177
+ _stateWritesEnabled = prior;
178
+ }
179
+ }
180
+
146
181
  // Safe mkdir: only creates .agentic-security/ if the parent has a project marker.
147
182
  // Returns the dir on success, null if refused. Logs a warning when refused.
148
183
  export function ensureStateDir(scanRoot) {
@@ -0,0 +1,77 @@
1
+ // FR-706 (assurance-hardening PRD): manifest-based export and deletion
2
+ // reports — "Operators can prove what was exported, deleted, retained, or
3
+ // failed."
4
+ //
5
+ // Two report kinds, one shared per-item shape ({name, status, ...}), each
6
+ // written by its one real caller in bin/agentic-security.js:
7
+ // - `cmdReset` builds a deletion report from the SAME target/preserved
8
+ // data it already computes for its console output (see FR-702/FR-703),
9
+ // reshaped into a durable, structured artifact instead of only ever
10
+ // printed and lost. Written on BOTH a dry run (items carry status
11
+ // 'planned') and a real run (status 'deleted'/'failed'), so an operator
12
+ // can prove either "what would happen" or "what did happen" depending
13
+ // on which they asked for — `dryRun` on the report says which.
14
+ // - `cmdExport` (new) builds an export report after copying every
15
+ // currently-present registered artifact to an operator-chosen
16
+ // destination, naming what was copied (with a hash) and what failed.
17
+ //
18
+ // Each report is a SINGLE OVERWRITTEN file per kind — the same "last
19
+ // action" precedent as last-scan.json, not an ever-growing log. An
20
+ // operator who needs a persistent audit trail across many runs should
21
+ // retain these externally (commit them, forward to their own SIEM); this
22
+ // module's job is only to make the LAST action's outcome durable and
23
+ // provable, not to be the audit log itself.
24
+
25
+ import { statePath, safeWriteState } from './state-dir.js';
26
+
27
+ export const DELETION_REPORT_FILE = 'deletion-report.json';
28
+ export const EXPORT_REPORT_FILE = 'export-report.json';
29
+
30
+ export function buildDeletionReport({ mode, dryRun, root, items, preserved }) {
31
+ const list = Array.isArray(items) ? items : [];
32
+ const preservedList = Array.isArray(preserved) ? preserved : [];
33
+ return {
34
+ schema: 'agentic-security/deletion-report@1',
35
+ generatedAt: new Date().toISOString(),
36
+ mode: mode || 'reset',
37
+ dryRun: !!dryRun,
38
+ root: root || null,
39
+ items: list,
40
+ preserved: preservedList,
41
+ summary: {
42
+ planned: list.filter(i => i.status === 'planned').length,
43
+ deleted: list.filter(i => i.status === 'deleted').length,
44
+ failed: list.filter(i => i.status === 'failed').length,
45
+ preserved: preservedList.length,
46
+ },
47
+ };
48
+ }
49
+
50
+ export function writeDeletionReport(scanRoot, report) {
51
+ try {
52
+ const fp = statePath(scanRoot, DELETION_REPORT_FILE);
53
+ return safeWriteState(fp, JSON.stringify(report, null, 2) + '\n') ? fp : null;
54
+ } catch { return null; }
55
+ }
56
+
57
+ export function buildExportReport({ root, outDir, items }) {
58
+ const list = Array.isArray(items) ? items : [];
59
+ return {
60
+ schema: 'agentic-security/export-report@1',
61
+ generatedAt: new Date().toISOString(),
62
+ root: root || null,
63
+ outDir: outDir || null,
64
+ items: list,
65
+ summary: {
66
+ exported: list.filter(i => i.status === 'exported').length,
67
+ failed: list.filter(i => i.status === 'failed').length,
68
+ },
69
+ };
70
+ }
71
+
72
+ export function writeExportReport(scanRoot, report) {
73
+ try {
74
+ const fp = statePath(scanRoot, EXPORT_REPORT_FILE);
75
+ return safeWriteState(fp, JSON.stringify(report, null, 2) + '\n') ? fp : null;
76
+ } catch { return null; }
77
+ }
@@ -4,11 +4,41 @@
4
4
  // reviewer + expiry + rule-version pin)
5
5
  // One function `applySuppressions(findings, scanRoot, profile)` filters in
6
6
  // place. Loaders accept malformed input gracefully (skip bad entries, log).
7
+ //
8
+ // FR-1004 (assurance-hardening PRD): "exception owner, reason, scope,
9
+ // compensating control, and expiry." The pro schema already had reason
10
+ // (`reason`) and expiry (`expires_at`); `owner`, `scope`, and
11
+ // `compensating_control` are added here as required fields on the SAME
12
+ // entry, validated by the SAME `validateProSuppression` gate that already
13
+ // enforces the two-person rule. This is a deliberate tightening: an
14
+ // existing suppressions.yml entry written before this field set was
15
+ // required will now fail validation and REOPEN its finding, same as any
16
+ // other invalid pro suppression already does — an operator must add the
17
+ // three new fields to keep the exception honored. `owner` is who is
18
+ // accountable for eventually resolving or renewing the exception (may or
19
+ // may not be the same person as `reviewer`); `scope` is a short,
20
+ // human-readable statement of what the exception covers (this module's
21
+ // matching remains per-finding — `scope` documents intent, it does not
22
+ // widen what gets suppressed); `compensating_control` is the specific
23
+ // alternative mitigation that justifies leaving the underlying finding
24
+ // unfixed (a required field, not inferred from `reason` — "we'll fix it
25
+ // later" is a reason, not a compensating control, and conflating the two
26
+ // was the actual gap this PRD item exists to close).
27
+ //
28
+ // "Expired exceptions automatically reopen findings or fail the gate":
29
+ // `applySuppressions` already returns an expired suppression's finding to
30
+ // the KEPT (open) set rather than the suppressed set (see the `exp < now`
31
+ // branch below) — an expired exception was always self-reopening by
32
+ // construction, this was just never covered by a test until FR-1004.
33
+ // Reopening feeds the existing severity-based CI gate (`--fail-on`)
34
+ // exactly like any other open finding; no second, parallel expiry-gate
35
+ // mechanism is needed or built here.
7
36
 
8
37
  import * as fs from 'node:fs';
9
38
  import * as path from 'node:path';
10
39
  import * as yaml from '../util/yaml.js';
11
40
  import { statePath, safeWriteState } from './state-dir.js';
41
+ import { loadApproverRegistry, verifyApprover, requiredRolesFor } from '../fix/approver-registry.js';
12
42
 
13
43
  const MS_PER_DAY = 86400000;
14
44
  const SOFT_TTL_DAYS = 30;
@@ -59,14 +89,31 @@ export function loadProSuppressions(scanRoot) {
59
89
  }
60
90
 
61
91
  // Validate one entry. Returns { ok, errors }.
62
- export function validateProSuppression(entry) {
92
+ //
93
+ // FR-1002: "bind approvals, EXCEPTIONS, AND SUPPRESSIONS to verified
94
+ // identities and roles... anonymous or unauthorized high-risk exceptions
95
+ // fail policy." A pro suppression's `justification_signed_by` is exactly
96
+ // such an exception-approval identity, but until this fix it was pure
97
+ // self-reported free text -- FR-307/FR-1002's registry existed and was
98
+ // wired into apply_fix, but never consulted here. `opts.registry` is
99
+ // optional and NO-OP when absent (loadApproverRegistry(scanRoot) returns
100
+ // null until an operator opts in, matching every other gate in this
101
+ // registry), so an existing project with no registry file sees zero
102
+ // behavior change. `opts.requiredRoles` lets the caller scope role
103
+ // requirements to the SUPPRESSED FINDING's own family/category, the same
104
+ // way FR-307 scopes them to a change's material-change category.
105
+ export function validateProSuppression(entry, opts = {}) {
63
106
  const errors = [];
64
- for (const k of ['finding_id', 'file', 'reason', 'justification_signed_by', 'reviewer', 'expires_at']) {
107
+ for (const k of ['finding_id', 'file', 'reason', 'justification_signed_by', 'reviewer', 'expires_at', 'owner', 'scope', 'compensating_control']) {
65
108
  if (!entry[k] || (typeof entry[k] === 'string' && !entry[k].trim())) errors.push(`missing: ${k}`);
66
109
  }
67
110
  if (entry.justification_signed_by && entry.reviewer && entry.justification_signed_by === entry.reviewer) {
68
111
  errors.push('justification_signed_by must differ from reviewer (two-person rule)');
69
112
  }
113
+ if (entry.justification_signed_by && opts.registry !== undefined) {
114
+ const v = verifyApprover(opts.registry, entry.justification_signed_by, opts.requiredRoles || []);
115
+ if (!v.verified) errors.push(`justification_signed_by not authorized: ${v.reason}`);
116
+ }
70
117
  if (entry.expires_at) {
71
118
  const t = _dateOnly(entry.expires_at);
72
119
  if (!Number.isFinite(t)) errors.push('expires_at must be ISO date');
@@ -84,6 +131,11 @@ export function applySuppressions(findings, scanRoot, profile) {
84
131
  const items = isPro ? loadProSuppressions(scanRoot) : loadSoftAccepted(scanRoot);
85
132
  if (!items.length) return findings;
86
133
 
134
+ // FR-1002: loaded once per call, not per entry — a missing registry file
135
+ // (the common, not-opted-in case) makes verifyApprover() a no-op below,
136
+ // exactly like every other gate built on this registry.
137
+ const approverRegistry = isPro ? loadApproverRegistry(scanRoot) : null;
138
+
87
139
  const now = _now();
88
140
  const kept = [];
89
141
  const suppressed = [];
@@ -106,7 +158,11 @@ export function applySuppressions(findings, scanRoot, profile) {
106
158
  }
107
159
  // Pro: validate the entry still passes
108
160
  if (isPro) {
109
- const v = validateProSuppression({ ...matched, severity: f.severity });
161
+ // Same registry/category shape FR-307 already uses for high-impact
162
+ // fixes: an operator opts a finding family into role-gating via
163
+ // requiredRolesByCategory, keyed by that family's name.
164
+ const requiredRoles = requiredRolesFor(approverRegistry, [f.family].filter(Boolean));
165
+ const v = validateProSuppression({ ...matched, severity: f.severity }, { registry: approverRegistry, requiredRoles });
110
166
  if (!v.ok) { kept.push({ ...f, _suppressionInvalid: v.errors }); continue; }
111
167
  }
112
168
  suppressed.push({ ...f, _suppressed: matched });
@@ -0,0 +1,380 @@
1
+ // Privacy IR adapter (assurance-hardening PRD, Milestone 2, FR-401).
2
+ //
3
+ // dataflow/privacy-taint.js's annotatePrivacyTaint() expects a per-file map
4
+ // of `{ _content, decls: [{name, line, type}], calls: [{line, fullPath|callee,
5
+ // args: [{text}]}] }` — a FLAT, per-file shape. The real Layer-1 IR
6
+ // (ir/index.js#buildProjectIR) does not produce that shape at all: it is
7
+ // per-FUNCTION (`functions: [{params, cfg: {nodes: {...}}}]`), and CFG node
8
+ // arguments are structured expression trees (`{kind:'ident', name}`,
9
+ // `{kind:'member', object, prop}`, ...), not `{text: string}` objects. This
10
+ // was the actual reason privacy analysis was wired with hardcoded
11
+ // `decls:[], calls:[]` (A-06) — there was no adapter reconciling the two
12
+ // shapes, not a missing capability in the underlying IR/taint engine (see
13
+ // docs/implementation/assurance-hardening-decisions.md D-0003's pattern:
14
+ // verify before assuming something is unbuilt).
15
+ //
16
+ // This module is that adapter. It does NOT reimplement privacy analysis —
17
+ // it only reshapes the real IR into exactly what annotatePrivacyTaint()
18
+ // already knows how to consume.
19
+ //
20
+ // Declarations: sourced from every function's `params` (a parameter is a
21
+ // declaration) and every `assign`-kind CFG node's `target` (an assignment
22
+ // declares/rebinds a name). Both are what a real codebase would call PII
23
+ // "at the point it enters a function or gets bound to a local".
24
+ //
25
+ // Calls: sourced from every `call`-kind CFG node, AND from `assign`-kind
26
+ // nodes whose `source` is itself a call expression (`const x = fetch(...)`)
27
+ // — privacy-taint.js only walks a flat `ir.calls` list, so a call used as
28
+ // an assignment's right-hand side needs to be surfaced there too or it's
29
+ // invisible to the sink-matching pass entirely.
30
+ //
31
+ // Argument "text": privacy-taint.js's sink-matching does a literal
32
+ // \b<name>\b regex test against the joined text of a call's argument
33
+ // expressions — it does not need faithful re-rendered source code, only
34
+ // text that CONTAINS every identifier name referenced (directly or nested)
35
+ // in that argument, so a tainted variable's name is found wherever it
36
+ // appears. `_identifiersIn` recursively collects identifier names (and
37
+ // literal values, for completeness) rather than attempting full expression
38
+ // pretty-printing, which is simpler and sufficient for this consumer's
39
+ // actual matching logic.
40
+ //
41
+ // Storage: this codebase already has a separate stored-taint subsystem
42
+ // (engine.js's STORED_TAINT_FIELD_PATTERNS / buildStoredTaintRegistry) that
43
+ // tracks which ORM/model fields are written with unsanitized request input,
44
+ // for its own cross-file write-then-render correlation (crossStoredTaint).
45
+ // `_storageForFile` reindexes that ALREADY-COMPUTED, whole-project registry
46
+ // down to one file's writes rather than recomputing anything — the engine
47
+ // builds it once per scan regardless of whether privacy analysis runs at all.
48
+ //
49
+ // Types: the underlying Layer-1 IR (ir/index.js#buildProjectIR) tracks no
50
+ // type-annotation/inference data for any language — genuine cross-language
51
+ // type inference would mean extending every language parser, a separate,
52
+ // substantially larger project deliberately out of scope here. What IS in
53
+ // scope, and implemented below, is a narrow, honest, TypeScript-only sliver:
54
+ // `_extractTsTypes` does a SEPARATE, read-only `@babel/core` `parseSync` pass
55
+ // over `.ts`/`.tsx` source (never touching ir/parser-js.js's own transform
56
+ // pipeline or its shared, heavily-relied-on visitor plugin) and walks the
57
+ // resulting AST for `TSTypeAnnotation` nodes on function parameters and
58
+ // `let`/`const`/`var` declarators. Babel's `parseSync` only PARSES — it never
59
+ // runs `@babel/preset-typescript`'s stripping transform, so a `.ts` file's
60
+ // type annotations are fully intact on the AST it returns, no plugin-ordering
61
+ // trick required. Matching this SEPARATE parse's declarations back to the
62
+ // real IR's decls is done by `(line, name)` — the same real source line and
63
+ // identifier name a given parameter or declarator sits on in both parses,
64
+ // since both parse the identical source text. A `.js` file, a type Babel
65
+ // cannot express as a plain name (destructured/computed types, deeply
66
+ // generic types past a small recursion depth), or any parse/traverse failure
67
+ // all degrade to `type: null` — never a guess, matching every other
68
+ // optional field in this module.
69
+
70
+ import { parseSync as babelParseSync, traverse as babelTraverse } from '@babel/core';
71
+ import presetTypescript from '@babel/preset-typescript';
72
+
73
+ const MAX_EXPR_DEPTH = 40;
74
+ const MAX_TS_TYPE_DEPTH = 6;
75
+
76
+ function _calleeToString(callee, depth = 0) {
77
+ if (typeof callee === 'string') return callee;
78
+ if (!callee || typeof callee !== 'object' || depth > MAX_EXPR_DEPTH) return '';
79
+ switch (callee.kind) {
80
+ case 'ident': return callee.name || '';
81
+ case 'member': {
82
+ const obj = _calleeToString(callee.object, depth + 1);
83
+ return obj ? `${obj}.${callee.prop || ''}` : String(callee.prop || '');
84
+ }
85
+ case 'call': return _calleeToString(callee.callee, depth + 1);
86
+ default: return '';
87
+ }
88
+ }
89
+
90
+ function _identifiersIn(expr, out, depth = 0) {
91
+ if (!expr || typeof expr !== 'object' || depth > MAX_EXPR_DEPTH) return;
92
+ switch (expr.kind) {
93
+ case 'ident':
94
+ if (expr.name) out.push(expr.name);
95
+ return;
96
+ case 'literal':
97
+ if (expr.value != null) out.push(String(expr.value));
98
+ return;
99
+ case 'member':
100
+ _identifiersIn(expr.object, out, depth + 1);
101
+ if (expr.prop) out.push(String(expr.prop));
102
+ return;
103
+ case 'binary':
104
+ case 'logical':
105
+ _identifiersIn(expr.left, out, depth + 1);
106
+ _identifiersIn(expr.right, out, depth + 1);
107
+ return;
108
+ case 'tpl':
109
+ for (const p of expr.parts || []) _identifiersIn(p, out, depth + 1);
110
+ return;
111
+ case 'call':
112
+ _identifiersIn(expr.callee, out, depth + 1);
113
+ for (const a of expr.args || []) _identifiersIn(a, out, depth + 1);
114
+ return;
115
+ case 'array':
116
+ for (const e of expr.elements || []) _identifiersIn(e, out, depth + 1);
117
+ return;
118
+ case 'object':
119
+ for (const p of expr.props || []) _identifiersIn(p.value, out, depth + 1);
120
+ return;
121
+ case 'union':
122
+ for (const b of expr.branches || []) _identifiersIn(b, out, depth + 1);
123
+ return;
124
+ default:
125
+ return;
126
+ }
127
+ }
128
+
129
+ function _argsToTextArgs(args) {
130
+ return (args || []).map(a => {
131
+ const names = [];
132
+ _identifiersIn(a, names);
133
+ return { text: names.join(' ') };
134
+ });
135
+ }
136
+
137
+ // A TSQualifiedName is `A.B.C` (e.g. `Foo.Bar`) — dot-join it the same way
138
+ // _calleeToString dot-joins a member expression above.
139
+ function _qualifiedTypeName(node, depth = 0) {
140
+ if (!node || depth > MAX_TS_TYPE_DEPTH) return null;
141
+ if (node.type === 'Identifier') return node.name || null;
142
+ if (node.type === 'TSQualifiedName') {
143
+ const left = _qualifiedTypeName(node.left, depth + 1);
144
+ const right = node.right?.name || null;
145
+ if (!left || !right) return left || right;
146
+ return `${left}.${right}`;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Renders a TSType AST node to a short, human-readable string — never a
153
+ * best-effort re-render of the whole type, just enough to be a real,
154
+ * non-fabricated signal. Anything not recognized (mapped types, conditional
155
+ * types, indexed access, deeply nested generics past the depth guard) is
156
+ * `null`, exactly like every other "don't know" in this module — no type
157
+ * string is ever invented for a shape this doesn't genuinely understand.
158
+ */
159
+ function _tsTypeToString(node, depth = 0) {
160
+ if (!node || depth > MAX_TS_TYPE_DEPTH) return null;
161
+ switch (node.type) {
162
+ case 'TSStringKeyword': return 'string';
163
+ case 'TSNumberKeyword': return 'number';
164
+ case 'TSBooleanKeyword': return 'boolean';
165
+ case 'TSAnyKeyword': return 'any';
166
+ case 'TSUnknownKeyword': return 'unknown';
167
+ case 'TSVoidKeyword': return 'void';
168
+ case 'TSNullKeyword': return 'null';
169
+ case 'TSUndefinedKeyword': return 'undefined';
170
+ case 'TSNeverKeyword': return 'never';
171
+ case 'TSObjectKeyword': return 'object';
172
+ case 'TSBigIntKeyword': return 'bigint';
173
+ case 'TSSymbolKeyword': return 'symbol';
174
+ case 'TSTypeReference': {
175
+ const name = node.typeName?.type === 'Identifier'
176
+ ? node.typeName.name
177
+ : _qualifiedTypeName(node.typeName, depth + 1);
178
+ if (!name) return null;
179
+ // Babel's current AST names this `typeArguments`; `typeParameters` was
180
+ // the property on older @babel/parser versions for the same node —
181
+ // checking both means this doesn't silently go generic-blind on a
182
+ // dependency bump either direction.
183
+ const paramNodes = (node.typeArguments || node.typeParameters)?.params || [];
184
+ const args = paramNodes.map(p => _tsTypeToString(p, depth + 1)).filter(Boolean);
185
+ return (args.length && args.length === paramNodes.length) ? `${name}<${args.join(', ')}>` : name;
186
+ }
187
+ case 'TSArrayType': {
188
+ const el = _tsTypeToString(node.elementType, depth + 1);
189
+ return el ? `${el}[]` : null;
190
+ }
191
+ case 'TSUnionType': {
192
+ const parts = (node.types || []).map(t => _tsTypeToString(t, depth + 1)).filter(Boolean);
193
+ return parts.length === (node.types || []).length ? parts.join(' | ') : null;
194
+ }
195
+ case 'TSLiteralType': {
196
+ const lit = node.literal;
197
+ if (lit?.type === 'StringLiteral') return JSON.stringify(lit.value);
198
+ if (lit?.type === 'NumericLiteral') return String(lit.value);
199
+ if (lit?.type === 'BooleanLiteral') return String(lit.value);
200
+ return null;
201
+ }
202
+ default: return null;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * FR-401's "types" element, TypeScript-only. A separate, read-only parse of
208
+ * the real source — never the transformed AST ir/parser-js.js builds its IR
209
+ * from, and never a mutation of anything that module owns. `parseSync` only
210
+ * parses; `@babel/preset-typescript`'s stripping transform never runs, so
211
+ * `.ts`/`.tsx` type annotations are fully intact on the returned AST.
212
+ *
213
+ * Keyed by the real source line a parameter or declarator's IDENTIFIER sits
214
+ * on (matching how ir/parser-js.js's own IR already records a parameter's
215
+ * line as its enclosing function's start line, and an assignment's line as
216
+ * its own CFG node's line) so `_adaptFile` can look a type up by the exact
217
+ * (line, name) pair it already has, without needing any cross-parse node
218
+ * identity.
219
+ *
220
+ * @returns {Map<number, Map<string,string>>} line -> (name -> type string)
221
+ */
222
+ function _extractTsTypes(file, content) {
223
+ const out = new Map();
224
+ if (!/\.tsx?$/i.test(file) || typeof content !== 'string' || content.length > 500_000) return out;
225
+ const record = (name, line, typeAnnotationNode) => {
226
+ if (!name || !line || !typeAnnotationNode) return;
227
+ const t = _tsTypeToString(typeAnnotationNode.typeAnnotation);
228
+ if (!t) return;
229
+ if (!out.has(line)) out.set(line, new Map());
230
+ out.get(line).set(name, t);
231
+ };
232
+ let ast;
233
+ try {
234
+ ast = babelParseSync(content, {
235
+ filename: file,
236
+ presets: [[presetTypescript, { ignoreExtensions: true }]],
237
+ // Mirrors ir/parser-js.js's own parserOpts exactly — accepting the same
238
+ // decorator syntax it accepts, so this separate parse does not reject
239
+ // (and silently lose types from) a file the real IR parser accepts.
240
+ parserOpts: { plugins: ['decorators-legacy', 'decoratorAutoAccessors'] },
241
+ babelrc: false, configFile: false, ast: true, code: false,
242
+ });
243
+ } catch { return out; }
244
+ if (!ast) return out;
245
+ try {
246
+ babelTraverse(ast, {
247
+ Function(path) {
248
+ for (const p of path.node.params || []) {
249
+ const resolved = p.type === 'AssignmentPattern' ? p.left : p;
250
+ if (resolved?.type === 'Identifier' && resolved.typeAnnotation) {
251
+ record(resolved.name, path.node.loc?.start?.line, resolved.typeAnnotation);
252
+ }
253
+ }
254
+ },
255
+ VariableDeclarator(path) {
256
+ const id = path.node.id;
257
+ if (id?.type === 'Identifier' && id.typeAnnotation) {
258
+ record(id.name, path.node.loc?.start?.line, id.typeAnnotation);
259
+ }
260
+ },
261
+ });
262
+ } catch { /* degrade to whatever was recorded before the failure */ }
263
+ return out;
264
+ }
265
+
266
+ /**
267
+ * FR-401's "storage" element. Reindexes the whole-project, field-name-keyed
268
+ * stored-taint registry (engine.js#buildStoredTaintRegistry — already
269
+ * computed once per scan for crossStoredTaint's own cross-file correlation,
270
+ * not recomputed here) down to just the writes that happened in THIS file.
271
+ * `storedRegistry` is `{ [fieldName]: [{file, line, snippet, named}, ...] }`;
272
+ * this returns the flattened, per-file subset as
273
+ * `[{field, line, snippet, named}, ...]`.
274
+ */
275
+ function _storageForFile(file, storedRegistry) {
276
+ const out = [];
277
+ if (!storedRegistry || typeof storedRegistry !== 'object') return out;
278
+ for (const [field, writes] of Object.entries(storedRegistry)) {
279
+ for (const w of writes || []) {
280
+ if (w && w.file === file) out.push({ field, line: w.line ?? 0, snippet: w.snippet || '', named: !!w.named });
281
+ }
282
+ }
283
+ return out;
284
+ }
285
+
286
+ /**
287
+ * @param {object} fileIR - one entry from ir/index.js#buildProjectIR's `perFile` map: {file, functions, topLevel}
288
+ * @param {Map<number, Map<string,string>>|null} [tsTypes] - `_extractTsTypes`'s
289
+ * output for this same file. Optional: omitted (or no match at a given
290
+ * (line, name)) leaves `type: null`, exactly as before this existed.
291
+ * @returns {{decls: Array, calls: Array}}
292
+ */
293
+ function _adaptFile(fileIR, tsTypes) {
294
+ const decls = [];
295
+ const calls = [];
296
+ const returns = [];
297
+ const typeAt = (line, name) => tsTypes?.get(line)?.get(name) ?? null;
298
+ for (const fn of fileIR?.functions || []) {
299
+ for (const paramName of fn.params || []) {
300
+ if (typeof paramName === 'string' && paramName) {
301
+ // `kind` genuinely distinguishes a parameter from a later
302
+ // assignment (FR-401's "parameters" vs "assignments" elements) —
303
+ // annotatePrivacyTaint() ignores unknown fields, so this is purely
304
+ // additive for that consumer.
305
+ decls.push({ name: paramName, line: fn.line ?? 0, type: typeAt(fn.line ?? 0, paramName), kind: 'parameter' });
306
+ }
307
+ }
308
+ const nodes = fn.cfg?.nodes || {};
309
+ for (const node of Object.values(nodes)) {
310
+ if (!node) continue;
311
+ if (node.kind === 'assign' && node.target) {
312
+ const line = node.line ?? fn.line ?? 0;
313
+ decls.push({ name: node.target, line, type: typeAt(line, node.target), kind: 'assignment' });
314
+ }
315
+ if (node.kind === 'call') {
316
+ calls.push({
317
+ line: node.line ?? fn.line ?? 0,
318
+ fullPath: _calleeToString(node.callee),
319
+ args: _argsToTextArgs(node.args),
320
+ });
321
+ } else if (node.kind === 'assign' && node.source && node.source.kind === 'call') {
322
+ calls.push({
323
+ line: node.line ?? fn.line ?? 0,
324
+ fullPath: _calleeToString(node.source.callee),
325
+ args: _argsToTextArgs(node.source.args),
326
+ });
327
+ } else if (node.kind === 'return' && node.value) {
328
+ // FR-401's "returns" element: every CFG language parser genuinely
329
+ // emits return-kind nodes (confirmed across parser-js/py/java/go/
330
+ // rb/php/cs/kt/cpp), so this is real IR data, not a stub — surfaced
331
+ // here even though annotatePrivacyTaint() (the shallow walker) does
332
+ // not consume it today, matching the adapter's stated job of
333
+ // supplying what the real IR has, not just what today's one
334
+ // consumer happens to read (see D-0052's evidence for the honest
335
+ // caveat: no current caller reads this field yet).
336
+ const names = [];
337
+ _identifiersIn(node.value, names);
338
+ returns.push({ line: node.line ?? fn.line ?? 0, names });
339
+ }
340
+ }
341
+ }
342
+ return { decls, calls, returns };
343
+ }
344
+
345
+ /**
346
+ * Build the flat, per-file map annotatePrivacyTaint() expects, from the real
347
+ * Layer-1 IR plus the raw file contents (for the `_content` field it uses
348
+ * for snippet extraction — the IR itself carries no raw source text).
349
+ *
350
+ * @param {Record<string,object>} perFileIR - ir/index.js#buildProjectIR's `perFile` (or buildProjectIRAsync's)
351
+ * @param {Record<string,string>} fileContents
352
+ * @param {object|null} [storedRegistry] - engine.js#buildStoredTaintRegistry's
353
+ * output, for FR-401's "storage" element. Optional and additive: omitted
354
+ * (or not an object) yields `storage: []` for every file, same
355
+ * degrade-gracefully convention as every other field here — this is not a
356
+ * second copy of the registry's own computation, just a per-file view of
357
+ * the one the engine already builds once per scan.
358
+ * @returns {Map<string, {_content:string, decls:Array, calls:Array, returns:Array, storage:Array}>}
359
+ */
360
+ export function adaptIRForPrivacyTaint(perFileIR, fileContents, storedRegistry) {
361
+ const out = new Map();
362
+ const fc = fileContents || {};
363
+ for (const [file, content] of Object.entries(fc)) {
364
+ if (typeof content !== 'string') continue;
365
+ const fileIR = perFileIR ? perFileIR[file] : null;
366
+ // FR-401's "types" element: a real, TypeScript-only signal via a
367
+ // separate parse (see _extractTsTypes's own header) — a no-op Map for
368
+ // every non-.ts/.tsx file, so this costs nothing on the common path.
369
+ const tsTypes = _extractTsTypes(file, content);
370
+ const { decls, calls, returns } = fileIR ? _adaptFile(fileIR, tsTypes) : { decls: [], calls: [], returns: [] };
371
+ const storage = _storageForFile(file, storedRegistry);
372
+ out.set(file, { _content: content, decls, calls, returns, storage });
373
+ }
374
+ return out;
375
+ }
376
+
377
+ export const _internals = {
378
+ _calleeToString, _identifiersIn, _argsToTextArgs, _adaptFile, _storageForFile,
379
+ _tsTypeToString, _qualifiedTypeName, _extractTsTypes,
380
+ };