@clear-capabilities/agentic-security-scanner 0.183.1 → 0.185.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 +29 -0
- package/bin/agentic-security.js +88 -0
- package/dist/{2a8456e0800aeee871b6.json → 0b301d45b231f144a1b4.json} +27 -9
- package/dist/1128.index.js +908 -0
- package/dist/1336.index.js +261 -112
- package/dist/1665.index.js +828 -0
- package/dist/2923.index.js +17 -9
- package/dist/9382.index.js +23 -2
- package/dist/9579.index.js +11 -4
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +27 -9
- package/src/context-intelligence/controls.js +15 -0
- package/src/context-intelligence/reachability.js +8 -2
- package/src/dataflow/CLAUDE.md +43 -1
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +929 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- 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/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/discovery/hunter.js +6 -2
- package/src/discovery/index.js +5 -2
- package/src/engine.js +63 -3
- 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/language/haskell-disclosure.js +13 -1
- package/src/lineage/CLAUDE.md +71 -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/observation-adapters.js +17 -9
- 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 +138 -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 +19 -0
- package/src/posture/CLAUDE.md +13 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- package/src/report/index.js +2 -0
|
@@ -67,53 +67,107 @@ export function enumerateSinks(perFileIR, callGraph) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* callGraph: { functions, callers, callees }
|
|
77
|
-
* maxCallerDepth: bounds reverse-call-graph walk
|
|
70
|
+
* Canonical caller index: callee qid -> [caller qid]. Built from each
|
|
71
|
+
* function's recorded `calls` (statement-level call nodes) and resolved with
|
|
72
|
+
* resolveKnownCallee, never the guessing resolve(). The demand selector
|
|
73
|
+
* (demand-query.js) extends it with the embedded call sites that `fn.calls`
|
|
74
|
+
* does not record (assignment right-hand sides, return values, nested
|
|
75
|
+
* arguments, callback registrations).
|
|
78
76
|
*/
|
|
79
|
-
export function
|
|
80
|
-
const reachableFns = new Set();
|
|
81
|
-
if (!Array.isArray(sinks) || !callGraph || !callGraph.functions) return reachableFns;
|
|
82
|
-
|
|
83
|
-
// Build a reverse callgraph: who calls fn-qid?
|
|
77
|
+
export function buildCallerQidIndex(callGraph) {
|
|
84
78
|
const callersOf = new Map();
|
|
79
|
+
if (!callGraph || !callGraph.functions) return callersOf;
|
|
85
80
|
for (const fn of callGraph.functions.values()) {
|
|
86
81
|
for (const callee of (fn.calls || [])) {
|
|
87
|
-
// callee.callee is a SOURCE-LEVEL name ("b.fill");
|
|
88
|
-
//
|
|
89
|
-
// never match. Resolve to a qid first.
|
|
90
|
-
const name = callee && typeof callee === 'object' ? callee.callee : callee;
|
|
91
|
-
if (!name) continue;
|
|
82
|
+
// callee.callee is a SOURCE-LEVEL name ("b.fill"); lookups are by qid,
|
|
83
|
+
// so keying by the name could never match. Resolve to a qid first.
|
|
92
84
|
// resolveKnownCallee (not resolve): callee.callee may be a flattened
|
|
93
85
|
// member-call string ("loader.read"); the plain resolve() would guess
|
|
94
86
|
// via its bare-tail fallback and invent an edge to an unrelated
|
|
95
87
|
// same-named function. See callgraph.js.
|
|
88
|
+
const name = callee && typeof callee === 'object' ? callee.callee : callee;
|
|
89
|
+
if (!name) continue;
|
|
96
90
|
const qid = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(name, fn.file) : null;
|
|
97
91
|
if (!qid) continue;
|
|
98
92
|
if (!callersOf.has(qid)) callersOf.set(qid, []);
|
|
99
93
|
callersOf.get(qid).push(fn.qid);
|
|
100
94
|
}
|
|
101
95
|
}
|
|
96
|
+
return callersOf;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* RHS-lite demand-driven slice. For each sink, walk backward through the
|
|
101
|
+
* intra-procedural CFG via def-use, then ascend via the call graph for
|
|
102
|
+
* each unresolved parameter binding. Returns the set of fn qids that are
|
|
103
|
+
* REACHABLE from the sink (and therefore worth deep-analyzing).
|
|
104
|
+
*
|
|
105
|
+
* sinks: enumerateSinks() output
|
|
106
|
+
* callGraph: { functions, callers, callees }
|
|
107
|
+
* maxCallerDepth: bounds reverse-call-graph walk
|
|
108
|
+
*/
|
|
109
|
+
export function reachabilitySliceFromSinks(sinks, callGraph, maxCallerDepth = MAX_CALLER_DEPTH) {
|
|
110
|
+
return reachabilitySliceWithCompletion(sinks, callGraph, maxCallerDepth).reachable;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Same walk as reachabilitySliceFromSinks, plus a completion record
|
|
115
|
+
* (DAG-07 R07.1). A function the walk never reached is skipped by
|
|
116
|
+
* `shouldAnalyzeUnderRhs`, so a walk that stopped at its worklist cap or its
|
|
117
|
+
* caller-depth ceiling has silently narrowed what is analyzed; the record
|
|
118
|
+
* says so, with the number of callers left unexplored.
|
|
119
|
+
*
|
|
120
|
+
* Returns { reachable: Set<qid>, completion: { status, reasons,
|
|
121
|
+
* unexploredFrontierCount, visited, limits } }.
|
|
122
|
+
*/
|
|
123
|
+
export function reachabilitySliceWithCompletion(sinks, callGraph, maxCallerDepth = MAX_CALLER_DEPTH) {
|
|
124
|
+
const reachableFns = new Set();
|
|
125
|
+
const completion = {
|
|
126
|
+
status: 'complete', reasons: [], unexploredFrontierCount: 0, visited: 0,
|
|
127
|
+
limits: { maxWorklist: MAX_WORKLIST, maxCallerDepth },
|
|
128
|
+
};
|
|
129
|
+
if (!Array.isArray(sinks) || !callGraph || !callGraph.functions) {
|
|
130
|
+
completion.status = 'unavailable';
|
|
131
|
+
completion.reasons = ['no-call-graph'];
|
|
132
|
+
return { reachable: reachableFns, completion };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Build a reverse callgraph: who calls fn-qid?
|
|
136
|
+
// DAG-02: factored into the shared, exported buildCallerQidIndex so the
|
|
137
|
+
// demand selector (demand-query.js) reuses the same canonical incoming-edge
|
|
138
|
+
// index. Behaviour is unchanged.
|
|
139
|
+
const callersOf = buildCallerQidIndex(callGraph);
|
|
102
140
|
|
|
103
141
|
const work = [];
|
|
104
142
|
for (const s of sinks) work.push({ qid: s.fnQid, depth: 0 });
|
|
105
143
|
let visitedCount = 0;
|
|
144
|
+
let depthCut = 0;
|
|
145
|
+
let capped = false;
|
|
106
146
|
while (work.length) {
|
|
107
|
-
if (++visitedCount > MAX_WORKLIST) break;
|
|
147
|
+
if (++visitedCount > MAX_WORKLIST) { capped = true; break; }
|
|
108
148
|
const { qid, depth } = work.shift();
|
|
109
149
|
if (reachableFns.has(qid)) continue;
|
|
110
150
|
reachableFns.add(qid);
|
|
111
|
-
if (depth >= maxCallerDepth)
|
|
151
|
+
if (depth >= maxCallerDepth) {
|
|
152
|
+
// Callers of this function exist but are not explored.
|
|
153
|
+
for (const callerQid of (callersOf.get(qid) || [])) if (!reachableFns.has(callerQid)) depthCut += 1;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
112
156
|
for (const callerQid of (callersOf.get(qid) || [])) {
|
|
113
157
|
if (!reachableFns.has(callerQid)) work.push({ qid: callerQid, depth: depth + 1 });
|
|
114
158
|
}
|
|
115
159
|
}
|
|
116
|
-
|
|
160
|
+
completion.visited = visitedCount;
|
|
161
|
+
if (capped) {
|
|
162
|
+
completion.reasons.push('worklist-limit');
|
|
163
|
+
completion.unexploredFrontierCount += work.length;
|
|
164
|
+
}
|
|
165
|
+
if (depthCut > 0) {
|
|
166
|
+
completion.reasons.push('caller-depth-limit');
|
|
167
|
+
completion.unexploredFrontierCount += depthCut;
|
|
168
|
+
}
|
|
169
|
+
if (completion.reasons.length) completion.status = 'partial';
|
|
170
|
+
return { reachable: reachableFns, completion };
|
|
117
171
|
}
|
|
118
172
|
|
|
119
173
|
/**
|
|
@@ -128,10 +182,12 @@ export function rhsReachableFunctions(perFileIR, callGraph) {
|
|
|
128
182
|
const sinks = enumerateSinks(perFileIR, callGraph);
|
|
129
183
|
if (!sinks.length) {
|
|
130
184
|
// No sinks → no demand. Return null to signal "analyze all" to caller.
|
|
131
|
-
|
|
185
|
+
// `completion` is an additive property (DAG-07 R07.1); existing callers
|
|
186
|
+
// destructure only { reachable, sinks }.
|
|
187
|
+
return { reachable: null, sinks: [], completion: { status: 'complete', reasons: [], unexploredFrontierCount: 0, visited: 0, limits: {}, note: 'no-sinks-analyze-all' } };
|
|
132
188
|
}
|
|
133
|
-
const reachable =
|
|
134
|
-
return { reachable, sinks };
|
|
189
|
+
const { reachable, completion } = reachabilitySliceWithCompletion(sinks, callGraph);
|
|
190
|
+
return { reachable, sinks, completion };
|
|
135
191
|
}
|
|
136
192
|
|
|
137
193
|
/**
|
package/src/discovery/hunter.js
CHANGED
|
@@ -124,6 +124,10 @@ export async function runHunter(focusArea, lens, ctx = {}, opts = {}) {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
const candidates = candidatesFromParsed(parsed, focusArea, lens);
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
// A proposed candidate with no file and line has nowhere to point and is dropped; say how many, so a reply full of them
|
|
128
|
+
// does not read as a reply that found nothing.
|
|
129
|
+
const proposed = Array.isArray(parsed?.candidates) ? parsed.candidates.length : 0;
|
|
130
|
+
const discarded = proposed - candidates.length;
|
|
131
|
+
appendEntry(transcript, { phase: 'result', candidateCount: candidates.length, discarded });
|
|
132
|
+
return { ...base, candidates, degraded: false, reason: null, discarded };
|
|
129
133
|
}
|
package/src/discovery/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { runHunter } from './hunter.js';
|
|
|
13
13
|
import { confirmAll } from './confirm.js';
|
|
14
14
|
import { disprovePanel } from './disprove.js';
|
|
15
15
|
import { judgeCandidates } from './judge.js';
|
|
16
|
-
import { loadMemory, saveMemory, rememberRun, previouslyRefuted, nextWavePlan } from './memory.js';
|
|
16
|
+
import { loadMemory, saveMemory, rememberRun, previouslyRefuted, nextWavePlan, forgetRefuted } from './memory.js';
|
|
17
17
|
import { emitVerification, headCommit } from '../posture/verification/emit.js';
|
|
18
18
|
import { promoteHypothesis } from '../posture/verification/hypothesis-promotion.js';
|
|
19
19
|
import { featureStatus } from '../posture/assurance/config.js';
|
|
@@ -149,7 +149,9 @@ export async function runDiscovery(ctx = {}, opts = {}) {
|
|
|
149
149
|
const budget = makeBudget(opts);
|
|
150
150
|
// PRD C4 — what previous runs already judged. scanRoot absent => no memory,
|
|
151
151
|
// which is the correct default for a library call with nowhere to persist.
|
|
152
|
-
|
|
152
|
+
let memory = opts.scanRoot ? loadMemory(opts.scanRoot) : null;
|
|
153
|
+
// `--forget-refuted`: a model, a ruleset or the code changed, so what an earlier run refuted is worth examining again.
|
|
154
|
+
if (memory && opts.forgetRefuted) memory = forgetRefuted(memory);
|
|
153
155
|
// Every LLM call in this pipeline goes through this one wrapped callback.
|
|
154
156
|
const llmInvoke = budget.wrap(opts.llmInvoke);
|
|
155
157
|
|
|
@@ -184,6 +186,7 @@ export async function runDiscovery(ctx = {}, opts = {}) {
|
|
|
184
186
|
const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {}, scanRoot: opts.scanRoot }, { llmInvoke, scanRoot: opts.scanRoot });
|
|
185
187
|
runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
|
|
186
188
|
if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
|
|
189
|
+
if (run.discarded > 0) reasons.push(`${area.label} × ${lens.key}: ${run.discarded} proposed candidate(s) had no usable file and line and were discarded`);
|
|
187
190
|
if (run.degraded) areaDegradedCount += 1;
|
|
188
191
|
else hunted.add(area.id);
|
|
189
192
|
candidates = candidates.concat(run.candidates);
|
package/src/engine.js
CHANGED
|
@@ -217,6 +217,8 @@ import { createStringMemo } from './util/string-memo.js';
|
|
|
217
217
|
import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
|
|
218
218
|
import { runDeepAnalysis } from './dataflow/index.js';
|
|
219
219
|
import { buildLineageGraph } from './lineage/index.js';
|
|
220
|
+
import { buildLineageGraphOwned, resolveLineageDeadlineMs } from './lineage/owned-build.js';
|
|
221
|
+
import { completionOfGraph, summarizeForHealth as summarizeCompletionForHealth } from './lineage/completion.js';
|
|
220
222
|
// v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
|
|
221
223
|
import { annotateCloneClusters, findCloneOutliers } from './posture/semantic-clone.js';
|
|
222
224
|
import { annotateAiProvenance } from './posture/ai-code-fingerprint.js';
|
|
@@ -8392,7 +8394,12 @@ function _osvQueryVersion(raw){
|
|
|
8392
8394
|
return fallback?fallback[1]:null;
|
|
8393
8395
|
}
|
|
8394
8396
|
|
|
8397
|
+
// What the advisory lookup could and could not answer on the last scan. A scan that never consulted advisories for some
|
|
8398
|
+
// dependencies must not read as a scan that found none: the stats feed scanHealth.freshness.advisories.
|
|
8399
|
+
let _osvStats = null;
|
|
8400
|
+
function advisoryCoverageMeta() { return _osvStats; }
|
|
8395
8401
|
async function queryOSV(components,allFileContents){
|
|
8402
|
+
_osvStats = { source: 'none-needed', componentsChecked: 0, answered: 0, unanswered: 0 };
|
|
8396
8403
|
const OSV_ECO={npm:'npm',pypi:'PyPI',packagist:'Packagist',rubygems:'RubyGems',golang:'Go',cargo:'crates.io',maven:'Maven',pub:'Pub'};
|
|
8397
8404
|
const results=[];
|
|
8398
8405
|
const queryable=components.filter(c=>OSV_ECO[c.ecosystem]&&c.version&&/\d+\.\d+/.test(c.version));
|
|
@@ -8419,7 +8426,9 @@ async function queryOSV(components,allFileContents){
|
|
|
8419
8426
|
}
|
|
8420
8427
|
|
|
8421
8428
|
const _osvOffline=process.env.AGENTIC_SECURITY_OFFLINE==='1';
|
|
8429
|
+
_osvStats = { source: queries.length ? (_osvOffline ? 'offline-cache-only' : 'live') : 'cache', componentsChecked: queryable.length, answered: queryable.length - queries.length, unanswered: queries.length };
|
|
8422
8430
|
if(queries.length&&!_osvOffline){
|
|
8431
|
+
let _answered=0;
|
|
8423
8432
|
for(let i=0;i<queries.length;i+=1000){
|
|
8424
8433
|
const chunkQ=queries.slice(i,i+1000),chunkC=uncached.slice(i,i+1000);
|
|
8425
8434
|
try{
|
|
@@ -8428,11 +8437,15 @@ async function queryOSV(components,allFileContents){
|
|
|
8428
8437
|
for(let idx=0;idx<(data.results||[]).length;idx++){
|
|
8429
8438
|
const{comp,ck}=chunkC[idx];
|
|
8430
8439
|
const vids=(data.results[idx].vulns||[]).map(v=>v.id).filter(Boolean);
|
|
8440
|
+
_answered++;
|
|
8431
8441
|
_osvCacheSet(ck,vids);
|
|
8432
8442
|
for(const vid of vids){if(!vulnAffects[vid])vulnAffects[vid]=[];vulnAffects[vid].push(comp);}
|
|
8433
8443
|
}
|
|
8434
8444
|
}catch(_){/* OSV unreachable, use whatever was cached */}
|
|
8435
8445
|
}
|
|
8446
|
+
_osvStats.answered += _answered;
|
|
8447
|
+
_osvStats.unanswered = queries.length - _answered;
|
|
8448
|
+
if (_osvStats.unanswered > 0) _osvStats.source = _answered > 0 ? 'live-partial' : 'unreachable';
|
|
8436
8449
|
}
|
|
8437
8450
|
|
|
8438
8451
|
// External-identifier exception (TOS compliance note):
|
|
@@ -9492,6 +9505,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9492
9505
|
let _deepCallGraph = null;
|
|
9493
9506
|
let _deepFailure = null;
|
|
9494
9507
|
let _deepIncomplete = null;
|
|
9508
|
+
let _deepIncremental = null; // DAG-05 R05.7: cache provenance of the deep pass, when AGENTIC_SECURITY_INCREMENTAL=1
|
|
9495
9509
|
if (_deepEnabled) {
|
|
9496
9510
|
const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
|
|
9497
9511
|
const t0 = Date.now();
|
|
@@ -9536,6 +9550,10 @@ function _deterministicFileTimings(timings) {
|
|
|
9536
9550
|
});
|
|
9537
9551
|
}
|
|
9538
9552
|
if (Array.isArray(irFindings.analysisIncomplete) && irFindings.analysisIncomplete.length) _deepIncomplete = irFindings.analysisIncomplete;
|
|
9553
|
+
if (irFindings._incrementalStats) {
|
|
9554
|
+
const st = irFindings._incrementalStats;
|
|
9555
|
+
_deepIncremental = { mode: st.mode, reason: st.reason, priorGeneration: st.priorGeneration, provenance: st.provenance, invalidation: st.invalidation, files: st.files, persisted: st.persisted && { committed: st.persisted.committed, error: st.persisted.error }, completion: st.completion };
|
|
9556
|
+
}
|
|
9539
9557
|
for (const f of irFindings) {
|
|
9540
9558
|
f.unvalidated = true;
|
|
9541
9559
|
f.validator_verdict = 'unvalidated';
|
|
@@ -9566,6 +9584,21 @@ function _deterministicFileTimings(timings) {
|
|
|
9566
9584
|
// the finding-based notice above does not: deep mode never explicitly
|
|
9567
9585
|
// requested at all (the common default-unset-in-CI case), which previously
|
|
9568
9586
|
// produced no signal of any kind that assurance had quietly narrowed.
|
|
9587
|
+
// DAG-05: when the cache was asked for, say what it did for this scan. Languages whose analyzers keep
|
|
9588
|
+
// no cross-scan state (Nix: there is no taint IR to key a cache on) are analysed in full every time and named.
|
|
9589
|
+
if (process.env.AGENTIC_SECURITY_INCREMENTAL === '1') {
|
|
9590
|
+
const noHook = Object.keys(fc).some((f) => /\.nix$/i.test(f)) ? ['nix'] : [];
|
|
9591
|
+
if (!_deepIncremental) {
|
|
9592
|
+
_deepIncremental = { mode: 'cold', reason: _deepEnabled ? 'the deep pass recorded no incremental state' : 'deep analysis did not run, so the cross-scan cache is not used' };
|
|
9593
|
+
}
|
|
9594
|
+
if (noHook.length) {
|
|
9595
|
+
_deepIncremental = {
|
|
9596
|
+
..._deepIncremental, languagesWithoutIncrementalHook: noHook,
|
|
9597
|
+
languages: Object.fromEntries(noHook.map((l) => [l, { incremental: false, mode: 'cold', reason: 'its analysers keep no cross-scan state and run in full on every scan' }])),
|
|
9598
|
+
note: `${noHook.join(', ')} analysers keep no cross-scan state and run in full on every scan`,
|
|
9599
|
+
};
|
|
9600
|
+
}
|
|
9601
|
+
}
|
|
9569
9602
|
const _deepStatus = {
|
|
9570
9603
|
requested: _deepRequested,
|
|
9571
9604
|
enabled: _deepEnabled,
|
|
@@ -9579,6 +9612,8 @@ function _deterministicFileTimings(timings) {
|
|
|
9579
9612
|
failure: _deepFailure,
|
|
9580
9613
|
// E2-05: where the deep analysis stopped short (budget, deadline, function limit, a thrown analysis). Present only when something did.
|
|
9581
9614
|
...(_deepIncomplete ? { incomplete: _deepIncomplete } : {}),
|
|
9615
|
+
// DAG-05 R05.7: which facts were reused from the cache and which were analysed afresh, and why a run was cold.
|
|
9616
|
+
...(_deepIncremental ? { incremental: _deepIncremental } : {}),
|
|
9582
9617
|
};
|
|
9583
9618
|
// Sub-project E, increment 5 — Data Flow Explorer lineage graph.
|
|
9584
9619
|
// DELIBERATELY INDEPENDENT of AGENTIC_SECURITY_DEEP/_deepEnabled above
|
|
@@ -9610,10 +9645,11 @@ function _deterministicFileTimings(timings) {
|
|
|
9610
9645
|
// Reuses the SAME _sharedIR memo _deepEnabled's own block above uses —
|
|
9611
9646
|
// if deep mode already built it, this is free; if not, this is what
|
|
9612
9647
|
// first triggers the build (the whole reason this gate is independent).
|
|
9613
|
-
const
|
|
9648
|
+
const _lineageDeadlineMs = resolveLineageDeadlineMs();
|
|
9649
|
+
const { perFile, callGraph } = _lineageDeadlineMs ? {} : (_sharedIR || (_sharedIR = await _buildIR()));
|
|
9614
9650
|
const _lineageT0 = Date.now();
|
|
9615
9651
|
setProgress({ phase: 'Lineage graph', current: 0, total: 0, file: `starting (budget ${Math.round(_lineageBudgetMs / 1000)}s)` });
|
|
9616
|
-
const
|
|
9652
|
+
const _lineageOpts = {
|
|
9617
9653
|
// Live per-function progress from the field-identity driver's own
|
|
9618
9654
|
// single pass over every function (src/lineage/driver.js).
|
|
9619
9655
|
onProgress: (p) => setProgress({
|
|
@@ -9639,9 +9675,32 @@ function _deterministicFileTimings(timings) {
|
|
|
9639
9675
|
deterministic: isDeterministic(),
|
|
9640
9676
|
perFile,
|
|
9641
9677
|
fileContents,
|
|
9642
|
-
}
|
|
9678
|
+
};
|
|
9679
|
+
// DAG-07 R07.7: with AGENTIC_SECURITY_LINEAGE_DEADLINE_MS set, the build runs in an
|
|
9680
|
+
// owned worker with a watchdog. Unset: the in-process call, exactly as before.
|
|
9681
|
+
let _lr = _lineageDeadlineMs
|
|
9682
|
+
? await buildLineageGraphOwned(fileContents, { ..._lineageOpts, onProgress: undefined, perFile: undefined, deadlineMs: _lineageDeadlineMs }).done
|
|
9683
|
+
: buildLineageGraph(callGraph, _lineageOpts);
|
|
9684
|
+
// The worker entry is a separate file that the single-file bundle does not ship.
|
|
9685
|
+
// When the worker cannot start, fall back to the in-process build with the
|
|
9686
|
+
// cooperative deadline (no watchdog for a non-polling worker, but the analysis
|
|
9687
|
+
// still stops and reports `cancelled`); never fail the build over it.
|
|
9688
|
+
if (_lineageDeadlineMs && _lr.status === 'failed' && /Cannot find module|ERR_MODULE_NOT_FOUND|owned-worker/i.test(String(_lr.failure || ''))) {
|
|
9689
|
+
// The deadline branch skipped the shared IR (the worker rebuilds its own); build it now.
|
|
9690
|
+
const _ir = _sharedIR || (_sharedIR = await _buildIR());
|
|
9691
|
+
_lr = buildLineageGraph(_ir.callGraph, { ..._lineageOpts, perFile: _ir.perFile, deadline: Date.now() + _lineageDeadlineMs });
|
|
9692
|
+
}
|
|
9693
|
+
if (_lr.terminatedByWatchdog) _lineageStatus.failure = _lr.failure;
|
|
9643
9694
|
if (_lr.status === 'complete') {
|
|
9644
9695
|
_lineageGraph = _lr.graph;
|
|
9696
|
+
// DAG-07 R07.2: a graph built from a field analysis that stopped
|
|
9697
|
+
// early must reach scan health, not only the graph body.
|
|
9698
|
+
const _ac = _lr.graph?.coverage?.analysisCompletion;
|
|
9699
|
+
if (_ac && _ac.status !== 'complete') { _lineageStatus.incomplete = _ac.incomplete; _lineageStatus.degraded = _ac.degraded; }
|
|
9700
|
+
// R07.2: the whole envelope (cancelled, construction limits, seeding
|
|
9701
|
+
// limits and zero-flow results included), not only the iteration
|
|
9702
|
+
// and degraded-context lists above.
|
|
9703
|
+
{ const _env = completionOfGraph(_lr.graph); if (_env.status !== 'complete') _lineageStatus.completion = summarizeCompletionForHealth(_env); }
|
|
9645
9704
|
if (_lr.elapsedMs > _lineageBudgetMs) {
|
|
9646
9705
|
// Same "measured, not truly interrupted" disclosure _deepEnabled's
|
|
9647
9706
|
// own elapsed > budgetMs branch already makes for IR-taint — see
|
|
@@ -11226,6 +11285,7 @@ function _deterministicFileTimings(timings) {
|
|
|
11226
11285
|
epss: epssLiveMeta(),
|
|
11227
11286
|
calibration: calibrationFreshness(),
|
|
11228
11287
|
compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
|
|
11288
|
+
advisories: advisoryCoverageMeta(),
|
|
11229
11289
|
});
|
|
11230
11290
|
} // end if (!skipAnnotators) — FR-PROV-029
|
|
11231
11291
|
setDetectorTimingSink(null);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# scanner/src/graph/
|
|
2
|
+
|
|
3
|
+
Typed, read-only graph views and identity crosswalks (DAG-06). A lens over the
|
|
4
|
+
graph layers that already exist; it replaces none of them and changes none of
|
|
5
|
+
their schemas, exports or validators.
|
|
6
|
+
|
|
7
|
+
| Module | Responsibility |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `refs.js` | Reference format `{v, graphKind, repository, graphDigest, entityKind, localId}`, `refKey`/`parseRef`, `GraphViewError` codes, canonical digest |
|
|
10
|
+
| `relations.js` | Relation kinds (data_flow, control_dependence, calls, returns, dependency, declaration, observation, artifact_derivation), edge origins (code, derived, schema, declared, observed), the traversal filter |
|
|
11
|
+
| `view.js` | `IndexedGraphView` (the view contract) and the adjacency index cache keyed by graph kind + digest + adapter version |
|
|
12
|
+
| `adapters/` | `provenance` (PathStore pnode/pedge), `dataflow` (DataFlowGraph v1), `semantic` (SemanticGraph v1), `artifact` (artifact provenance graph), `crossrepo` (CrossRepoLink records, findings) |
|
|
13
|
+
| `registry.js` | Registered views; rejects dangling refs, incompatible adapter versions, same-identity-different-content, ambiguous bare local ids |
|
|
14
|
+
| `crosswalk.js` | Versioned, evidence-bearing mappings; cardinality is explicit; cross-repository `identity` mappings are refused |
|
|
15
|
+
| `crosswalk-builders.js` | finding to call site to witnesses to flow; projected node to underlying sites |
|
|
16
|
+
| `observation.js` | Attributes a runtime observation to a projected node's sites without copying it onto every site |
|
|
17
|
+
|
|
18
|
+
## Rules
|
|
19
|
+
|
|
20
|
+
- **Read-only, pure, no network.** Adapters never mutate their source graph;
|
|
21
|
+
`reconstruct()` returns the original object. `path-store.js` is duck-typed and
|
|
22
|
+
never imported; no analysis engine is imported anywhere here.
|
|
23
|
+
- **A local id is never a key.** Every index and mapping is keyed by the full
|
|
24
|
+
reference (kind, repository, digest, entity kind, local id).
|
|
25
|
+
- **Two axes.** A query filter chooses relations AND origins. The default
|
|
26
|
+
traverses `code`, `derived` and `schema` edges only; a `declared` cross-repo
|
|
27
|
+
link or an `observed` edge is walked only when the caller asks for that origin.
|
|
28
|
+
- **Coarsening is stated.** A mapping with several targets is `one-to-many`
|
|
29
|
+
with `coarsens` and `unknowns`; nothing is attributed to "every" target.
|
|
30
|
+
- **Versions.** Adapter versions and `CROSSWALK_VERSION` are semver; the registry
|
|
31
|
+
accepts only supported majors.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
//
|
|
2
|
+
// adapters/artifact.js: read-only view over the artifact provenance graph
|
|
3
|
+
// (artifact/provenance-graph.js: commit -> build -> OCI digest -> SBOM
|
|
4
|
+
// component -> finding, plus an operator-declared deployment).
|
|
5
|
+
//
|
|
6
|
+
// The artifact graph has no repository identity of its own, so the caller names
|
|
7
|
+
// it (`repository`: the image repository or the source repository the artifact
|
|
8
|
+
// was built from). The graph digest is a canonical hash of the graph body.
|
|
9
|
+
//
|
|
10
|
+
// Relations: commit->build, build->digest and digest->deployment are
|
|
11
|
+
// `artifact_derivation`; digest->component and component->finding are
|
|
12
|
+
// `dependency`. An operator-declared deployment edge has origin `declared`; an
|
|
13
|
+
// edge with evidence 'none' (a commit that is not known) is kept, carries an
|
|
14
|
+
// `unknowns` entry, and has grade 'none'.
|
|
15
|
+
//
|
|
16
|
+
// The generator can list the same SBOM component or finding twice (an OS and an
|
|
17
|
+
// application package with one purl). Byte-identical duplicates are collapsed
|
|
18
|
+
// in the view and counted in metadata; duplicates that DIFFER are rejected,
|
|
19
|
+
// because choosing one would be a silent merge. `reconstruct()` returns the
|
|
20
|
+
// original graph, duplicates included.
|
|
21
|
+
|
|
22
|
+
import { digestOf, GraphViewError, canonicalJson } from '../refs.js';
|
|
23
|
+
import { IndexedGraphView, local } from '../view.js';
|
|
24
|
+
|
|
25
|
+
export const ARTIFACT_ADAPTER_VERSION = '1.0.0';
|
|
26
|
+
|
|
27
|
+
function relationFor(fromKind, toKind) {
|
|
28
|
+
if (toKind === 'sbomComponent' || toKind === 'finding') return 'dependency';
|
|
29
|
+
return 'artifact_derivation';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function artifactView(graph, opts) {
|
|
33
|
+
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
|
|
34
|
+
throw new TypeError('artifactView requires a graph with nodes[] and edges[]');
|
|
35
|
+
}
|
|
36
|
+
const graphDigest = digestOf(graph);
|
|
37
|
+
const byId = new Map();
|
|
38
|
+
let duplicateNodes = 0;
|
|
39
|
+
for (const n of graph.nodes) {
|
|
40
|
+
const prev = byId.get(n.id);
|
|
41
|
+
if (prev) {
|
|
42
|
+
if (canonicalJson(prev) !== canonicalJson(n)) throw new GraphViewError('DUPLICATE_ID', `artifact graph has two different nodes with id ${n.id}`, { id: n.id });
|
|
43
|
+
duplicateNodes += 1;
|
|
44
|
+
} else byId.set(n.id, n);
|
|
45
|
+
}
|
|
46
|
+
const nodes = [...byId.values()].map((n) => ({ entityKind: n.kind, localId: n.id, native: n, summary: { kind: n.kind } }));
|
|
47
|
+
const seenEdges = new Map();
|
|
48
|
+
let duplicateEdges = 0;
|
|
49
|
+
const edges = [];
|
|
50
|
+
for (const e of graph.edges) {
|
|
51
|
+
const id = `agedge:${e.from}->${e.to}`;
|
|
52
|
+
const prev = seenEdges.get(id);
|
|
53
|
+
if (prev) {
|
|
54
|
+
if (canonicalJson(prev) !== canonicalJson(e)) throw new GraphViewError('DUPLICATE_ID', `artifact graph has two different edges ${e.from} -> ${e.to}`, { id });
|
|
55
|
+
duplicateEdges += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
seenEdges.set(id, e);
|
|
59
|
+
const from = byId.get(e.from);
|
|
60
|
+
const to = byId.get(e.to);
|
|
61
|
+
if (!from || !to) throw new GraphViewError('DANGLING_REF', `artifact edge ${e.from} -> ${e.to} points at a node that is not in the graph`, { id });
|
|
62
|
+
const declared = e.evidence === 'operator-declared' || to.kind === 'deployment';
|
|
63
|
+
const none = e.evidence === 'none';
|
|
64
|
+
edges.push({
|
|
65
|
+
id, relation: relationFor(from.kind, to.kind), origin: declared ? 'declared' : 'derived',
|
|
66
|
+
from: local(from.kind, from.id), to: local(to.kind, to.id),
|
|
67
|
+
evidence: { grade: e.evidence ?? 'none', source: 'artifact-provenance-graph' },
|
|
68
|
+
unknowns: none ? [{ kind: 'unlinked', reason: 'no evidence links these two nodes' }] : (e.digestMatches === false ? [{ kind: 'digest-mismatch', reason: 'declared digest differs from the scanned artifact' }] : []),
|
|
69
|
+
attrs: e.digestMatches === undefined ? {} : { digestMatches: e.digestMatches }, native: e,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const unlinked = edges.filter((e) => e.unknowns.some((u) => u.kind === 'unlinked')).length;
|
|
73
|
+
const reasons = unlinked ? [`unlinked-edges:${unlinked}`] : [];
|
|
74
|
+
return new IndexedGraphView({
|
|
75
|
+
graphKind: 'artifact', adapterVersion: ARTIFACT_ADAPTER_VERSION, repository: opts?.repository, graphDigest,
|
|
76
|
+
metadata: { nodes: graph.nodes.length, edges: graph.edges.length, duplicateNodes, duplicateEdges },
|
|
77
|
+
completion: { status: reasons.length ? 'partial' : 'complete', reasons, coverage: null },
|
|
78
|
+
nodes, edges,
|
|
79
|
+
reconstruct: () => graph,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
//
|
|
2
|
+
// adapters/crossrepo.js: read-only view over CrossRepoLink records
|
|
3
|
+
// (lineage/cross-repo-link.js), plus a small findings view.
|
|
4
|
+
//
|
|
5
|
+
// A cross-repository link is an operator DECLARATION. In this view it is an
|
|
6
|
+
// edge with origin `declared` and evidence grade `declared`, so the default
|
|
7
|
+
// query filter (code-derived and derived edges only) never walks it as if it
|
|
8
|
+
// were a code-derived data flow, and it can never be mistaken for observed
|
|
9
|
+
// telemetry (R06.4). Its endpoints live in OTHER graphs, so they are foreign
|
|
10
|
+
// references, each carrying that graph's repository and digest. An endpoint
|
|
11
|
+
// that no registered graph can resolve is reported by the registry as
|
|
12
|
+
// unresolved, not dropped.
|
|
13
|
+
//
|
|
14
|
+
// Both endpoints are `dataflow` node references: that is the only graph kind a
|
|
15
|
+
// CrossRepoLink record binds today.
|
|
16
|
+
|
|
17
|
+
import { validateCrossRepoLink } from '../../lineage/cross-repo-link.js';
|
|
18
|
+
import { digestOf, makeRef, GraphViewError } from '../refs.js';
|
|
19
|
+
import { IndexedGraphView, foreign } from '../view.js';
|
|
20
|
+
|
|
21
|
+
export const CROSSREPO_ADAPTER_VERSION = '1.0.0';
|
|
22
|
+
export const FINDINGS_ADAPTER_VERSION = '1.0.0';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object[]} links CrossRepoLink records
|
|
26
|
+
* @param {{localRepository:string}} opts the repository the `local` side belongs to
|
|
27
|
+
*/
|
|
28
|
+
export function crossRepoLinkView(links, opts) {
|
|
29
|
+
if (!Array.isArray(links)) throw new TypeError('crossRepoLinkView requires an array of links');
|
|
30
|
+
const localRepository = opts?.localRepository;
|
|
31
|
+
const nodes = [];
|
|
32
|
+
const edges = [];
|
|
33
|
+
for (const l of links) {
|
|
34
|
+
const v = validateCrossRepoLink(l);
|
|
35
|
+
if (!v.valid) throw new GraphViewError('INVALID_LINK', `cross-repo link ${l?.id ?? '?'} is invalid: ${v.errors[0].path} ${v.errors[0].message}`, { errors: v.errors });
|
|
36
|
+
nodes.push({ entityKind: 'link', localId: l.id, native: l, summary: { declaredBy: l.declaredBy, declaredAt: l.declaredAt } });
|
|
37
|
+
const from = makeRef({ graphKind: 'dataflow', repository: localRepository, graphDigest: l.local.graphDigest, entityKind: 'node', localId: l.local.nodeId });
|
|
38
|
+
const to = makeRef({ graphKind: 'dataflow', repository: l.remote.repository, graphDigest: l.remote.graphDigest, entityKind: 'node', localId: l.remote.nodeId });
|
|
39
|
+
edges.push({
|
|
40
|
+
id: l.id, relation: 'data_flow', origin: 'declared',
|
|
41
|
+
from: foreign(from), to: foreign(to),
|
|
42
|
+
evidence: { grade: 'declared', source: 'operator', declaredBy: l.declaredBy },
|
|
43
|
+
unknowns: [{ kind: 'not-observed', reason: 'a declared link is an assertion, not an observed data flow' }],
|
|
44
|
+
attrs: { rationale: l.rationale ?? null, provenance: l.provenance }, native: l,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return new IndexedGraphView({
|
|
48
|
+
graphKind: 'cross-repo-links', adapterVersion: CROSSREPO_ADAPTER_VERSION, repository: localRepository, graphDigest: digestOf(links),
|
|
49
|
+
metadata: { links: links.length },
|
|
50
|
+
completion: { status: 'complete', reasons: [], coverage: null },
|
|
51
|
+
nodes, edges, allowForeign: true,
|
|
52
|
+
reconstruct: () => links,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Findings as referenceable entities (no edges): the starting point of a
|
|
58
|
+
* finding -> call site -> witnesses -> flow join.
|
|
59
|
+
* @param {object[]} findings scanner findings; localId is `stableId` when present, else `id`
|
|
60
|
+
*/
|
|
61
|
+
export function findingsView(findings, opts) {
|
|
62
|
+
if (!Array.isArray(findings)) throw new TypeError('findingsView requires an array of findings');
|
|
63
|
+
const nodes = findings.map((f) => {
|
|
64
|
+
const id = f.stableId ?? f.id;
|
|
65
|
+
if (typeof id !== 'string' || !id) throw new GraphViewError('BAD_REF', 'every finding needs a stableId or id');
|
|
66
|
+
return { entityKind: 'finding', localId: id, native: f, summary: { file: f.file ?? null, line: Number.isInteger(f.line) ? f.line : null, severity: f.severity ?? null } };
|
|
67
|
+
});
|
|
68
|
+
return new IndexedGraphView({
|
|
69
|
+
graphKind: 'findings', adapterVersion: FINDINGS_ADAPTER_VERSION, repository: opts?.repository, graphDigest: digestOf(findings),
|
|
70
|
+
metadata: { findings: findings.length },
|
|
71
|
+
completion: { status: 'unknown', reasons: ['a finding list says nothing about what was not scanned'], coverage: null },
|
|
72
|
+
nodes, edges: [],
|
|
73
|
+
reconstruct: () => findings,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//
|
|
2
|
+
// adapters/dataflow.js: read-only view over a DataFlowGraph v1 document (the
|
|
3
|
+
// graph-builder output / signed export body).
|
|
4
|
+
//
|
|
5
|
+
// The graph keeps its own schema, validator and digest. This view:
|
|
6
|
+
// - uses the existing `computeGraphDigest` as the graph digest, so the
|
|
7
|
+
// digest in every reference equals the digest the export already signs;
|
|
8
|
+
// - exposes node, edge, flow, dataElement, evidence and transformation
|
|
9
|
+
// entities under their original ids;
|
|
10
|
+
// - maps `edge.provenance` to the origin axis (code -> code, schema ->
|
|
11
|
+
// schema, manual -> declared, runtime -> observed), so a runtime-observed
|
|
12
|
+
// or manually declared edge is never traversed as a code-derived one by a
|
|
13
|
+
// default query;
|
|
14
|
+
// - reports completion from `scanHealth`, `coverage` and `limitations`.
|
|
15
|
+
//
|
|
16
|
+
// Projected nodes are category-granular: one node can stand for many source
|
|
17
|
+
// sites. That coarsening is not hidden here; it is recorded by crosswalks
|
|
18
|
+
// (one-to-many) built in crosswalk-builders.js.
|
|
19
|
+
|
|
20
|
+
import { computeGraphDigest } from '../../lineage/export-json.js';
|
|
21
|
+
import { IndexedGraphView, local } from '../view.js';
|
|
22
|
+
|
|
23
|
+
export const DATAFLOW_ADAPTER_VERSION = '1.0.0';
|
|
24
|
+
|
|
25
|
+
const ORIGIN_OF_PROVENANCE = { code: 'code', schema: 'schema', manual: 'declared', runtime: 'observed' };
|
|
26
|
+
|
|
27
|
+
const ENTITY_SECTIONS = [
|
|
28
|
+
['node', 'nodes'], ['edge', 'edges'], ['flow', 'flows'],
|
|
29
|
+
['dataElement', 'dataElements'], ['evidence', 'evidence'], ['transformation', 'transformations'],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} graph DataFlowGraph v1 (e.g. buildDataFlowGraph(...).graph)
|
|
34
|
+
* @param {{repository?:string}} [opts]
|
|
35
|
+
*/
|
|
36
|
+
export function dataflowView(graph, opts = {}) {
|
|
37
|
+
if (!graph || typeof graph !== 'object' || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
|
|
38
|
+
throw new TypeError('dataflowView requires a DataFlowGraph v1 object with nodes[] and edges[]');
|
|
39
|
+
}
|
|
40
|
+
const repository = opts.repository ?? graph.scope?.repository;
|
|
41
|
+
const graphDigest = computeGraphDigest(graph);
|
|
42
|
+
|
|
43
|
+
const nodes = [];
|
|
44
|
+
for (const [kind, section] of ENTITY_SECTIONS) {
|
|
45
|
+
for (const e of graph[section] || []) {
|
|
46
|
+
nodes.push({ entityKind: kind, localId: e.id, native: e, summary: kind === 'node' ? { kind: e.kind, subtype: e.subtype ?? null, label: e.label ?? null, coverageStatus: e.coverageStatus ?? null } : null });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const edges = graph.edges.map((e) => {
|
|
50
|
+
const unknowns = [];
|
|
51
|
+
if (e.protocol?.destinationResolution && e.protocol.destinationResolution !== 'literal') unknowns.push({ kind: 'destination-resolution', reason: e.protocol.destinationResolution });
|
|
52
|
+
if (e.coverageStatus && e.coverageStatus !== 'modeled') unknowns.push({ kind: 'coverage', reason: e.coverageStatus });
|
|
53
|
+
return {
|
|
54
|
+
id: e.id, relation: 'data_flow', origin: ORIGIN_OF_PROVENANCE[e.provenance] ?? 'schema',
|
|
55
|
+
from: local('node', e.from), to: local('node', e.to),
|
|
56
|
+
evidence: { grade: e.evidenceGrade ?? e.coverageStatus ?? 'none', source: 'dataflow-graph', evidenceRefs: e.evidenceRefs ?? [] },
|
|
57
|
+
unknowns, attrs: { provenance: e.provenance, relationship: e.relationship }, native: e,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const health = graph.scanHealth || {};
|
|
62
|
+
const reasons = [];
|
|
63
|
+
const status = health.status === 'complete' ? 'complete' : health.status ? 'partial' : 'unknown';
|
|
64
|
+
if (health.status && health.status !== 'complete') reasons.push(`scan-health:${health.status}${health.reason ? `:${health.reason}` : ''}`);
|
|
65
|
+
if (graph.coverage?.pathBudgetTruncation) reasons.push('path-budget-truncation');
|
|
66
|
+
if (health.analysisCompletion && health.analysisCompletion.status && health.analysisCompletion.status !== 'complete') reasons.push(`analysis-${health.analysisCompletion.status}`);
|
|
67
|
+
if ((graph.limitations || []).length) reasons.push(`limitations:${graph.limitations.length}`);
|
|
68
|
+
|
|
69
|
+
return new IndexedGraphView({
|
|
70
|
+
graphKind: 'dataflow', adapterVersion: DATAFLOW_ADAPTER_VERSION, repository, graphDigest,
|
|
71
|
+
metadata: { schemaVersion: graph.schemaVersion ?? null, graphId: graph.graphId ?? null, scope: graph.scope ?? null, counts: Object.fromEntries(ENTITY_SECTIONS.map(([k, s]) => [k, (graph[s] || []).length])) },
|
|
72
|
+
completion: { status: reasons.length && status === 'complete' ? 'partial' : status, reasons, coverage: graph.coverage ?? null },
|
|
73
|
+
nodes, edges,
|
|
74
|
+
reconstruct: () => graph,
|
|
75
|
+
});
|
|
76
|
+
}
|