@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +555 -0
- package/bin/agentic-security.js +770 -49
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +117 -18
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +24 -6
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/271.index.js +165 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +187 -52
- package/dist/444.index.js +11 -2
- package/dist/449.index.js +76 -12
- package/dist/526.index.js +117 -18
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +27 -5
- package/dist/730.index.js +311 -0
- package/dist/736.index.js +301 -0
- package/dist/824.index.js +7 -0
- package/dist/905.index.js +88 -22
- package/dist/920.index.js +491 -0
- package/dist/970.index.js +173 -0
- package/dist/agentic-security.mjs +13 -13
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/calibration-seed.json +2 -0
- package/package.json +23 -9
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/index.js +18 -0
- package/src/dataflow/privacy-catalog.js +290 -0
- package/src/dataflow/privacy-deep-walker.js +515 -0
- package/src/dataflow/privacy-governance.js +126 -0
- package/src/dataflow/privacy-inventory.js +154 -0
- package/src/dataflow/privacy-sink-policy.js +125 -0
- package/src/dataflow/privacy-taint.js +115 -54
- package/src/dataflow/privacy-taxonomy.js +233 -0
- package/src/discovery/disprove.js +7 -3
- package/src/discovery/hunter.js +9 -5
- package/src/discovery/index.js +2 -2
- package/src/discovery/llm-invoke.js +69 -13
- package/src/egress/audit.js +147 -0
- package/src/egress/policy.js +313 -0
- package/src/egress/redact.js +180 -0
- package/src/engine.js +1048 -302
- package/src/fix/apply-fix-service.js +404 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +1 -1
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/tools.js +177 -50
- package/src/pipeline/analyzer-supervisor.js +93 -0
- package/src/pipeline/analyzer-worker.js +26 -0
- package/src/pipeline/annotator-runner.js +33 -0
- package/src/pipeline/assurance-mode.js +154 -0
- package/src/pipeline/cascade-worker-pool.js +172 -0
- package/src/pipeline/cascade-worker.js +43 -0
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/detector-runner.js +51 -0
- package/src/pipeline/enrichment-completion.js +58 -0
- package/src/pipeline/evidence-provenance.js +91 -0
- package/src/pipeline/finding-schema.js +108 -0
- package/src/pipeline/legacy-compat.js +101 -0
- package/src/pipeline/producer-collector.js +48 -0
- package/src/pipeline/producer-registry.js +112 -0
- package/src/pipeline/scan-health.js +144 -0
- package/src/posture/CLAUDE.md +123 -0
- package/src/posture/accuracy-scorecard.js +156 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +241 -0
- package/src/posture/auditor-walkthrough.js +186 -21
- package/src/posture/calibration-feedback.js +201 -0
- package/src/posture/calibration-seed.json +2 -0
- package/src/posture/calibration.js +25 -0
- package/src/posture/compliance-evidence-signing.js +131 -0
- package/src/posture/compliance-policy.js +324 -17
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/custom-rules.js +36 -0
- package/src/posture/deterministic.js +8 -1
- package/src/posture/encryption-provider.js +205 -0
- package/src/posture/evidence-grade-wording.js +71 -0
- package/src/posture/fix-history.js +137 -20
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +65 -8
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/git-history.js +13 -5
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +111 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +47 -6
- package/src/posture/production-feedback.js +179 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +64 -5
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +248 -5
- package/src/report/oscal.js +7 -2
- package/src/runScan.js +34 -5
- package/src/sast/cpp.js +3 -14
- package/src/sast/rate-limit.js +33 -3
- package/src/sca/llm-function-extract.js +6 -0
- package/src/util/git-hardening.js +128 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Finding schema (assurance-hardening PRD, Milestone 0, FR-105).
|
|
2
|
+
//
|
|
3
|
+
// The PRD's §10.1 envelope (schemaVersion/stableId/producerId/... in ten
|
|
4
|
+
// named groups) describes an IDEAL shape. This engine's actual canonical
|
|
5
|
+
// shape already exists — it is report/index.js's `normalizeFindings()`,
|
|
6
|
+
// which every JSON/SARIF/HTML/CSV/JUnit/OSCAL output format calls (verified
|
|
7
|
+
// by grepping every call site in report/index.js, oscal.js, and
|
|
8
|
+
// bin/agentic-security.js before writing this module — see
|
|
9
|
+
// docs/implementation/assurance-hardening-decisions.md D-0003's rule: read
|
|
10
|
+
// the existing mechanism before building a parallel one). `normalizeFindings`
|
|
11
|
+
// already enforces an explicit allowlist with an honest `null` default for
|
|
12
|
+
// every optional field, which is most of what FR-105 asks for; it was simply
|
|
13
|
+
// never versioned or independently callable as "does this finding satisfy
|
|
14
|
+
// the contract".
|
|
15
|
+
//
|
|
16
|
+
// This module does two things, both additive (Milestone 0 scope — no gate
|
|
17
|
+
// wiring, no new required fields, nothing gets rejected):
|
|
18
|
+
// 1. Names FINDING_SCHEMA_VERSION so future breaking changes to the
|
|
19
|
+
// normalizeFindings shape have something to bump (FR-105 acceptance:
|
|
20
|
+
// "select a supported output contract during migration" per §12.1's
|
|
21
|
+
// `--schema-version` flag, not yet wired to a CLI flag in this cycle).
|
|
22
|
+
// 2. Provides describeFindingCompleteness(), a pure, non-throwing function
|
|
23
|
+
// that reports which of the CURRENT canonical fields a given finding
|
|
24
|
+
// actually has non-null values for — observability, not enforcement.
|
|
25
|
+
// Full enforcement (every producer's output must pass validation before
|
|
26
|
+
// it can reach a reporter) is Milestone 1's FR-103/FR-104, which
|
|
27
|
+
// requires producer isolation (FR-201) to exist first so a rejected
|
|
28
|
+
// finding can be attributed to the producer that emitted it.
|
|
29
|
+
//
|
|
30
|
+
// One MCP correctness bug was found and fixed alongside this (not scope
|
|
31
|
+
// creep — it's the exact FR-105 acceptance criterion "...and MCP outputs
|
|
32
|
+
// derive from the same validated object"): mcp/tools.js's scan_diff hand-
|
|
33
|
+
// rolled a 3-of-4-channel finding merge instead of calling
|
|
34
|
+
// normalizeFindings(), which both risked field-mapping drift and silently
|
|
35
|
+
// excluded the supplyChain channel. Fixed in mcp/tools.js; see that file's
|
|
36
|
+
// comment for the residual limitation (scan_diff still never populates
|
|
37
|
+
// depFileContents, so supply-chain detection does not reach it today for an
|
|
38
|
+
// unrelated, deeper reason this schema module does not address).
|
|
39
|
+
|
|
40
|
+
export const FINDING_SCHEMA_VERSION = 1;
|
|
41
|
+
|
|
42
|
+
// Field groups mirror the PRD's §10.1 structure, using the CURRENT field
|
|
43
|
+
// names normalizeFindings() actually produces (see decision D-0003: extend
|
|
44
|
+
// what exists, don't invent a parallel vocabulary no real finding uses).
|
|
45
|
+
// `required` fields are ones every finding channel (sast/secret/logic/sca)
|
|
46
|
+
// sets to a real (non-null) value today; `optional` fields are legitimately
|
|
47
|
+
// null on many findings (e.g. a finding no annotator has enriched yet).
|
|
48
|
+
export const FINDING_FIELD_GROUPS = {
|
|
49
|
+
// `findingProvenance` is REQUIRED, not optional, and that is deliberate:
|
|
50
|
+
// posture/provenance/coordinator.js guarantees every finding it sees leaves
|
|
51
|
+
// with a TERMINAL provenance object, expressing every failure mode as a
|
|
52
|
+
// status ('not_available', 'uncommitted', 'budget_exhausted', 'error')
|
|
53
|
+
// rather than as an absent field. So a missing findingProvenance never means
|
|
54
|
+
// "provenance didn't apply here" — it means the finding escaped annotation
|
|
55
|
+
// entirely, which is exactly the condition this group exists to surface.
|
|
56
|
+
identity: { required: ['id', 'kind', 'vuln', 'findingProvenance'], optional: ['stableId'] },
|
|
57
|
+
location: { required: ['file', 'line'], optional: ['snippet'] },
|
|
58
|
+
classification: { required: ['severity'], optional: ['cwe', 'owaspLlm', 'family', 'parser', 'tags', 'description'] },
|
|
59
|
+
confidence: { required: [], optional: ['confidence', 'confidenceTier', 'calibrated_confidence', 'calibration_reason'] },
|
|
60
|
+
evidence: { required: [], optional: ['proof', 'falsification', 'verification', 'chain', 'sources', 'corroboration'] },
|
|
61
|
+
privacy: { required: [], optional: ['dataClasses'] },
|
|
62
|
+
compliance: { required: [], optional: [] }, // no per-finding controlRefs today — compliance mapping is scan-level (FR-501/E5)
|
|
63
|
+
remediation: { required: [], optional: ['fix', 'remediation'] },
|
|
64
|
+
risk: { required: [], optional: ['exploitability', 'exploitabilityTier', 'compositeRisk', 'compositeRiskTier', 'crownJewelScore', 'riskDollars'] },
|
|
65
|
+
lifecycle: { required: [], optional: ['triage', 'quarantined', 'unreachable'] },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reports, for one normalized finding (i.e. an item from
|
|
70
|
+
* report/index.js#normalizeFindings' output — this function does NOT accept
|
|
71
|
+
* a raw pre-normalization finding), which canonical groups have their
|
|
72
|
+
* required fields present and which optional fields are populated.
|
|
73
|
+
*
|
|
74
|
+
* Pure, non-throwing, additive-only: does not mutate the finding, does not
|
|
75
|
+
* gate anything, and is not called from engine.js's default pipeline. It
|
|
76
|
+
* exists to be run over a real scan's output for observability (a future
|
|
77
|
+
* `--explain-health`-style report, per PRD §12.1) — see this module's
|
|
78
|
+
* header comment for what is deliberately NOT yet true of it.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} finding - one entry from normalizeFindings(scan)
|
|
81
|
+
* @returns {{schemaVersion:number, missingRequiredFields:string[], populatedOptionalFields:string[], missingOptionalFields:string[], isComplete:boolean}}
|
|
82
|
+
*/
|
|
83
|
+
export function describeFindingCompleteness(finding) {
|
|
84
|
+
const f = finding && typeof finding === 'object' ? finding : {};
|
|
85
|
+
const missingRequiredFields = [];
|
|
86
|
+
const populatedOptionalFields = [];
|
|
87
|
+
const missingOptionalFields = [];
|
|
88
|
+
|
|
89
|
+
const isPopulated = (v) => v !== null && v !== undefined && !(Array.isArray(v) && v.length === 0);
|
|
90
|
+
|
|
91
|
+
for (const group of Object.values(FINDING_FIELD_GROUPS)) {
|
|
92
|
+
for (const field of group.required) {
|
|
93
|
+
if (!isPopulated(f[field])) missingRequiredFields.push(field);
|
|
94
|
+
}
|
|
95
|
+
for (const field of group.optional) {
|
|
96
|
+
if (isPopulated(f[field])) populatedOptionalFields.push(field);
|
|
97
|
+
else missingOptionalFields.push(field);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
schemaVersion: FINDING_SCHEMA_VERSION,
|
|
103
|
+
missingRequiredFields,
|
|
104
|
+
populatedOptionalFields,
|
|
105
|
+
missingOptionalFields,
|
|
106
|
+
isComplete: missingRequiredFields.length === 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Legacy field compatibility adapter (assurance-hardening PRD, Milestone 1,
|
|
2
|
+
// FR-108: "Publish a compatibility adapter for legacy finding consumers.
|
|
3
|
+
// Existing integrations receive a deprecation warning and equivalent legacy
|
|
4
|
+
// fields for one documented transition period.")
|
|
5
|
+
//
|
|
6
|
+
// This codebase's own stated convention (repeated three times across
|
|
7
|
+
// CHANGELOG.md: "No breaking changes. All new capabilities are additive")
|
|
8
|
+
// means a finding field should almost never be renamed or removed out from
|
|
9
|
+
// under an existing consumer. But it can happen for a good reason — this
|
|
10
|
+
// same session's FR-804 renamed `riskDollars.confidenceFloor` to
|
|
11
|
+
// `confidenceWeight` because the old name was actively misleading (the
|
|
12
|
+
// field stopped being a floor once the bug it named was fixed) — and when
|
|
13
|
+
// it does, a consumer reading the old field name should not silently start
|
|
14
|
+
// getting `undefined`. That rename is the concrete, real case this adapter
|
|
15
|
+
// exists for, not a hypothetical one.
|
|
16
|
+
//
|
|
17
|
+
// LEGACY_FIELD_ALIASES names every known rename. applyLegacyCompat(finding)
|
|
18
|
+
// backfills the OLD dotted path from the NEW one's current value whenever
|
|
19
|
+
// the new value is present and nothing already occupies the old path — an
|
|
20
|
+
// existing integration reading the deprecated name keeps working, unchanged,
|
|
21
|
+
// for the documented transition period. legacyFieldDeprecationNotice()
|
|
22
|
+
// turns that into the single, honest, report-level notice FR-108 asks for,
|
|
23
|
+
// rather than silently reintroducing the old shape forever with no signal
|
|
24
|
+
// that it is scheduled for removal.
|
|
25
|
+
|
|
26
|
+
export const LEGACY_FIELD_ALIASES = Object.freeze([
|
|
27
|
+
Object.freeze({
|
|
28
|
+
oldPath: 'riskDollars.confidenceFloor',
|
|
29
|
+
newPath: 'riskDollars.confidenceWeight',
|
|
30
|
+
deprecatedInVersion: '0.143.0', // FR-804, same session as this adapter
|
|
31
|
+
sunsetAfter: '2027-02-24', // ~6 months from this adapter's introduction
|
|
32
|
+
reason: "renamed because the field stopped being a floor — Math.max(0.4, f.confidence || 0.8)'s unconditional floor and 0-as-falsy inflation were removed as part of FR-804's fix",
|
|
33
|
+
}),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function _getPath(obj, dottedPath) {
|
|
37
|
+
return dottedPath.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _setPath(obj, dottedPath, value) {
|
|
41
|
+
const parts = dottedPath.split('.');
|
|
42
|
+
let o = obj;
|
|
43
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
44
|
+
// Only backfill onto a parent object that already exists — if
|
|
45
|
+
// riskDollars itself is null/absent, there is nothing to attach a
|
|
46
|
+
// legacy alias to, and creating a fake `{confidenceFloor: ...}` shell
|
|
47
|
+
// would be actively misleading (it would look like a real annotation
|
|
48
|
+
// that never ran).
|
|
49
|
+
if (o[parts[i]] == null || typeof o[parts[i]] !== 'object') return false;
|
|
50
|
+
o = o[parts[i]];
|
|
51
|
+
}
|
|
52
|
+
o[parts[parts.length - 1]] = value;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Backfill deprecated field names on ONE finding from their current
|
|
58
|
+
* replacements. Mutates the finding in place (matches every other
|
|
59
|
+
* annotator's convention in this codebase). Additive only — never
|
|
60
|
+
* overwrites a value already present at the old path, so a caller that set
|
|
61
|
+
* the legacy field explicitly (e.g. a hand-built test fixture) is never
|
|
62
|
+
* clobbered.
|
|
63
|
+
*
|
|
64
|
+
* @returns {string[]} which legacy field paths were actually backfilled —
|
|
65
|
+
* empty for the common, eventual steady state where nothing legacy fired.
|
|
66
|
+
*/
|
|
67
|
+
export function applyLegacyCompat(finding) {
|
|
68
|
+
if (!finding || typeof finding !== 'object') return [];
|
|
69
|
+
const applied = [];
|
|
70
|
+
for (const alias of LEGACY_FIELD_ALIASES) {
|
|
71
|
+
const newValue = _getPath(finding, alias.newPath);
|
|
72
|
+
if (newValue === undefined) continue; // nothing to backfill from
|
|
73
|
+
if (_getPath(finding, alias.oldPath) !== undefined) continue; // already set — don't clobber
|
|
74
|
+
if (_setPath(finding, alias.oldPath, newValue)) applied.push(alias.oldPath);
|
|
75
|
+
}
|
|
76
|
+
if (applied.length) finding._legacyFields = applied;
|
|
77
|
+
return applied;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A single, report-level deprecation notice summarizing every legacy alias
|
|
82
|
+
* actually used across a finding set. Returns null when none fired.
|
|
83
|
+
*/
|
|
84
|
+
export function legacyFieldDeprecationNotice(findings) {
|
|
85
|
+
if (!Array.isArray(findings)) return null;
|
|
86
|
+
const used = new Set();
|
|
87
|
+
for (const f of findings) {
|
|
88
|
+
if (Array.isArray(f?._legacyFields)) for (const p of f._legacyFields) used.add(p);
|
|
89
|
+
}
|
|
90
|
+
if (used.size === 0) return null;
|
|
91
|
+
const fields = [...used].sort().map(oldPath => {
|
|
92
|
+
const alias = LEGACY_FIELD_ALIASES.find(a => a.oldPath === oldPath);
|
|
93
|
+
return alias
|
|
94
|
+
? { oldPath, newPath: alias.newPath, deprecatedInVersion: alias.deprecatedInVersion, sunsetAfter: alias.sunsetAfter, reason: alias.reason }
|
|
95
|
+
: { oldPath, newPath: null, deprecatedInVersion: null, sunsetAfter: null, reason: null };
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
message: 'This report includes deprecated legacy field name(s), backfilled from their current replacements for backward compatibility. Update integrations to read the new field name(s) before the documented sunset date.',
|
|
99
|
+
fields,
|
|
100
|
+
};
|
|
101
|
+
}
|