@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
@@ -0,0 +1,26 @@
1
+ // Worker-thread entry point for analyzer-supervisor.js (assurance-hardening
2
+ // PRD FR-202, D-0046 phase 1).
3
+ //
4
+ // Generic by design: workerData names a module and an export to call with a
5
+ // list of plain arguments, so this file has no knowledge of what analyzer it
6
+ // is running. The supervisor is the only caller; this file is not meant to
7
+ // be imported directly.
8
+
9
+ import { parentPort, workerData } from 'node:worker_threads';
10
+
11
+ async function run() {
12
+ try {
13
+ const { modulePath, exportName, args } = workerData;
14
+ const mod = await import(modulePath);
15
+ const fn = mod[exportName];
16
+ if (typeof fn !== 'function') {
17
+ throw new Error(`export "${exportName}" is not a function in ${modulePath}`);
18
+ }
19
+ const result = await fn(...(args || []));
20
+ parentPort.postMessage({ ok: true, result });
21
+ } catch (e) {
22
+ parentPort.postMessage({ ok: false, error: String((e && e.message) || e) });
23
+ }
24
+ }
25
+
26
+ run();
@@ -0,0 +1,33 @@
1
+ // Annotator runner (assurance-hardening PRD, Milestone 0/1, FR-106).
2
+ //
3
+ // Extracted from engine.js's local `_runAnnotator` closure so the Promise-
4
+ // aware error-capture contract can be fault-injection tested directly,
5
+ // without needing a real annotator to actually throw (this codebase's
6
+ // annotators are deliberately built never to — see posture/CLAUDE.md's "no
7
+ // throwing" convention — which is good for production but means there was
8
+ // no natural way to prove the capture path works end-to-end before this
9
+ // module existed). engine.js's `_runAnnotator` is now a thin wrapper that
10
+ // closes over its local `_annotatorErrors` array and delegates here; this
11
+ // module has no engine.js-specific state, so it takes the errors array as a
12
+ // parameter instead of a closure.
13
+ //
14
+ // Contract: a rejection or thrown error from `fn()` — sync or async — is
15
+ // captured as a structured entry, never left to become an unhandled
16
+ // rejection or to race ahead of the caller. The caller MUST `await` this
17
+ // function for the second half of that contract (capture-before-return) to
18
+ // hold; see engine.js's call sites, all of which now do.
19
+
20
+ /**
21
+ * @param {Array<{phase:string, err:string}>} annotatorErrors - mutated in place (push only)
22
+ * @param {string} phase - name recorded on a captured error
23
+ * @param {() => any} fn - sync or async callback
24
+ * @returns {Promise<any>} fn()'s resolved value, or undefined if it threw/rejected
25
+ */
26
+ export async function runAnnotatorAsync(annotatorErrors, phase, fn) {
27
+ try {
28
+ return await fn();
29
+ } catch (e) {
30
+ annotatorErrors.push({ phase, err: String((e && e.message) || e) });
31
+ return undefined;
32
+ }
33
+ }
@@ -0,0 +1,91 @@
1
+ // Assurance modes (assurance-hardening PRD FR-204).
2
+ //
3
+ // "Add assurance modes: advisory, standard, and strict | Strict mode fails
4
+ // when a required analyzer fails, times out, is unavailable, or is
5
+ // silently skipped." Section 12.1 names the CLI surface:
6
+ // `--assurance advisory|standard|strict`.
7
+ //
8
+ // Built directly on FR-203's coverage ledger (pipeline/coverage-ledger.js)
9
+ // and FR-206's scan-health.js -- this module adds no new signal collection
10
+ // of its own, only a POLICY over signals that already exist. That is
11
+ // deliberate: assurance modes decide how strict to be about incomplete
12
+ // analysis; they must never be the thing that DEFINES what "incomplete"
13
+ // means, or the two concepts would drift out of sync.
14
+ //
15
+ // THREE MODES:
16
+ // advisory — never fails the gate over scan health, regardless of what
17
+ // scanHealth reports. Purely informational (surfaced in scanHealth /
18
+ // toShipVerdict / `ci`'s stderr, per FR-206's fix -- this mode does not
19
+ // suppress that, it just does not ADD a build-failing consequence).
20
+ // standard (the DEFAULT, matching this codebase's behavior before and
21
+ // after FR-204) — same as advisory for gate purposes: an incomplete
22
+ // scan is surfaced, never silently hidden, but does not itself fail a
23
+ // build independent of the ordinary --fail-on severity threshold. The
24
+ // distinction from advisory is one of INTENT/reporting emphasis, not
25
+ // mechanism -- see the module-level note below on why this codebase
26
+ // does not invent a mechanical difference the PRD text does not ask
27
+ // for.
28
+ // strict — an incomplete scan (any analyzer failed, timed out, or was
29
+ // skipped by policy -- the ledger's own three non-"completed" outcomes)
30
+ // is a HARD FAILURE, independent of and IN ADDITION TO --fail-on,
31
+ // mirroring cmdCi's own existing precedent for its --policy gate
32
+ // ("Policy runs ALONGSIDE the --fail-on threshold; either gate can
33
+ // fail the build").
34
+ //
35
+ // WHY ADVISORY AND STANDARD ARE MECHANICALLY IDENTICAL HERE: the PRD's own
36
+ // one-line acceptance criterion only specifies STRICT mode's behavior in
37
+ // full ("fails when..."); it does not name a distinct mechanical
38
+ // consequence for standard beyond "the default, not strict." Inventing an
39
+ // intermediate failure condition neither named in the PRD nor requested
40
+ // would be exactly the kind of unrequested scope-widening this codebase's
41
+ // own conventions warn against. If a future requirement needs standard
42
+ // mode to behave differently from advisory, that is a deliberate, separate
43
+ // decision -- not something to guess at here.
44
+
45
+ export const ASSURANCE_MODES = Object.freeze(['advisory', 'standard', 'strict']);
46
+ export const DEFAULT_ASSURANCE_MODE = 'standard';
47
+
48
+ function _isValidMode(mode) {
49
+ return ASSURANCE_MODES.includes(mode);
50
+ }
51
+
52
+ /**
53
+ * @param {string} mode - one of ASSURANCE_MODES; invalid/missing degrades to the default.
54
+ * @param {object|null} scanHealth - the engine's computed scan.scanHealth (FR-206).
55
+ * @returns {{ok: boolean, mode: string, reason: string|null, conditions: string[]}}
56
+ * ok:false only ever happens in strict mode; advisory/standard always ok:true
57
+ * (they report, they do not gate).
58
+ */
59
+ export function evaluateAssuranceMode(mode, scanHealth) {
60
+ const effectiveMode = _isValidMode(mode) ? mode : DEFAULT_ASSURANCE_MODE;
61
+ const conditions = Array.isArray(scanHealth?.conditions) ? scanHealth.conditions : [];
62
+
63
+ if (effectiveMode !== 'strict') {
64
+ return { ok: true, mode: effectiveMode, reason: null, conditions };
65
+ }
66
+
67
+ // Strict: any of the ledger's three non-"completed" analyzer outcomes,
68
+ // OR an annotator error / deep-mode failure (scanHealth's OTHER,
69
+ // non-analyzer conditions) -- "a required analyzer... is unavailable, or
70
+ // is silently skipped" covers more than just coverage-ledger.js's own
71
+ // per-detector accounting; a scan whose ANY known-good signal degraded
72
+ // is not "complete" under strict's own definition.
73
+ const status = scanHealth?.status;
74
+ if (!scanHealth || status !== 'complete') {
75
+ const a = scanHealth?.analyzers;
76
+ const parts = [];
77
+ if (a?.failed > 0) parts.push(`${a.failed} analyzer(s) failed`);
78
+ if (a?.timedOut > 0) parts.push(`${a.timedOut} analyzer(s) timed out`);
79
+ if (a?.skippedByPolicy > 0) parts.push(`${a.skippedByPolicy} analyzer(s) silently skipped by policy`);
80
+ const analyzerSummary = parts.length ? ` (${parts.join(', ')})` : '';
81
+ return {
82
+ ok: false,
83
+ mode: 'strict',
84
+ reason: `strict mode requires a fully complete scan; scanHealth.status is '${status ?? 'unknown'}'${analyzerSummary}`,
85
+ conditions,
86
+ };
87
+ }
88
+ return { ok: true, mode: 'strict', reason: null, conditions };
89
+ }
90
+
91
+ export const _internals = { _isValidMode };
@@ -0,0 +1,172 @@
1
+ // FR-202 phase 3b (D-0050): a pool of long-lived workers, each running engine.js's
2
+ // _runFileCascade for many files over its lifetime, instead of the one-shot-per-call
3
+ // model runWithDeadline/analyzer-supervisor.js uses (which is correct for FR-202's
4
+ // primitive-level proof but would force whole-project setup — _buildProjectIndex,
5
+ // _buildGlobalJavaTaintedMethodIndex, _loadCustomRules — to redo per FILE, an O(n^2)
6
+ // cost across a scan). Each worker performs that setup ONCE at creation via
7
+ // _initCascadeWorkerState, then answers task messages over its lifetime.
8
+ //
9
+ // A task that exceeds its deadline+grace gets its OWN worker terminated (genuinely
10
+ // preemptive, mirroring analyzer-supervisor.js's worker.terminate() precedent) and a
11
+ // fresh replacement worker is spawned to keep the pool at full size for later files —
12
+ // one hung file costs one worker restart, not the whole scan.
13
+ import { Worker } from 'node:worker_threads';
14
+ import * as path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ // `new Worker()` needs a REAL file on disk -- once ncc bundles this module's code
18
+ // into a dist/ chunk, `import.meta.url` resolves to that chunk's location (dist/),
19
+ // not to src/pipeline/, so a naive path.join(HERE, 'cascade-worker.js') would look
20
+ // for a file that was never copied there. src/ always ships as a sibling of dist/
21
+ // (see package.json's "files"), in both a dev checkout and the published npm
22
+ // package, so fall back to the real, unbundled worker script in that case.
23
+ function _resolveDefaultWorkerScript() {
24
+ const hereDir = path.dirname(fileURLToPath(import.meta.url));
25
+ if (path.basename(hereDir) === 'pipeline') return path.join(hereDir, 'cascade-worker.js');
26
+ return path.join(hereDir, '..', 'src', 'pipeline', 'cascade-worker.js');
27
+ }
28
+ const DEFAULT_WORKER_SCRIPT = _resolveDefaultWorkerScript();
29
+ export const DEFAULT_GRACE_MS = 500;
30
+
31
+ function spawnReadyWorker({ workerScript, modulePath, fileContents, scanRoot }) {
32
+ return new Promise((resolve, reject) => {
33
+ let settled = false;
34
+ const worker = new Worker(workerScript, { workerData: { modulePath, fileContents, scanRoot } });
35
+ const finish = (fn, value) => {
36
+ if (settled) return;
37
+ settled = true;
38
+ worker.off('message', onMessage);
39
+ worker.off('error', onError);
40
+ fn(value);
41
+ };
42
+ const onMessage = (msg) => {
43
+ if (!msg) return;
44
+ if (msg.type === 'ready') finish(resolve, worker);
45
+ else if (msg.type === 'init-error') { worker.terminate().catch(() => {}); finish(reject, new Error(msg.error)); }
46
+ };
47
+ const onError = (err) => { worker.terminate().catch(() => {}); finish(reject, err); };
48
+ worker.on('message', onMessage);
49
+ worker.on('error', onError);
50
+ });
51
+ }
52
+
53
+ /**
54
+ * @param {object} opts
55
+ * @param {Record<string,string>} opts.fileContents - broadcast once to every worker at creation.
56
+ * @param {string|null} opts.scanRoot
57
+ * @param {number} [opts.poolSize=4]
58
+ * @param {string} opts.modulePath - absolute path to the engine.js each worker imports.
59
+ * @param {string} [opts.workerScript] - defaults to cascade-worker.js next to this file.
60
+ * @param {number} [opts.graceMs]
61
+ */
62
+ export function createCascadePool(opts = {}) {
63
+ const {
64
+ fileContents = {},
65
+ scanRoot = null,
66
+ poolSize = 4,
67
+ workerScript = DEFAULT_WORKER_SCRIPT,
68
+ modulePath,
69
+ graceMs = DEFAULT_GRACE_MS,
70
+ } = opts;
71
+ if (!modulePath) throw new Error('createCascadePool requires opts.modulePath');
72
+ if (!(Number.isInteger(poolSize) && poolSize > 0)) throw new Error('createCascadePool requires opts.poolSize to be a positive integer');
73
+
74
+ const idle = [];
75
+ const pendingIdle = [];
76
+ const allWorkers = new Set();
77
+ let nextTaskId = 1;
78
+ let closed = false;
79
+ let spawnFailureCount = 0;
80
+ let lastSpawnError = null;
81
+
82
+ function spawnAndRegister() {
83
+ return spawnReadyWorker({ workerScript, modulePath, fileContents, scanRoot }).then((w) => {
84
+ allWorkers.add(w);
85
+ return w;
86
+ });
87
+ }
88
+
89
+ // Background respawn after a killed worker (see runFile's timeout branch)
90
+ // is deliberately fire-and-forget -- the timed-out task has already
91
+ // resolved and nothing is waiting on this promise -- but a swallowed
92
+ // failure here would otherwise shrink the pool invisibly (e.g. thread
93
+ // exhaustion making every future respawn fail silently forever). Track
94
+ // it instead of a bare `.catch(() => {})`.
95
+ function respawnInBackground() {
96
+ spawnAndRegister().then(releaseWorker).catch((err) => {
97
+ spawnFailureCount += 1;
98
+ lastSpawnError = err;
99
+ });
100
+ }
101
+
102
+ function releaseWorker(w) {
103
+ if (closed) { allWorkers.delete(w); w.terminate().catch(() => {}); return; }
104
+ if (pendingIdle.length) pendingIdle.shift()(w);
105
+ else idle.push(w);
106
+ }
107
+
108
+ function acquireWorker() {
109
+ if (idle.length) return Promise.resolve(idle.pop());
110
+ return new Promise((resolve) => pendingIdle.push(resolve));
111
+ }
112
+
113
+ const ready = Promise.all(
114
+ Array.from({ length: poolSize }, () => spawnAndRegister().then(releaseWorker))
115
+ );
116
+
117
+ /**
118
+ * Run one file's cascade on a pooled worker. Resolves with
119
+ * {ok:true, result} | {ok:false, error} | {ok:false, timedOut:true, timeoutMs, graceMs}.
120
+ * Never rejects.
121
+ */
122
+ async function runFile(p, c, taskScanRoot, detectorErrors, taskOpts = {}) {
123
+ await ready;
124
+ if (closed) return { ok: false, error: 'pool is closed' };
125
+ const timeoutMs = taskOpts.timeoutMs;
126
+ const worker = await acquireWorker();
127
+ const id = nextTaskId++;
128
+ return new Promise((resolve) => {
129
+ let settled = false;
130
+ let killTimer = null;
131
+ const onMessage = (msg) => {
132
+ if (settled || !msg || msg.id !== id) return;
133
+ settled = true;
134
+ if (killTimer) clearTimeout(killTimer);
135
+ worker.off('message', onMessage);
136
+ releaseWorker(worker);
137
+ resolve(msg.ok ? { ok: true, result: msg.result } : { ok: false, error: msg.error });
138
+ };
139
+ worker.on('message', onMessage);
140
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
141
+ killTimer = setTimeout(() => {
142
+ if (settled) return;
143
+ settled = true;
144
+ worker.off('message', onMessage);
145
+ allWorkers.delete(worker);
146
+ worker.terminate().catch(() => {});
147
+ if (!closed) respawnInBackground();
148
+ resolve({ ok: false, timedOut: true, timeoutMs, graceMs });
149
+ }, timeoutMs + graceMs);
150
+ killTimer.unref?.();
151
+ }
152
+ worker.postMessage({ type: 'task', id, p, c, scanRoot: taskScanRoot, detectorErrors });
153
+ });
154
+ }
155
+
156
+ async function shutdown() {
157
+ closed = true;
158
+ await ready.catch(() => {});
159
+ for (const w of allWorkers) await w.terminate().catch(() => {});
160
+ allWorkers.clear();
161
+ idle.length = 0;
162
+ }
163
+
164
+ return {
165
+ runFile,
166
+ shutdown,
167
+ ready,
168
+ DEFAULT_GRACE_MS: graceMs,
169
+ getSpawnFailureCount: () => spawnFailureCount,
170
+ getLastSpawnError: () => lastSpawnError,
171
+ };
172
+ }
@@ -0,0 +1,43 @@
1
+ // FR-202 phase 3b (D-0050): the long-lived worker thread spawned by cascade-worker-pool.js.
2
+ // Unlike analyzer-worker.js (one-shot: single call, single reply, then terminated by its
3
+ // caller), this worker performs one-time setup ONCE at startup, then services many per-file
4
+ // _runFileCascade task messages for the lifetime of the pool. See D-0050 for why per-file
5
+ // setup (rebuilding the whole-project index for every single file) would be O(N^2) and was
6
+ // rejected.
7
+ import { parentPort, workerData } from 'node:worker_threads';
8
+
9
+ const { modulePath, fileContents, scanRoot } = workerData;
10
+
11
+ let mod = null;
12
+ let initError = null;
13
+
14
+ async function init() {
15
+ try {
16
+ mod = await import(modulePath);
17
+ if (typeof mod._initCascadeWorkerState !== 'function' || typeof mod._runFileCascade !== 'function') {
18
+ throw new Error(`module at ${modulePath} does not export _initCascadeWorkerState/_runFileCascade`);
19
+ }
20
+ await mod._initCascadeWorkerState(fileContents, scanRoot);
21
+ parentPort.postMessage({ type: 'ready' });
22
+ } catch (err) {
23
+ initError = String((err && err.stack) || err);
24
+ parentPort.postMessage({ type: 'init-error', error: initError });
25
+ }
26
+ }
27
+
28
+ parentPort.on('message', (msg) => {
29
+ if (!msg || msg.type !== 'task') return;
30
+ const { id, p, c, scanRoot: taskScanRoot, detectorErrors } = msg;
31
+ if (!mod) {
32
+ parentPort.postMessage({ id, ok: false, error: initError || 'worker not initialized' });
33
+ return;
34
+ }
35
+ try {
36
+ const result = mod._runFileCascade(p, c, taskScanRoot, detectorErrors || []);
37
+ parentPort.postMessage({ id, ok: true, result });
38
+ } catch (err) {
39
+ parentPort.postMessage({ id, ok: false, error: String((err && err.message) || err) });
40
+ }
41
+ });
42
+
43
+ init();
Binary file
@@ -0,0 +1,51 @@
1
+ // Detector runner (assurance-hardening PRD, E2 decomposition — D-0028 step
2
+ // (a): "build a small `_runDetector(errs, file, name, thunk)` isolation
3
+ // helper first (low-risk, no call sites touched yet)").
4
+ //
5
+ // FR-201: "Execute detectors in isolated units with per-analyzer error
6
+ // capture | One detector exception does not prevent subsequent independent
7
+ // analyzers from running."
8
+ //
9
+ // Modeled directly on `annotator-runner.js`'s own runAnnotatorAsync (FR-106,
10
+ // already shipped) — same isolation contract, same "extract into its own
11
+ // file so the capture path can be fault-injection tested directly" rationale
12
+ // (this codebase's own detectors are built never to throw in practice, so
13
+ // there is no natural way to prove the isolation works end-to-end without
14
+ // synthetic throwing callbacks). The one real difference: every one of
15
+ // `engine.js`'s ~128 per-file detector call sites is SYNCHRONOUS — none
16
+ // return a Promise — so this helper is deliberately sync-only, not a
17
+ // generic sync-or-async wrapper. Wrapping ~128 calls per file in an async
18
+ // function across every scanned file would add real Promise-microtask
19
+ // overhead to the hottest loop in the engine for no benefit any detector
20
+ // actually needs.
21
+ //
22
+ // THIS MODULE HAS NO CALLER YET, BY DESIGN. D-0028's own decomposition is
23
+ // explicit that step (a) — this helper — must exist and be proven correct
24
+ // BEFORE step (b) (migrating the ~128 heterogeneous call sites in small,
25
+ // git-diffable, gate-verified batches) begins. Wiring it into engine.js
26
+ // is intentionally out of scope for this step.
27
+
28
+ /**
29
+ * Run one detector (`fn`), isolating any exception it throws so it cannot
30
+ * abort the caller's per-file loop. On success, returns `fn()`'s value
31
+ * unchanged. On a thrown exception, appends a structured entry to
32
+ * `detectorErrors` and returns `undefined` — the SAME "undefined means it
33
+ * failed, check the errors array" contract `runAnnotatorAsync` already
34
+ * establishes, so a caller spreading the result into an array must guard
35
+ * with `...(runDetector(...) || [])`, exactly as existing `_runAnnotator`
36
+ * call sites already do for their own non-array return values.
37
+ *
38
+ * @param {Array<{file:string, analyzer:string, err:string}>} detectorErrors - mutated in place (push only)
39
+ * @param {string} file - the file being analyzed when `fn` was invoked
40
+ * @param {string} analyzer - the detector's name, for FR-203's future per-analyzer coverage ledger
41
+ * @param {() => any} fn - the detector call itself, e.g. `() => scanXxx(file, cc)`
42
+ * @returns {any} fn()'s return value, or undefined if it threw
43
+ */
44
+ export function runDetector(detectorErrors, file, analyzer, fn) {
45
+ try {
46
+ return fn();
47
+ } catch (e) {
48
+ detectorErrors.push({ file, analyzer, err: String((e && e.message) || e) });
49
+ return undefined;
50
+ }
51
+ }
@@ -0,0 +1,58 @@
1
+ // Enrichment completion pass (assurance-hardening PRD, Milestone 1, FR-103).
2
+ //
3
+ // engine.js's primary enrichment chain (stableId -> defaults -> confidence
4
+ // -> calibration -> exploitability -> proof-gate -> ...) runs once, early,
5
+ // over whatever is in `finalFindings` at that moment. At least 19 separate
6
+ // call sites append MORE findings after that chain already ran (A-03) —
7
+ // cross-language taint, IAM/container/business-logic/spec-drift/concurrency
8
+ // producers, privacy-taint, plus several opt-in "world-class artifact"
9
+ // emitters (API contract, SBOM diff, license graph, multi-sink synthesis).
10
+ // Moving all 19 call sites earlier in that ~900-line function was
11
+ // considered and rejected as the fix here: several depend on state (aR,
12
+ // annotatedComponents, supplyChain dedup results) not yet computed at an
13
+ // earlier point, and relocating them carries real regression risk against
14
+ // this project's own 500+-entry accuracy corpus for a single engineering
15
+ // cycle's blast radius.
16
+ //
17
+ // Instead: this is a GAP-FILLING pass, run once, late, right before the
18
+ // finding collection is frozen (FR-104). It finds every finding still
19
+ // missing a stableId — the reliable marker of "never went through the
20
+ // primary chain," since annotateStableIds is the first stage in that chain
21
+ // and runs unconditionally — and runs the same core stages on exactly that
22
+ // subset. This covers every late producer's output equally, whether or not
23
+ // that producer has been converted to go through producer-collector.js
24
+ // (see that module's header for which ones have).
25
+ //
26
+ // Two of the four stages (backfillFindingDefaults, annotateConfidence)
27
+ // already skip a finding that has the field set, so calling them here is
28
+ // pure gap-filling by construction. The other two (annotateCalibratedConfidence,
29
+ // annotateExploitability) always recompute unconditionally, which is why
30
+ // this scopes to the late subset rather than the whole array — cheap and
31
+ // correct, not "cheap because deduped by luck."
32
+
33
+ import { annotateStableIds } from '../posture/stable-id.js';
34
+ import { backfillFindingDefaults } from '../posture/finding-defaults.js';
35
+ import { annotateConfidence } from '../posture/confidence.js';
36
+ import { annotateCalibratedConfidence } from '../posture/calibration.js';
37
+ import { annotateExploitability } from '../posture/exploitability.js';
38
+
39
+ /**
40
+ * @param {object[]} finalFindings
41
+ * @param {object} ctx
42
+ * @param {string} ctx.scanRoot
43
+ * @param {object} ctx.projectCtx - same shape engine.js's own annotateExploitability call already builds
44
+ * @returns {{gapFilledCount:number}}
45
+ */
46
+ export function completeEnrichment(finalFindings, { scanRoot, projectCtx } = {}) {
47
+ if (!Array.isArray(finalFindings)) return { gapFilledCount: 0 };
48
+ const late = finalFindings.filter(f => f && typeof f === 'object' && !f.stableId);
49
+ if (!late.length) return { gapFilledCount: 0 };
50
+
51
+ annotateStableIds(late);
52
+ backfillFindingDefaults(late);
53
+ annotateConfidence(late);
54
+ try { annotateCalibratedConfidence(late, { scanRoot }); } catch { /* calibration table load is best-effort */ }
55
+ try { annotateExploitability(late, projectCtx || {}); } catch { /* scoring is best-effort */ }
56
+
57
+ return { gapFilledCount: late.length };
58
+ }
@@ -0,0 +1,91 @@
1
+ // Evidence provenance (assurance-hardening PRD, Milestone 1, FR-107).
2
+ //
3
+ // A real scanned finding carries ~90 flat fields (verified against a live
4
+ // scan of test/fixtures/vulnerable-js before writing this) — a mix of what
5
+ // the detector directly OBSERVED (file, line, matched snippet, rule id) and
6
+ // what LATER ANNOTATORS INFERRED from it (confidence, calibrated_confidence,
7
+ // severity adjustments, exploitability, compositeRisk, crownJewelScore,
8
+ // riskDollars, toxicity, blastRadius, and dozens more). Nothing about the
9
+ // flat shape tells a reviewer which is which — that distinction exists only
10
+ // as tribal knowledge of which field name means what. FR-107's acceptance
11
+ // criterion is exactly this: "A reviewer can distinguish observed facts from
12
+ // inferred confidence, severity, and risk."
13
+ //
14
+ // `posture/why-fired.js` ALREADY captures genuine raw evidence — sink/source
15
+ // snippets, path steps, sanitizers considered — nested under
16
+ // `finding.whyFired.evidence`, and is already wired into engine.js's default
17
+ // pipeline (confirmed: `annotateWhyFired` runs on every scan). That module
18
+ // solves "what did the detector see" for the detection step itself. This
19
+ // module does the complementary, coarser-grained job FR-107 actually asks
20
+ // for: given ANY finding, cleanly partition its top-level fields into
21
+ // observed vs. derived, so a reviewer (or a report) can ask the question
22
+ // without already knowing which of ~90 field names is which.
23
+ //
24
+ // DESIGN: an explicit ALLOWLIST of observed fields, not a blocklist of
25
+ // derived ones. A blocklist enumerating ~80 derived field names would need
26
+ // updating every time a new annotator ships a new field — exactly the kind
27
+ // of drift this session has repeatedly found and fixed elsewhere (producer
28
+ // registry, artifact registry, egress completeness guard). An allowlist of
29
+ // the small, stable set of fields a DETECTOR sets before any annotator runs
30
+ // is self-maintaining: anything not on it is derived by construction, so a
31
+ // new annotator's new field is correctly classified `derived` automatically,
32
+ // with zero maintenance here.
33
+ //
34
+ // Additive, non-throwing, Milestone-1 observability — like FR-105's
35
+ // describeFindingCompleteness, this does not mutate findings, gate anything,
36
+ // or run from engine.js's default pipeline.
37
+
38
+ import { FINDING_SCHEMA_VERSION } from './finding-schema.js';
39
+
40
+ // The fields a detector sets to describe WHAT it found and WHERE, before any
41
+ // annotator has touched the finding. Matches report/index.js's
42
+ // normalizeFindings() identity/location field names (not invented — read
43
+ // from the actual canonical shape, per this session's D-0003 discipline).
44
+ export const RAW_OBSERVED_FIELDS = Object.freeze([
45
+ 'id', 'stableId', 'kind', 'vuln', 'cwe', 'owaspLlm', 'stride', 'family', 'parser',
46
+ 'file', 'line', 'snippet',
47
+ ]);
48
+
49
+ /**
50
+ * Split one finding's fields into what was directly observed by a detector
51
+ * vs. what was inferred by a later annotator.
52
+ *
53
+ * @param {object} finding - one entry from report/index.js#normalizeFindings()'s
54
+ * output (the same contract describeFindingCompleteness uses) — NOT a raw
55
+ * pre-normalization engine finding. Some detector families only set `line`/
56
+ * `snippet` nested under `f.source`/`f.sink` internally; normalizeFindings()
57
+ * is what guarantees they land as flat top-level keys, which is what this
58
+ * function's field partition depends on.
59
+ * @returns {{schemaVersion:number, observed:object, derived:object}}
60
+ * `observed` — RAW_OBSERVED_FIELDS present on the finding, plus
61
+ * `detectorEvidence` (finding.whyFired.evidence, when annotateWhyFired has
62
+ * run) — the snippets/path/sanitizers a detector directly saw.
63
+ * `derived` — every OTHER non-null field: confidence, severity,
64
+ * exploitability, compositeRisk, riskDollars, and everything else any
65
+ * annotator computed. Named `derived` rather than enumerated because the
66
+ * set of annotator-added fields grows over time and enumerating it here
67
+ * would silently go stale (see module header).
68
+ */
69
+ export function describeEvidenceProvenance(finding) {
70
+ const f = finding && typeof finding === 'object' ? finding : {};
71
+ const observedSet = new Set(RAW_OBSERVED_FIELDS);
72
+ const observed = {};
73
+ const derived = {};
74
+
75
+ for (const [key, value] of Object.entries(f)) {
76
+ if (value === null || value === undefined) continue;
77
+ if (observedSet.has(key)) observed[key] = value;
78
+ else derived[key] = value;
79
+ }
80
+
81
+ // whyFired.evidence is itself raw (what the detector saw); whyFired as a
82
+ // whole still lands in `derived` above since it also carries `considered`
83
+ // (pipeline-processing outcomes) and `scanner` (ruleset provenance) —
84
+ // neither is "what the detector observed", so only the evidence slice is
85
+ // promoted into `observed`, not the whole wrapper object.
86
+ if (f.whyFired && typeof f.whyFired === 'object' && f.whyFired.evidence) {
87
+ observed.detectorEvidence = f.whyFired.evidence;
88
+ }
89
+
90
+ return { schemaVersion: FINDING_SCHEMA_VERSION, observed, derived };
91
+ }