@clear-capabilities/agentic-security-scanner 0.184.0 → 0.186.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/bin/agentic-security.js +111 -1
- package/dist/1128.index.js +912 -0
- package/dist/1665.index.js +828 -0
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- package/dist/5752.index.js +466 -0
- package/dist/7405.index.js +1 -1
- package/dist/8158.index.js +1713 -0
- package/dist/8798.index.js +1 -1
- package/dist/9382.index.js +2 -2
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +29 -12
- package/src/dataflow/CLAUDE.md +46 -1
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +1168 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- package/src/dataflow/exploit-prover.js +6 -2
- package/src/dataflow/ifds-precise.js +22 -0
- package/src/dataflow/incremental-replay.js +118 -0
- package/src/dataflow/incremental-session.js +257 -0
- package/src/dataflow/incremental.js +237 -35
- package/src/dataflow/index.js +25 -94
- package/src/dataflow/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/dataflow/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/engine.js +113 -8
- package/src/graph/CLAUDE.md +31 -0
- package/src/graph/adapters/artifact.js +81 -0
- package/src/graph/adapters/crossrepo.js +75 -0
- package/src/graph/adapters/dataflow.js +76 -0
- package/src/graph/adapters/provenance.js +92 -0
- package/src/graph/adapters/semantic.js +89 -0
- package/src/graph/crosswalk-builders.js +152 -0
- package/src/graph/crosswalk.js +146 -0
- package/src/graph/index.js +15 -0
- package/src/graph/observation.js +57 -0
- package/src/graph/refs.js +105 -0
- package/src/graph/registry.js +129 -0
- package/src/graph/relations.js +81 -0
- package/src/graph/scc.js +241 -0
- package/src/graph/view.js +254 -0
- package/src/ir/CLAUDE.md +4 -0
- package/src/ir/callgraph.js +36 -8
- package/src/ir/index.js +18 -4
- package/src/ir/parser-js.js +10 -0
- package/src/language/CLAUDE.md +13 -0
- package/src/language/nix-option-types.js +170 -0
- package/src/language/nix-script-taint.js +46 -3
- package/src/language/nix-secrets.js +20 -2
- package/src/lineage/CLAUDE.md +72 -8
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
- package/src/lineage/boundary-contract.js +273 -0
- package/src/lineage/completion.js +205 -0
- package/src/lineage/continuation.js +140 -0
- package/src/lineage/control-credit.js +313 -0
- package/src/lineage/correlation.js +199 -0
- package/src/lineage/coverage.js +8 -1
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/cut-planner.js +548 -0
- package/src/lineage/driver.js +219 -66
- package/src/lineage/engine.js +166 -21
- package/src/lineage/export-json.js +5 -0
- package/src/lineage/graph-builder.js +84 -7
- package/src/lineage/haskell-view.js +59 -0
- package/src/lineage/index.js +3 -0
- package/src/lineage/nix-view.js +35 -0
- package/src/lineage/owned-build.js +77 -0
- package/src/lineage/owned-worker-entry.js +22 -0
- package/src/lineage/owned-worker.js +95 -0
- package/src/lineage/path-evidence.js +123 -0
- package/src/lineage/path-predicates.js +529 -0
- package/src/lineage/path-query.js +189 -20
- package/src/lineage/path-store.js +191 -17
- package/src/lineage/scc-solver.js +186 -0
- package/src/lineage/smt-orchestrator.js +174 -0
- package/src/lineage/smt-z3-backend.js +294 -0
- package/src/lineage/source-seeding.js +36 -2
- package/src/lineage/summaries.js +66 -4
- package/src/lineage/value-versions.js +130 -0
- package/src/lineage/witness-selection.js +95 -0
- package/src/pipeline/scan-health.js +18 -0
- package/src/posture/CLAUDE.md +17 -0
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- package/src/report/index.js +2 -0
- package/src/sast/CLAUDE.md +2 -0
- package/src/sast/_call-args.js +70 -0
- package/src/sast/init-scope.js +87 -0
- package/src/sast/listen-bind-address.js +49 -0
- package/src/sast/redirect-target.js +106 -0
- package/src/sast/reflected-input.js +76 -0
- package/src/sast/send-file-root.js +27 -0
- package/src/sast/sensitive-data-logging.js +38 -1
- package/dist/119.index.js +0 -197
- package/dist/128.index.js +0 -129
- package/dist/1293.index.js +0 -5689
- package/dist/1336.index.js +0 -16182
- package/dist/1351.index.js +0 -133
- package/dist/1387.index.js +0 -271
- package/dist/1437.index.js +0 -23
- package/dist/1445.index.js +0 -110
- package/dist/1505.index.js +0 -151
- package/dist/1557.index.js +0 -293
- package/dist/1621.index.js +0 -311
- package/dist/1751.index.js +0 -160
- package/dist/1765.index.js +0 -727
- package/dist/1861.index.js +0 -690
- package/dist/1906.index.js +0 -433
- package/dist/208.index.js +0 -1631
- package/dist/2223.index.js +0 -159
- package/dist/2238.index.js +0 -295
- package/dist/2530.index.js +0 -152
- package/dist/2565.index.js +0 -116
- package/dist/2574.index.js +0 -127
- package/dist/2620.index.js +0 -345
- package/dist/2773.index.js +0 -98
- package/dist/2775.index.js +0 -32
- package/dist/2966.index.js +0 -171
- package/dist/2972.index.js +0 -87
- package/dist/2991.index.js +0 -131
- package/dist/3063.index.js +0 -833
- package/dist/3075.index.js +0 -227
- package/dist/3121.index.js +0 -297
- package/dist/3314.index.js +0 -263
- package/dist/3480.index.js +0 -1822
- package/dist/3481.index.js +0 -24
- package/dist/3506.index.js +0 -268
- package/dist/3533.index.js +0 -591
- package/dist/3653.index.js +0 -450
- package/dist/3740.index.js +0 -722
- package/dist/3822.index.js +0 -131
- package/dist/3868.index.js +0 -292
- package/dist/3918.index.js +0 -192
- package/dist/4090.index.js +0 -288
- package/dist/4113.index.js +0 -1286
- package/dist/4121.index.js +0 -1190
- package/dist/4142.index.js +0 -690
- package/dist/4168.index.js +0 -509
- package/dist/4281.index.js +0 -993
- package/dist/4336.index.js +0 -436
- package/dist/4387.index.js +0 -532
- package/dist/4413.index.js +0 -302
- package/dist/4476.index.js +0 -4510
- package/dist/4491.index.js +0 -133
- package/dist/4538.index.js +0 -185
- package/dist/4684.index.js +0 -319
- package/dist/4740.index.js +0 -165
- package/dist/4780.index.js +0 -209
- package/dist/4802.index.js +0 -225
- package/dist/4812.index.js +0 -379
- package/dist/4835.index.js +0 -126
- package/dist/494.index.js +0 -226
- package/dist/4991.index.js +0 -549
- package/dist/5199.index.js +0 -305
- package/dist/5216.index.js +0 -109
- package/dist/5439.index.js +0 -301
- package/dist/5443.index.js +0 -97
- package/dist/5496.index.js +0 -377
- package/dist/5738.index.js +0 -240
- package/dist/5801.index.js +0 -588
- package/dist/5817.index.js +0 -714
- package/dist/5858.index.js +0 -223
- package/dist/5933.index.js +0 -143
- package/dist/601.index.js +0 -23
- package/dist/6173.index.js +0 -273
- package/dist/6291.index.js +0 -1871
- package/dist/6312.index.js +0 -666
- package/dist/634.index.js +0 -16182
- package/dist/644.index.js +0 -87
- package/dist/6508.index.js +0 -283
- package/dist/6560.index.js +0 -645
- package/dist/6576.index.js +0 -422
- package/dist/6579.index.js +0 -271
- package/dist/6655.index.js +0 -3221
- package/dist/6745.index.js +0 -305
- package/dist/6818.index.js +0 -21642
- package/dist/6932.index.js +0 -1190
- package/dist/6972.index.js +0 -167
- package/dist/6995.index.js +0 -163
- package/dist/7249.index.js +0 -191
- package/dist/7372.index.js +0 -1197
- package/dist/7546.index.js +0 -541
- package/dist/76.index.js +0 -448
- package/dist/7879.index.js +0 -163
- package/dist/8025.index.js +0 -130
- package/dist/8054.index.js +0 -248
- package/dist/808.index.js +0 -1330
- package/dist/8097.index.js +0 -201
- package/dist/8126.index.js +0 -191
- package/dist/8178.index.js +0 -78
- package/dist/8382.index.js +0 -93
- package/dist/8408.index.js +0 -261
- package/dist/8540.index.js +0 -174
- package/dist/8635.index.js +0 -802
- package/dist/8688.index.js +0 -410
- package/dist/8699.index.js +0 -779
- package/dist/8891.index.js +0 -457
- package/dist/8916.index.js +0 -885
- package/dist/9146.index.js +0 -170
- package/dist/9168.index.js +0 -841
- package/dist/9292.index.js +0 -369
- package/dist/9422.index.js +0 -866
- package/dist/9440.index.js +0 -453
- package/dist/9479.index.js +0 -152
- package/dist/9481.index.js +0 -220
- package/dist/9539.index.js +0 -100
- package/dist/9547.index.js +0 -103
- package/dist/9692.index.js +0 -90
- package/dist/9800.index.js +0 -462
- package/dist/9899.index.js +0 -138
- package/dist/9981.index.js +0 -206
- package/dist/999.index.js +0 -117
- package/dist/9999.index.js +0 -1698
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
//
|
|
2
|
+
// control-credit.js: which controls protect which witness, and which do not (DAG-04, R04.5 / R04.6).
|
|
3
|
+
//
|
|
4
|
+
// A control earns credit on a witness only when ALL of these hold, each recorded as evidence:
|
|
5
|
+
// * the sanitizer's catalog family matches the SINK family (an HTML encoder does not protect SQL);
|
|
6
|
+
// * the value that reaches the sink is the value the control produced: the control's call sits ON the
|
|
7
|
+
// witness (an edge at its node), its result is what the witness carries, and every later read of the
|
|
8
|
+
// controlled variable sees exactly that definition (reassignment or a merge with another definition
|
|
9
|
+
// voids the credit: stale credit is refused);
|
|
10
|
+
// * nothing later on the same witness reverses it (decoders keyed by family, from sanitizer-gate.js).
|
|
11
|
+
//
|
|
12
|
+
// What this does NOT establish: that the sanitizer is correct. The family comes from the sanitizer
|
|
13
|
+
// catalog, and dominance (a control on every path) is evidence about coverage, never proof of sanitizer
|
|
14
|
+
// semantics. Every credit says so (`semanticsProof: false`). An unrecognized call that widened the value
|
|
15
|
+
// (an opaque call) makes the witness `unknown`, not `unprotected` and never `protected`.
|
|
16
|
+
//
|
|
17
|
+
// Control state per witness: 'protected' | 'unprotected' | 'unknown'. `assessAllPathsProtectedWithControls`
|
|
18
|
+
// feeds the real states into witness-selection.js `assessAllPathsProtected`, whose rules are unchanged:
|
|
19
|
+
// it looks at every enumerated path, refuses on a truncated or resumed query, and one unprotected path
|
|
20
|
+
// refutes the claim. An optional independent abstract proof (`abstractControlProof`) can license the claim
|
|
21
|
+
// when enumeration was truncated, because it does not depend on which paths were enumerated.
|
|
22
|
+
|
|
23
|
+
import { CATALOG } from '../dataflow/catalog.js';
|
|
24
|
+
import { accessPathOf } from '../dataflow/access-paths.js';
|
|
25
|
+
import { familyOfFinding, _reversedOnPath } from '../dataflow/sanitizer-gate.js';
|
|
26
|
+
import { computeDominators } from '../ir/ssa.js';
|
|
27
|
+
import { computeReachingDefinitions } from './value-versions.js';
|
|
28
|
+
import { exprRoots, walkExpr } from './source-seeding.js';
|
|
29
|
+
import { assessAllPathsProtected } from './witness-selection.js';
|
|
30
|
+
import { recognizeTransformation } from './transform-catalog.js';
|
|
31
|
+
|
|
32
|
+
export const CONTROL_CREDIT_VERSION = 'control-credit/1';
|
|
33
|
+
|
|
34
|
+
const CREDIT_LIMITATIONS = Object.freeze([
|
|
35
|
+
'Sanitizer semantics come from the catalog family; correctness of the sanitizer itself is not proven.',
|
|
36
|
+
'Dominance or presence on the path is coverage evidence, not proof that the control neutralizes the sink.',
|
|
37
|
+
'Controls are matched by callee name; a project-local wrapper is an opaque call.',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
let _index = null;
|
|
41
|
+
/** callee -> list of family sets, one per catalog entry (kept apart so name ambiguity is detectable). */
|
|
42
|
+
function sanitizerIndex() {
|
|
43
|
+
if (_index) return _index;
|
|
44
|
+
_index = new Map();
|
|
45
|
+
for (const e of CATALOG) {
|
|
46
|
+
if (!e || e.kind !== 'sanitizer' || e.match?.type !== 'call' || !e.match.callee) continue;
|
|
47
|
+
const fams = Array.isArray(e.appliesTo) ? e.appliesTo : [];
|
|
48
|
+
if (!_index.has(e.match.callee)) _index.set(e.match.callee, []);
|
|
49
|
+
_index.get(e.match.callee).push(new Set(fams));
|
|
50
|
+
}
|
|
51
|
+
return _index;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function dotted(callee) {
|
|
55
|
+
if (typeof callee === 'string') return callee;
|
|
56
|
+
if (!callee) return null;
|
|
57
|
+
if (callee.kind === 'ident') return callee.name;
|
|
58
|
+
if (callee.kind === 'member') { const o = dotted(callee.object); return o ? `${o}.${callee.prop}` : callee.prop; }
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Names a call may be known by, most specific first. */
|
|
63
|
+
export function calleeNames(callee) {
|
|
64
|
+
const full = dotted(callee);
|
|
65
|
+
if (!full) return [];
|
|
66
|
+
const bare = full.split('.').pop();
|
|
67
|
+
return bare === full ? [full] : [full, bare];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The families a callee neutralizes: { known, families:Set, ambiguous }. `ambiguous` means the callee name
|
|
72
|
+
* is shared by catalog entries that disagree on families (a bare `escape`), so it cannot be credited.
|
|
73
|
+
*/
|
|
74
|
+
export function controlFamiliesOf(callee) {
|
|
75
|
+
const idx = sanitizerIndex();
|
|
76
|
+
for (const nm of calleeNames(callee)) {
|
|
77
|
+
const sets = idx.get(nm);
|
|
78
|
+
if (!sets) continue;
|
|
79
|
+
const key = (s) => [...s].sort().join(',');
|
|
80
|
+
const distinct = new Set(sets.map(key));
|
|
81
|
+
if (distinct.size > 1) return { known: true, name: nm, families: new Set(sets.flatMap((s) => [...s])), ambiguous: true };
|
|
82
|
+
return { known: true, name: nm, families: new Set(sets[0]), ambiguous: false };
|
|
83
|
+
}
|
|
84
|
+
return { known: false, name: null, families: new Set(), ambiguous: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The sink family for a catalog sink entry (`{ vuln: { cwe, name } }`) or a finding-shaped object. */
|
|
88
|
+
export function sinkFamilyOf(x) {
|
|
89
|
+
if (!x) return null;
|
|
90
|
+
const v = x.vuln && typeof x.vuln === 'object' ? x.vuln : x;
|
|
91
|
+
return familyOfFinding({ cwe: v.cwe ?? x.cwe, vuln: typeof v.name === 'string' ? v.name : (typeof x.vuln === 'string' ? x.vuln : ''), family: x.family });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function callsAt(node) {
|
|
95
|
+
const calls = [];
|
|
96
|
+
if (!node) return calls;
|
|
97
|
+
for (const root of exprRoots(node)) walkExpr(root, (e) => { if (e.kind === 'call') calls.push(e); });
|
|
98
|
+
if (node.kind === 'call' && node.callee) calls.unshift({ kind: 'call', callee: node.callee, args: node.args ?? [] });
|
|
99
|
+
return calls;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const covers = (a, b) => typeof a === 'string' && typeof b === 'string' && (a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`));
|
|
103
|
+
|
|
104
|
+
function argReadsPath(call, fromPath) {
|
|
105
|
+
if (!fromPath) return false;
|
|
106
|
+
let hit = false;
|
|
107
|
+
for (const a of call.args ?? []) {
|
|
108
|
+
walkExpr(a, (e) => { if (!hit && (e.kind === 'ident' || e.kind === 'member')) { const p = e.kind === 'ident' ? e.name : accessPathOf(e); if (covers(p, fromPath)) hit = true; } });
|
|
109
|
+
}
|
|
110
|
+
return hit;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const _rdCache = new WeakMap();
|
|
114
|
+
function rdOf(fn) {
|
|
115
|
+
const cfg = fn?.cfg;
|
|
116
|
+
if (!cfg) return null;
|
|
117
|
+
let rd = _rdCache.get(cfg);
|
|
118
|
+
if (!rd) { rd = computeReachingDefinitions({ cfg: { entry: cfg.entry, nodes: cfg.nodes }, params: fn.params ?? [] }); _rdCache.set(cfg, rd); }
|
|
119
|
+
return rd;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isWidened(h) {
|
|
123
|
+
return (h.widenReasons ?? []).includes('unresolved-call') || (h.annotations ?? []).some((a) => a.widenReason === 'unresolved-call');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Judge one witness.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} o
|
|
130
|
+
* @param {object[]} o.hops `path.hops`, source first
|
|
131
|
+
* @param {(id:string)=>object|null} o.nodeOf store.getNode (to read each hop's from/to access paths)
|
|
132
|
+
* @param {(qid:string)=>object|null} o.fnOf IR function lookup
|
|
133
|
+
* @param {string|null} o.sinkFamily the family of the sink (see sinkFamilyOf), null when unknown
|
|
134
|
+
* @param {boolean} [o.truncated] the path is incomplete or analysis was cut short
|
|
135
|
+
*/
|
|
136
|
+
export function creditPath({ hops, nodeOf, fnOf, sinkFamily = null, truncated = false } = {}) {
|
|
137
|
+
const list = Array.isArray(hops) ? hops : [];
|
|
138
|
+
const credits = []; const rejected = []; const opaque = [];
|
|
139
|
+
const pathOf = (id) => nodeOf?.(id)?.path ?? null;
|
|
140
|
+
|
|
141
|
+
for (let i = 0; i < list.length; i++) {
|
|
142
|
+
const h = list[i];
|
|
143
|
+
const fn = fnOf?.(h.scope);
|
|
144
|
+
const node = fn?.cfg?.nodes?.[h.siteNodeId];
|
|
145
|
+
if (!node) continue;
|
|
146
|
+
const fromPath = pathOf(h.fromNodeId);
|
|
147
|
+
const calls = callsAt(node);
|
|
148
|
+
let recognizedHere = false;
|
|
149
|
+
|
|
150
|
+
for (const c of calls) {
|
|
151
|
+
const fam = controlFamiliesOf(c.callee);
|
|
152
|
+
if (!fam.known) continue;
|
|
153
|
+
recognizedHere = true;
|
|
154
|
+
const where = { edgeId: h.edgeId ?? null, scope: h.scope, nodeId: h.siteNodeId, callee: fam.name, families: [...fam.families].sort() };
|
|
155
|
+
if (!argReadsPath(c, fromPath)) { rejected.push({ ...where, reason: 'value-not-through-control' }); continue; }
|
|
156
|
+
// The control's result must be what the witness carries.
|
|
157
|
+
const viaAssign = node.kind === 'assign' && node.source === c && typeof node.target === 'string';
|
|
158
|
+
const viaSinkArg = node.kind === 'call' && (node.args ?? []).includes(c);
|
|
159
|
+
if (!viaAssign && !viaSinkArg) { rejected.push({ ...where, reason: 'result-not-carried' }); continue; }
|
|
160
|
+
if (fam.ambiguous) { rejected.push({ ...where, reason: 'ambiguous-callee-family' }); opaque.push({ ...where, reason: 'ambiguous-callee-family' }); continue; }
|
|
161
|
+
if (!sinkFamily) { rejected.push({ ...where, reason: 'sink-family-unknown' }); opaque.push({ ...where, reason: 'sink-family-unknown' }); continue; }
|
|
162
|
+
if (!fam.families.has(sinkFamily) && !fam.families.has('*')) { rejected.push({ ...where, reason: 'wrong-family', sinkFamily }); continue; }
|
|
163
|
+
|
|
164
|
+
// Value version: later reads of the controlled variable must see exactly this definition.
|
|
165
|
+
let versionOk = true; let versionReason = null;
|
|
166
|
+
if (viaAssign) {
|
|
167
|
+
const T = node.target;
|
|
168
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
169
|
+
const hj = list[j];
|
|
170
|
+
if (hj.scope !== h.scope) continue;
|
|
171
|
+
const fj = pathOf(hj.fromNodeId);
|
|
172
|
+
if (!covers(fj, T)) continue;
|
|
173
|
+
const rd = rdOf(fnOf(hj.scope));
|
|
174
|
+
const vers = rd ? rd.versionsAt(hj.siteNodeId, T) : [];
|
|
175
|
+
if (!vers.includes(h.siteNodeId)) { versionOk = false; versionReason = 'stale-control-version'; break; }
|
|
176
|
+
if (vers.length > 1) { versionOk = false; versionReason = 'merged-with-other-definition'; break; }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!versionOk) { rejected.push({ ...where, reason: versionReason }); if (versionReason === 'merged-with-other-definition') opaque.push({ ...where, reason: versionReason }); continue; }
|
|
180
|
+
|
|
181
|
+
// Reversal later on the same witness voids the credit.
|
|
182
|
+
let reversedBy = null;
|
|
183
|
+
for (let j = i + 1; j < list.length && !reversedBy; j++) {
|
|
184
|
+
const nj = fnOf?.(list[j].scope)?.cfg?.nodes?.[list[j].siteNodeId];
|
|
185
|
+
for (const cj of callsAt(nj)) {
|
|
186
|
+
const hit = _reversedOnPath(calleeNames(cj.callee), sinkFamily);
|
|
187
|
+
if (hit) { reversedBy = hit; break; }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (reversedBy) { rejected.push({ ...where, reason: `reversed:${reversedBy}` }); continue; }
|
|
191
|
+
|
|
192
|
+
credits.push({
|
|
193
|
+
...where, sinkFamily, valueVersion: viaAssign ? h.siteNodeId : 'at-sink-argument',
|
|
194
|
+
basis: ['catalog-family', 'on-witness', 'value-version'], semanticsProof: false,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A parameterized query is the SQL-specific control that is not a call to a sanitizer: the value enters as a
|
|
199
|
+
// bound parameter and never as query text. Credited only for the sql sink family.
|
|
200
|
+
if (sinkFamily === 'sql' && isParameterizedSqlCall(node, fromPath)) {
|
|
201
|
+
credits.push({
|
|
202
|
+
edgeId: h.edgeId ?? null, scope: h.scope, nodeId: h.siteNodeId, callee: 'parameterized-query', families: ['sql'], sinkFamily,
|
|
203
|
+
valueVersion: 'bound-parameter', basis: ['placeholder-binding', 'on-witness'], semanticsProof: false,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// An unrecognized call that widened the value could be a control; the witness cannot be called unprotected.
|
|
208
|
+
if (!recognizedHere && isWidened(h)) {
|
|
209
|
+
const names = calls.map((c) => dotted(c.callee)).filter(Boolean);
|
|
210
|
+
// A known decoder is not opaque: it is a recognized reversal (and is judged as such above).
|
|
211
|
+
const known = calls.length > 0 && calls.every((c) => controlFamiliesOf(c.callee).known
|
|
212
|
+
|| recognizeTransformation(callDescriptor(c.callee))
|
|
213
|
+
|| (sinkFamily && _reversedOnPath(calleeNames(c.callee), sinkFamily)));
|
|
214
|
+
if (!known) opaque.push({ edgeId: h.edgeId ?? null, scope: h.scope, nodeId: h.siteNodeId, callees: names, reason: 'opaque-call' });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let controlState;
|
|
219
|
+
const reasons = [];
|
|
220
|
+
if (credits.length) { controlState = 'protected'; reasons.push('matching-control-on-witness'); }
|
|
221
|
+
else if (opaque.length) { controlState = 'unknown'; reasons.push(...new Set(opaque.map((o) => o.reason))); }
|
|
222
|
+
else if (truncated) { controlState = 'unknown'; reasons.push('witness-incomplete'); }
|
|
223
|
+
else { controlState = 'unprotected'; reasons.push(rejected.length ? [...new Set(rejected.map((r) => r.reason))].sort().join(',') : 'no-control-on-witness'); }
|
|
224
|
+
return {
|
|
225
|
+
version: CONTROL_CREDIT_VERSION, controlState, reasons,
|
|
226
|
+
credits, rejected, opaque, sinkFamily, limitations: [...CREDIT_LIMITATIONS],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const PLACEHOLDER_RE = /(\?|\$\d+|:[A-Za-z_]\w*|%s|@[A-Za-z_]\w*)/;
|
|
231
|
+
|
|
232
|
+
/** `db.query('... WHERE id = ?', [value])`: a literal query text with placeholders, the witness value only in later arguments. */
|
|
233
|
+
export function isParameterizedSqlCall(node, fromPath) {
|
|
234
|
+
if (!node || node.kind !== 'call') return false;
|
|
235
|
+
const args = node.args ?? [];
|
|
236
|
+
const text = args[0];
|
|
237
|
+
if (args.length < 2 || !text || text.kind !== 'literal' || typeof text.value !== 'string' || !PLACEHOLDER_RE.test(text.value)) return false;
|
|
238
|
+
return argReadsPath({ args: args.slice(1) }, fromPath);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function callDescriptor(callee) {
|
|
242
|
+
if (typeof callee === 'string') return { type: 'call', callee };
|
|
243
|
+
if (!callee) return null;
|
|
244
|
+
if (callee.kind === 'ident') return { type: 'call', callee: callee.name };
|
|
245
|
+
if (callee.kind === 'member' && callee.object?.kind === 'ident') return { type: 'member-call', object: callee.object.name, method: callee.prop };
|
|
246
|
+
if (callee.kind === 'member') return { type: 'call', callee: callee.prop };
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* An independent, intraprocedural abstract proof that a matching control covers EVERY path to a sink
|
|
252
|
+
* argument: a control assignment dominates the sink node and is the only definition of the variable that
|
|
253
|
+
* reaches it. It does not depend on which witnesses were enumerated. It is still evidence about coverage:
|
|
254
|
+
* the control's correctness comes from the catalog family.
|
|
255
|
+
*/
|
|
256
|
+
export function abstractControlProof(fn, { sinkNodeId, variable, sinkFamily }) {
|
|
257
|
+
const cfg = fn?.cfg;
|
|
258
|
+
if (!cfg || !sinkFamily || !variable) return { proved: false, reason: 'insufficient-input', semanticsProof: false };
|
|
259
|
+
const nodes = cfg.nodes instanceof Map ? Object.fromEntries(cfg.nodes) : cfg.nodes;
|
|
260
|
+
if (!nodes[sinkNodeId]) return { proved: false, reason: 'unknown-sink-node', semanticsProof: false };
|
|
261
|
+
const dom = computeDominators({ entry: cfg.entry, nodes });
|
|
262
|
+
const rd = rdOf(fn);
|
|
263
|
+
const vers = rd.versionsAt(sinkNodeId, variable);
|
|
264
|
+
if (vers.length !== 1) return { proved: false, reason: 'multiple-reaching-definitions', semanticsProof: false };
|
|
265
|
+
const defId = vers[0];
|
|
266
|
+
const def = nodes[defId];
|
|
267
|
+
if (!def || def.kind !== 'assign' || !dom.get(sinkNodeId)?.has(defId)) return { proved: false, reason: 'definition-does-not-dominate-sink', semanticsProof: false };
|
|
268
|
+
const call = def.source?.kind === 'call' ? def.source : null;
|
|
269
|
+
const fam = call ? controlFamiliesOf(call.callee) : null;
|
|
270
|
+
if (!fam?.known || fam.ambiguous) return { proved: false, reason: 'definition-is-not-a-recognized-control', semanticsProof: false };
|
|
271
|
+
if (!fam.families.has(sinkFamily) && !fam.families.has('*')) return { proved: false, reason: 'wrong-family', semanticsProof: false };
|
|
272
|
+
return {
|
|
273
|
+
proved: true, controlNodeId: defId, callee: fam.name, sinkFamily, basis: ['dominance', 'single-reaching-definition', 'catalog-family'],
|
|
274
|
+
semanticsProof: false, limitations: [...CREDIT_LIMITATIONS, 'Intraprocedural: other functions that reach the same sink are not covered by this proof.'],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* "Every path is protected", answered from real control states (R04.6).
|
|
280
|
+
*
|
|
281
|
+
* @param {object} o
|
|
282
|
+
* @param {object} o.result the reconstructPaths result
|
|
283
|
+
* @param {object[]} o.enumerated every enumerated path (not just the displayed ones), with `.hops`
|
|
284
|
+
* @param {Map<string,object>|((p:object)=>object)} o.evidenceOf per-path { controlState, feasibility? }
|
|
285
|
+
* @param {object} [o.proof] abstractControlProof result, an independent licence for the claim
|
|
286
|
+
*
|
|
287
|
+
* Paths whose feasibility is `infeasible-under-model` are excluded from the relevant set and LISTED, so the
|
|
288
|
+
* claim is explicitly scoped to the modeled feasible paths. unknown, unsupported and timeout paths stay
|
|
289
|
+
* relevant: they cannot be dismissed. The claim never rests on the displayed sample.
|
|
290
|
+
*/
|
|
291
|
+
export function assessAllPathsProtectedWithControls({ result, enumerated, evidenceOf, proof = null }) {
|
|
292
|
+
const get = (p) => (typeof evidenceOf === 'function' ? evidenceOf(p) : evidenceOf?.get?.(p.id)) ?? null;
|
|
293
|
+
const all = Array.isArray(enumerated) ? enumerated : [];
|
|
294
|
+
const excludedInfeasible = []; const relevant = [];
|
|
295
|
+
for (const p of all) {
|
|
296
|
+
const ev = get(p);
|
|
297
|
+
if (ev?.feasibility?.status === 'infeasible-under-model') excludedInfeasible.push({ pathId: p.id, contradiction: ev.feasibility.contradiction ?? null });
|
|
298
|
+
else relevant.push(p);
|
|
299
|
+
}
|
|
300
|
+
const attrsOf = (p) => ({ controlState: get(p)?.controlState ?? 'unknown' });
|
|
301
|
+
const base = assessAllPathsProtected({ result, enumerated: relevant, attrsOf });
|
|
302
|
+
const out = {
|
|
303
|
+
...base, semanticsVersion: CONTROL_CREDIT_VERSION, claimScope: 'modeled-feasible-paths',
|
|
304
|
+
relevantPathCount: relevant.length, excludedInfeasible, basis: 'enumeration', abstractProof: proof ?? null,
|
|
305
|
+
};
|
|
306
|
+
if (base.holds === false) return out;
|
|
307
|
+
// Enumeration could not establish the claim (truncated, resumed, or unknown states). An independent abstract
|
|
308
|
+
// proof can, because it does not depend on which paths were enumerated. A refutation above always wins.
|
|
309
|
+
if (!base.allowed && proof?.proved) {
|
|
310
|
+
return { ...out, allowed: true, holds: true, basis: 'abstract-proof', reasons: base.reasons.filter((r) => r !== 'refuted-by-unprotected-path'), proofCovers: 'every path to the sink argument within the function' };
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
//
|
|
2
|
+
// correlation.js: provenance correlation version 2 (DAG-03).
|
|
3
|
+
//
|
|
4
|
+
// Version 1 pairs the production and consumption halves of a hop at a join
|
|
5
|
+
// key (scope, nodeId, dataElementId, context) and marks the pairings it
|
|
6
|
+
// cannot tell apart as `ambiguousCorrelation`. Version 2 adds evidence the
|
|
7
|
+
// engine had at the moment it emitted each half, so that halves pair only
|
|
8
|
+
// when that evidence proves them compatible:
|
|
9
|
+
//
|
|
10
|
+
// derivationId where inside the CFG node's expression tree an in-half
|
|
11
|
+
// was produced (a segment path such as `/src/p:left/a0`)
|
|
12
|
+
// derivationScope the part of that tree an out-half consumes
|
|
13
|
+
// relPath the field path of an in-half relative to the value it
|
|
14
|
+
// is selected from, and of an out-half relative to the
|
|
15
|
+
// value it writes
|
|
16
|
+
// fromVersions the definitions of the read path that reach the read
|
|
17
|
+
// toVersion the definition a write creates
|
|
18
|
+
// invocationId one id per call expression instance
|
|
19
|
+
// callRole 'arg' | 'bind' | 'return'
|
|
20
|
+
// argPosition / formalPosition actual and formal argument positions
|
|
21
|
+
//
|
|
22
|
+
// Version 2 is OFF by default. It is enabled per call site by the
|
|
23
|
+
// `correlationVersion: 2` option or process-wide by
|
|
24
|
+
// AGENTIC_SECURITY_LINEAGE_CORRELATION=2. With it off no module in this
|
|
25
|
+
// package emits, reads or hashes any of the fields above, so version 1
|
|
26
|
+
// output, ids and digests are byte-identical to the pre-DAG-03 behavior.
|
|
27
|
+
//
|
|
28
|
+
// A hop that lacks the evidence (a hand-built stream, an older recorder, an
|
|
29
|
+
// expression kind that carries no derivation) is never pruned: it pairs the
|
|
30
|
+
// way version 1 paired it and the edge says so (`correlation:
|
|
31
|
+
// 'legacy-unknown'`). Evidence only ever removes pairings that are proven
|
|
32
|
+
// incompatible; it never removes a marked edge to make a metric look better.
|
|
33
|
+
//
|
|
34
|
+
import * as crypto from 'node:crypto';
|
|
35
|
+
import { provenanceNodeId, provenanceEdgeId } from './ids.js';
|
|
36
|
+
|
|
37
|
+
export const CORRELATION_VERSION_1 = 1;
|
|
38
|
+
export const CORRELATION_VERSION_2 = 2;
|
|
39
|
+
|
|
40
|
+
/** Additive hop fields version 2 adds to the 14 version 1 fields. */
|
|
41
|
+
export const V2_HOP_FIELDS = [
|
|
42
|
+
'derivationId', 'derivationScope', 'relPath', 'fromVersions', 'toVersion',
|
|
43
|
+
'invocationId', 'callRole', 'argPosition', 'formalPosition', 'allocKind', 'receiver',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Default bound on the call-context stack a balanced traversal tracks. */
|
|
47
|
+
export const DEFAULT_MAX_CALL_DEPTH = 8;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolves the effective correlation version. An explicit option wins over
|
|
51
|
+
* the environment; anything other than 2 (or '2') is version 1.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveCorrelationVersion(option) {
|
|
54
|
+
if (option !== undefined && option !== null) {
|
|
55
|
+
return Number(option) === CORRELATION_VERSION_2 ? CORRELATION_VERSION_2 : CORRELATION_VERSION_1;
|
|
56
|
+
}
|
|
57
|
+
return process.env.AGENTIC_SECURITY_LINEAGE_CORRELATION === '2' ? CORRELATION_VERSION_2 : CORRELATION_VERSION_1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- derivation ids ---------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/** Appends one segment to a derivation path. */
|
|
63
|
+
export function deriveChild(parent, seg) {
|
|
64
|
+
return `${parent ?? ''}/${seg}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Is `scope` a segment-aligned prefix of (or equal to) `id`? An out-half
|
|
69
|
+
* whose scope is `/src/p:left` consumes `/src/p:left` and everything under
|
|
70
|
+
* it, but not `/src/p:leftover`.
|
|
71
|
+
*/
|
|
72
|
+
export function derivationCovers(scope, id) {
|
|
73
|
+
if (scope === '' || scope === null || scope === undefined) return true;
|
|
74
|
+
return id === scope || id.startsWith(`${scope}/`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* For an out-half that writes the byPath entry `subPath` of a value built by
|
|
79
|
+
* `expr`, returns the derivation scope that entry was produced under and the
|
|
80
|
+
* part of `subPath` left over below that scope. Only an object literal names
|
|
81
|
+
* its properties; every other expression kind keeps the root scope, which
|
|
82
|
+
* is the version 1 behavior (all in-halves of the node are candidates).
|
|
83
|
+
*/
|
|
84
|
+
export function scopeForSubPath(expr, subPath, rootDerivation) {
|
|
85
|
+
let scope = rootDerivation ?? '';
|
|
86
|
+
let rest = subPath ?? '';
|
|
87
|
+
let cur = expr;
|
|
88
|
+
while (cur && cur.kind === 'object' && rest !== '') {
|
|
89
|
+
const dot = rest.indexOf('.');
|
|
90
|
+
const head = dot === -1 ? rest : rest.slice(0, dot);
|
|
91
|
+
const tail = dot === -1 ? '' : rest.slice(dot + 1);
|
|
92
|
+
const props = (cur.props ?? []).filter((p) => !p.spread && p.key === head);
|
|
93
|
+
if (props.length !== 1) break; // absent, duplicated or computed: keep the wider scope
|
|
94
|
+
scope = deriveChild(scope, `p:${head}`);
|
|
95
|
+
rest = tail;
|
|
96
|
+
cur = props[0].value;
|
|
97
|
+
}
|
|
98
|
+
return { scope, rest };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The field path of `key` relative to the queried path (empty when the key is the path or an ancestor). */
|
|
102
|
+
export function relPathOf(key, path) {
|
|
103
|
+
if (typeof key !== 'string' || typeof path !== 'string') return null;
|
|
104
|
+
return key.length > path.length && key.startsWith(`${path}.`) ? key.slice(path.length + 1) : '';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One id per call expression instance in a function context. */
|
|
108
|
+
export function invocationIdOf({ scope, nodeId, context, derivation }) {
|
|
109
|
+
const h = crypto.createHash('sha256')
|
|
110
|
+
.update([scope, nodeId, context, derivation].map((p) => (p === null || p === undefined ? '' : String(p))).join('|'))
|
|
111
|
+
.digest('hex').slice(0, 12);
|
|
112
|
+
return `inv:${h}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Two relative field paths disagree only when BOTH name a field and the names
|
|
116
|
+
// differ. An empty path is a container-level fact, which applies to every
|
|
117
|
+
// field of the container, so it never disagrees: pruning on it would drop a
|
|
118
|
+
// real edge.
|
|
119
|
+
function fieldsDisagree(a, b) {
|
|
120
|
+
return typeof a === 'string' && typeof b === 'string' && a !== '' && b !== '' && a !== b;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- pairing -----------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Are an in-half `s` and an out-half `o` of one group compatible under
|
|
127
|
+
* version 2? Both arguments are `{hop}` wrappers. `true` means "pair them";
|
|
128
|
+
* `evidence` says whether the pairing is proven by ids on both sides.
|
|
129
|
+
*/
|
|
130
|
+
export function pairCompatibility(sHop, oHop) {
|
|
131
|
+
// A call argument feeds exactly the parameter binding of the same
|
|
132
|
+
// invocation and position. It never reaches the assignment that receives
|
|
133
|
+
// the call's result, because the value goes through the callee.
|
|
134
|
+
if (sHop.callRole === 'arg') {
|
|
135
|
+
const ok = oHop.callRole === 'bind'
|
|
136
|
+
&& oHop.invocationId === sHop.invocationId
|
|
137
|
+
&& oHop.formalPosition === sHop.argPosition
|
|
138
|
+
&& !fieldsDisagree(sHop.relPath, oHop.relPath);
|
|
139
|
+
return { compatible: ok, proven: true };
|
|
140
|
+
}
|
|
141
|
+
if (oHop.callRole === 'bind') return { compatible: false, proven: true };
|
|
142
|
+
if (sHop.derivationId != null && oHop.derivationScope != null) {
|
|
143
|
+
if (!derivationCovers(oHop.derivationScope, sHop.derivationId)) return { compatible: false, proven: true };
|
|
144
|
+
if (fieldsDisagree(sHop.relPath, oHop.relPath)) return { compatible: false, proven: true };
|
|
145
|
+
return { compatible: true, proven: true };
|
|
146
|
+
}
|
|
147
|
+
return { compatible: true, proven: false };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- crosswalk ---------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Version 1 to version 2 crosswalk for a built version 2 store. A version 1
|
|
154
|
+
* id names a (scope, context, path, data element) location; the version 2
|
|
155
|
+
* ids under it are the value versions of that location. An edge crosswalks
|
|
156
|
+
* the same way through its endpoints.
|
|
157
|
+
*
|
|
158
|
+
* { version: 2, nodes: [{ v1, v2: [ids] }], edges: [{ v1, v2: [ids] }],
|
|
159
|
+
* unmapped: { nodes: [...], edges: [...] } }
|
|
160
|
+
*
|
|
161
|
+
* Every version 2 id appears under exactly one version 1 id, so the
|
|
162
|
+
* crosswalk is total over the store; `unmapped` is always empty for a
|
|
163
|
+
* correct store and exists so a test can assert that.
|
|
164
|
+
*/
|
|
165
|
+
export function buildCrosswalk(store) {
|
|
166
|
+
const nodes = store.nodes();
|
|
167
|
+
const edges = store.edges();
|
|
168
|
+
const v1OfNode = new Map();
|
|
169
|
+
const nodeGroups = new Map();
|
|
170
|
+
for (const n of nodes) {
|
|
171
|
+
const v1 = provenanceNodeId({
|
|
172
|
+
kind: n.kind, scope: n.scope, context: n.context, path: n.path,
|
|
173
|
+
siteNodeId: n.siteNodeId, dataElementId: n.dataElementId,
|
|
174
|
+
});
|
|
175
|
+
v1OfNode.set(n.id, v1);
|
|
176
|
+
if (!nodeGroups.has(v1)) nodeGroups.set(v1, []);
|
|
177
|
+
nodeGroups.get(v1).push(n.id);
|
|
178
|
+
}
|
|
179
|
+
const edgeGroups = new Map();
|
|
180
|
+
for (const e of edges) {
|
|
181
|
+
const v1 = provenanceEdgeId({
|
|
182
|
+
fromNodeId: v1OfNode.get(e.fromNodeId), toNodeId: v1OfNode.get(e.toNodeId),
|
|
183
|
+
dataElementId: e.dataElementId, scope: e.scope, context: e.context, siteNodeId: e.siteNodeId,
|
|
184
|
+
inKind: e.inKind, inSubKind: e.inSubKind, outKind: e.outKind, outSubKind: e.outSubKind,
|
|
185
|
+
widenReasons: e.widenReasons, lossReasons: e.lossReasons,
|
|
186
|
+
});
|
|
187
|
+
if (!edgeGroups.has(v1)) edgeGroups.set(v1, []);
|
|
188
|
+
edgeGroups.get(v1).push(e.id);
|
|
189
|
+
}
|
|
190
|
+
const toRows = (m) => [...m.entries()]
|
|
191
|
+
.map(([v1, v2]) => ({ v1, v2: [...new Set(v2)].sort() }))
|
|
192
|
+
.sort((a, b) => (a.v1 < b.v1 ? -1 : a.v1 > b.v1 ? 1 : 0));
|
|
193
|
+
return {
|
|
194
|
+
version: CORRELATION_VERSION_2,
|
|
195
|
+
nodes: toRows(nodeGroups),
|
|
196
|
+
edges: toRows(edgeGroups),
|
|
197
|
+
unmapped: { nodes: [], edges: [] },
|
|
198
|
+
};
|
|
199
|
+
}
|
package/src/lineage/coverage.js
CHANGED
|
@@ -442,7 +442,14 @@ export function buildCoverageLedger(built, opts = {}) {
|
|
|
442
442
|
paths: { enumerated: built.stats.pathsEnumerated, projected: built.stats.pathsProjected, truncatedQueries: built.stats.truncatedQueries },
|
|
443
443
|
budgets: { ...PATH_QUERY_DEFAULTS, ...(opts.budget ?? {}) },
|
|
444
444
|
|
|
445
|
-
provenance: {
|
|
445
|
+
provenance: {
|
|
446
|
+
hops: built.hops.length, pnodes: storeStats.nodes, pedges: storeStats.edges,
|
|
447
|
+
...(storeStats.correlationVersion ? { correlationVersion: storeStats.correlationVersion } : null),
|
|
448
|
+
},
|
|
449
|
+
// DAG-07: present only when some function stopped early, so a complete
|
|
450
|
+
// run's ledger (and its digest) is unchanged.
|
|
451
|
+
...(built.analysisCompletion && built.analysisCompletion.status !== 'complete'
|
|
452
|
+
? { analysisCompletion: built.analysisCompletion } : {}),
|
|
446
453
|
};
|
|
447
454
|
}
|
|
448
455
|
|
|
@@ -127,3 +127,25 @@ export function validateCrossRepoLink(record) {
|
|
|
127
127
|
|
|
128
128
|
return { valid: errors.length === 0, errors };
|
|
129
129
|
}
|
|
130
|
+
|
|
131
|
+
// DAG-08: attributable link evidence. A CrossRepoLink is an operator assertion, so as evidence it is a `manual-assertion`: it is
|
|
132
|
+
// not foreign-graph validation, a digest check, a code mapping or an observed connection, and nothing here can turn it into one.
|
|
133
|
+
// The Haskell/Nix analysis scope declared on either endpoint is carried through unchanged, so a link to static Nix evidence stays
|
|
134
|
+
// static-configuration evidence.
|
|
135
|
+
export function linkEvidence(link) {
|
|
136
|
+
const scope = {};
|
|
137
|
+
for (const s of ['local', 'remote']) {
|
|
138
|
+
const sc = link && link.scope && link.scope[s];
|
|
139
|
+
if (sc) scope[s] = { target: sc.target ?? null, config: sc.config ?? null, analysis: sc.analysis ?? null, limitations: sc.limitations || [] };
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
kind: 'manual-assertion', declared: true, observed: false, linkId: link && link.id, declaredBy: link && link.declaredBy,
|
|
143
|
+
declaredAt: link && link.declaredAt, provenance: link && link.provenance, scope,
|
|
144
|
+
staticConfigurationOnly: Object.values(scope).some((sc) => sc.analysis && sc.analysis.runtime === false),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** True only for a structurally valid declared link that does not claim to be observed. */
|
|
149
|
+
export function isDeclaredNotObserved(link) {
|
|
150
|
+
return !!link && validateCrossRepoLink(link).valid && !(link.evidence && link.evidence.observed === true);
|
|
151
|
+
}
|