@clear-capabilities/agentic-security-scanner 0.172.1 → 0.173.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 +15 -0
- package/bin/agentic-security.js +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +135 -11
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +27 -27
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +28 -3
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +2 -0
- package/src/context-intelligence/config.js +3 -1
- package/src/context-intelligence/controls.js +49 -1
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +2 -2
- package/src/context-intelligence/review.js +68 -4
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +272 -20
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/util/string-memo.js +38 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Fleet exposure ranking (E7-06): which findings matter most across many repositories and services, and WHY, without collapsing the reasons into one number.
|
|
2
|
+
//
|
|
3
|
+
// A single risk score hides what it is made of. This ranking keeps five things apart and shows each:
|
|
4
|
+
// impact what is lost if it is exploited: severity, the classes of data on the path (card numbers, health data), how many services the path crosses
|
|
5
|
+
// exposure who can reach it: a route of a public service, a route of an internal service, a path from nothing, or unknown
|
|
6
|
+
// exploitability how likely and how well shown: an execution proof, a known-exploited listing, a taint path, a pattern match; runtime evidence counts only while it is FRESH
|
|
7
|
+
// confidence how sure we are of the path itself: whether the chain is made of named, declared links or inferred ones
|
|
8
|
+
// coverage how complete the picture is: scan health of the repositories on the path, and repositories of the fleet that were not scanned at all
|
|
9
|
+
// Coverage is reported beside the rest and never folded into them: an incomplete picture is a caveat on the ranking, not a reason to rank something lower.
|
|
10
|
+
//
|
|
11
|
+
// PRIORITY is read from a fixed table of those tiers (`PRIORITY_RULES`), top to bottom, first match wins, so any finding's priority can be traced to the rule that
|
|
12
|
+
// gave it. Ties are broken by the tiers in a fixed order, then by id, so the order is deterministic.
|
|
13
|
+
//
|
|
14
|
+
// NEGATIVE CONTROLS built in: runtime evidence older than the freshness window does not raise exploitability and is listed as stale; a finding that arrives through
|
|
15
|
+
// a library only counts for a path whose library step is marked vulnerable for that very package (a dependency on a version outside the advisory range, or on a
|
|
16
|
+
// different package, excludes it and says so); a path is followed only along links that exist in the service graph, so a same-named route in another service is
|
|
17
|
+
// never reached.
|
|
18
|
+
const TIER = Object.freeze({ none: 0, unknown: 0, low: 1, medium: 2, high: 3, critical: 4 });
|
|
19
|
+
const SENSITIVE = new Set(['payment-card', 'health', 'phi', 'pii', 'credentials', 'financial']);
|
|
20
|
+
const SEVERITY = Object.freeze({ info: 'low', low: 'low', medium: 'medium', high: 'high', critical: 'critical' });
|
|
21
|
+
|
|
22
|
+
/** The pre-registered priority table. First matching rule wins; each names the tiers it needs. */
|
|
23
|
+
export const PRIORITY_RULES = Object.freeze([
|
|
24
|
+
{ id: 'P0-proven-public-sensitive', priority: 'P0', when: (t) => t.impact >= TIER.high && t.exposure === 'public' && t.exploitability >= TIER.high && t.confidence >= TIER.medium },
|
|
25
|
+
{ id: 'P0-critical-public', priority: 'P0', when: (t) => t.impact >= TIER.critical && t.exposure === 'public' && t.exploitability >= TIER.medium && t.confidence >= TIER.medium },
|
|
26
|
+
{ id: 'P1-high-public', priority: 'P1', when: (t) => t.impact >= TIER.high && t.exposure === 'public' && t.exploitability >= TIER.medium },
|
|
27
|
+
{ id: 'P1-proven-internal', priority: 'P1', when: (t) => t.impact >= TIER.high && t.exposure === 'internal' && t.exploitability >= TIER.high },
|
|
28
|
+
{ id: 'P2-reachable', priority: 'P2', when: (t) => (t.exposure === 'public' || t.exposure === 'internal') && t.impact >= TIER.medium },
|
|
29
|
+
{ id: 'P3-exposure-unknown', priority: 'P3', when: (t) => t.exposure === 'unknown' && t.impact >= TIER.high },
|
|
30
|
+
{ id: 'P4-not-reached', priority: 'P4', when: () => true },
|
|
31
|
+
]);
|
|
32
|
+
const PRIORITY_ORDER = ['P0', 'P1', 'P2', 'P3', 'P4'];
|
|
33
|
+
const tierName = (n) => Object.keys(TIER).find((k) => TIER[k] === n && k !== 'unknown') || 'none';
|
|
34
|
+
const daysBetween = (a, b) => (a - b) / 86400000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} o
|
|
38
|
+
* @param {{nodes:object[], edges:object[]}} o.graph from buildServiceChainGraph
|
|
39
|
+
* @param {{route:string, service:string, repo:string, sink:string, steps:object[], confidence:number, repos:string[], dataClassifications:string[], crossesServices:boolean, throughLibrary:object[]}[]} o.chains from findChains
|
|
40
|
+
* @param {object[]} o.findings { id, repo, sinkRef:{repo, qid, kind}, severity, proofTier, kev, epss, viaLibrary:{package}, runtime:{observedAt, corroboration}, scanHealth:'complete'|'partial'|... }
|
|
41
|
+
* @param {Object<string,{exposure:'public'|'internal'}>} o.services keyed by service name
|
|
42
|
+
* @param {string[]} [o.fleet] every repository of the fleet, so unscanned ones are counted
|
|
43
|
+
* @param {string[]} [o.scanned] the repositories that were scanned
|
|
44
|
+
* @param {number} o.now
|
|
45
|
+
* @param {number} [o.freshnessDays] runtime evidence older than this is stale (default 14)
|
|
46
|
+
*/
|
|
47
|
+
export function rankFleetExposure({ graph, chains, findings, services = {}, fleet = [], scanned = null, now, freshnessDays = 14 }) {
|
|
48
|
+
const byRepoSink = new Map();
|
|
49
|
+
for (const c of chains || []) {
|
|
50
|
+
const sinkStep = c.steps[c.steps.length - 1];
|
|
51
|
+
const node = (graph.nodes || []).find((n) => n.id === (sinkStep && sinkStep.to));
|
|
52
|
+
if (!node) continue;
|
|
53
|
+
const key = `${node.repo}|${node.id.split(':sink:')[1] || ''}`;
|
|
54
|
+
(byRepoSink.get(key) || byRepoSink.set(key, []).get(key)).push(c);
|
|
55
|
+
}
|
|
56
|
+
// a repository counts as scanned when the caller says so, or, failing that, when some finding of it carries a scan health
|
|
57
|
+
const scannedRepos = new Set(scanned || (findings || []).filter((f) => f.scanHealth).map((f) => f.repo));
|
|
58
|
+
const ranked = []; const excluded = [];
|
|
59
|
+
for (const f of findings || []) {
|
|
60
|
+
const ref = f.sinkRef || {};
|
|
61
|
+
const key = `${ref.repo}|${ref.qid}#${ref.kind}`;
|
|
62
|
+
let cands = byRepoSink.get(key) || [];
|
|
63
|
+
const dropped = [];
|
|
64
|
+
if (f.viaLibrary) {
|
|
65
|
+
const keep = cands.filter((c) => c.throughLibrary.some((l) => l.package === f.viaLibrary.package && l.vulnerable === true));
|
|
66
|
+
for (const c of cands) if (!keep.includes(c)) dropped.push({ route: c.route, reason: c.throughLibrary.some((l) => l.package === f.viaLibrary.package) ? `the dependency on ${f.viaLibrary.package} is outside the advisory range` : `the path does not use ${f.viaLibrary.package}` });
|
|
67
|
+
cands = keep;
|
|
68
|
+
if (!keep.length && dropped.length) { excluded.push({ id: f.id, reason: dropped[0].reason, paths: dropped.length }); continue; }
|
|
69
|
+
}
|
|
70
|
+
const factors = [];
|
|
71
|
+
// ---- exposure
|
|
72
|
+
let exposure = 'none'; let entry = null;
|
|
73
|
+
if (cands.length) {
|
|
74
|
+
const exposures = cands.map((c) => (services[c.service] ? services[c.service].exposure : 'unknown'));
|
|
75
|
+
exposure = exposures.includes('public') ? 'public' : exposures.includes('internal') ? 'internal' : 'unknown';
|
|
76
|
+
// the entry that explains the exposure: the most certain path among those that give it
|
|
77
|
+
entry = cands.filter((c) => (services[c.service] ? services[c.service].exposure : 'unknown') === exposure).sort((a, b) => b.confidence - a.confidence)[0] || cands[0];
|
|
78
|
+
factors.push({ dimension: 'exposure', value: exposure, because: `${cands.length} path(s) reach it from a route; the entry service${cands.length > 1 ? 's are' : ' is'} ${[...new Set(cands.map((c) => c.service))].join(', ')} (${exposure})` });
|
|
79
|
+
} else factors.push({ dimension: 'exposure', value: ref.repo ? 'none' : 'unknown', because: ref.repo ? 'no path from any route in the service graph reaches this sink' : 'the finding does not say which sink it is, so no path can be looked for' });
|
|
80
|
+
if (!cands.length && !ref.repo) exposure = 'unknown'; else if (!cands.length) exposure = 'none';
|
|
81
|
+
// ---- impact
|
|
82
|
+
const classes = [...new Set(cands.flatMap((c) => c.dataClassifications))].sort();
|
|
83
|
+
const sev = SEVERITY[f.severity] || 'low';
|
|
84
|
+
let impact = TIER[sev];
|
|
85
|
+
if (classes.some((c) => SENSITIVE.has(c))) { impact = Math.min(TIER.critical, impact + 1); factors.push({ dimension: 'impact', value: tierName(impact), because: `severity ${f.severity}; the path carries ${classes.filter((c) => SENSITIVE.has(c)).join(', ')}, which raises it one tier` }); }
|
|
86
|
+
else factors.push({ dimension: 'impact', value: tierName(impact), because: `severity ${f.severity}${classes.length ? `; data on the path: ${classes.join(', ')} (not rated sensitive)` : '; no data classification on the path'}` });
|
|
87
|
+
if (cands.some((c) => c.crossesServices)) factors.push({ dimension: 'impact', value: 'info', because: 'the path crosses services, so a compromise is not contained to the entry service' });
|
|
88
|
+
// ---- exploitability
|
|
89
|
+
let exploit = f.proofTier === 'execution-proven' ? TIER.high : f.kev ? TIER.high : (typeof f.epss === 'number' && f.epss >= 0.5) ? TIER.high : f.proofTier === 'taint-proven' ? TIER.medium : TIER.low;
|
|
90
|
+
factors.push({ dimension: 'exploitability', value: tierName(exploit), because: f.proofTier === 'execution-proven' ? 'a proof of concept ran and showed the effect' : f.kev ? 'listed as known exploited' : (typeof f.epss === 'number' && f.epss >= 0.5) ? `EPSS ${f.epss}` : f.proofTier === 'taint-proven' ? 'a taint path was found by static analysis; nothing was executed' : 'a pattern match; no path or proof' });
|
|
91
|
+
if (f.runtime && f.runtime.observedAt) {
|
|
92
|
+
const age = daysBetween(now, Date.parse(f.runtime.observedAt));
|
|
93
|
+
if (!(age >= 0)) factors.push({ dimension: 'exploitability', value: 'info', because: 'runtime evidence is dated in the future and is ignored' });
|
|
94
|
+
else if (age > freshnessDays) factors.push({ dimension: 'exploitability', value: 'info', stale: true, because: `runtime evidence is ${Math.floor(age)} days old, past the ${freshnessDays}-day window: stale, so it does not raise anything` });
|
|
95
|
+
else if (f.runtime.corroboration === 'corroborated') { exploit = Math.min(TIER.critical, exploit + 1); factors.push({ dimension: 'exploitability', value: tierName(exploit), because: `observed by a matched build ${Math.floor(age)} day(s) ago: raises it one tier` }); }
|
|
96
|
+
else factors.push({ dimension: 'exploitability', value: 'info', because: 'seen at runtime but not tied to this build: noted, not counted' });
|
|
97
|
+
}
|
|
98
|
+
// ---- confidence
|
|
99
|
+
const conf = entry ? entry.confidence : 0;
|
|
100
|
+
const confidence = !entry ? TIER.unknown : conf >= 0.7 ? TIER.high : conf >= 0.5 ? TIER.medium : TIER.low;
|
|
101
|
+
factors.push({ dimension: 'confidence', value: entry ? tierName(confidence) : 'unknown', because: entry ? `the best path has confidence ${entry.confidence} (product of its links; named links 0.9, inferred 0.7 and below)` : 'there is no path to judge' });
|
|
102
|
+
// ---- coverage (reported, never merged)
|
|
103
|
+
const reposOnPath = new Set(cands.flatMap((c) => c.repos));
|
|
104
|
+
const unscanned = [...reposOnPath].filter((r) => fleet.length && !scannedRepos.has(r));
|
|
105
|
+
const coverage = { scanHealth: f.scanHealth || 'unknown', reposOnPath: [...reposOnPath].sort(), unscannedReposOnPath: unscanned.sort(), complete: (f.scanHealth === 'complete') && unscanned.length === 0 };
|
|
106
|
+
factors.push({ dimension: 'coverage', value: coverage.complete ? 'complete' : 'incomplete', because: coverage.complete ? 'the scan of this repository was complete and every repository on the path was scanned' : `scan health ${coverage.scanHealth}${unscanned.length ? `; not scanned: ${unscanned.join(', ')}` : ''}` });
|
|
107
|
+
const tiers = { impact, exposure, exploitability: exploit, confidence };
|
|
108
|
+
const rule = PRIORITY_RULES.find((r) => r.when(tiers));
|
|
109
|
+
ranked.push({ id: f.id, repo: ref.repo || f.repo || null, priority: rule.priority, rule: rule.id, tiers: { impact: tierName(impact), exposure, exploitability: tierName(exploit), confidence: entry ? tierName(confidence) : 'unknown' }, coverage, entryRoute: entry ? entry.route : null, paths: cands.length, droppedPaths: dropped, factors, _sort: [PRIORITY_ORDER.indexOf(rule.priority), -impact, -(exposure === 'public' ? 2 : exposure === 'internal' ? 1 : 0), -exploit, -confidence, String(f.id)] });
|
|
110
|
+
}
|
|
111
|
+
ranked.sort((a, b) => { for (let i = 0; i < a._sort.length; i++) { if (a._sort[i] < b._sort[i]) return -1; if (a._sort[i] > b._sort[i]) return 1; } return 0; });
|
|
112
|
+
for (const r of ranked) delete r._sort;
|
|
113
|
+
const byPriority = Object.fromEntries(PRIORITY_ORDER.map((p) => [p, ranked.filter((r) => r.priority === p).length]));
|
|
114
|
+
return { ranked, excluded, summary: { findings: (findings || []).length, ranked: ranked.length, excluded: excluded.length, byPriority, incompleteCoverage: ranked.filter((r) => !r.coverage.complete).length } };
|
|
115
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Multi-repo, multi-service chain graph (E5-06).
|
|
2
|
+
//
|
|
3
|
+
// fleet-graph.js knows which repos depend on which packages. That says a defect in a shared library can reach its dependants, but not whether any route
|
|
4
|
+
// of any service reaches the defect. This module adds the layers that answer that, over the same fleet:
|
|
5
|
+
//
|
|
6
|
+
// call inside one repo: function A calls function B (taken from the semantic graph's resolved call edges, supplied as facts)
|
|
7
|
+
// library a function in repo A uses a symbol that a package published by repo L exports; the edge is made only when A's manifest depends on L
|
|
8
|
+
// (the existing dependency graph), and it is marked `vulnerable` when A's declared version is in L's advisory list
|
|
9
|
+
// api a function in repo A calls an HTTP route of ANOTHER service; the edge goes to the route that service exposes
|
|
10
|
+
// topic a function publishes an event topic and a function in another repo consumes it
|
|
11
|
+
//
|
|
12
|
+
// A CHAIN is route -> ... -> sink, found by walking those edges. Each step carries the evidence that made it and a confidence; a chain's confidence is
|
|
13
|
+
// the product of its steps'.
|
|
14
|
+
//
|
|
15
|
+
// WRONG-SERVICE EDGES ARE THE FAILURE TO AVOID. Two services often expose the same path (`/health`, `/charge`). An outbound call that names its target
|
|
16
|
+
// service links ONLY to that service's route. A call that names no service links to a route only when exactly ONE service in the tenant exposes it;
|
|
17
|
+
// when several do, no edge is made and the call is listed under `ambiguous` with the candidates, never resolved by guessing.
|
|
18
|
+
//
|
|
19
|
+
// BUDGETS. `maxRepos`, `maxNodes`, `maxEdges`, `maxDepth`, `maxChains` bound the work. Hitting one stops that step and sets `truncated` with the name of
|
|
20
|
+
// the budget; a result that was cut short never looks complete.
|
|
21
|
+
//
|
|
22
|
+
// TENANT ISOLATION. Every repo belongs to one tenant, every node id is prefixed with it, and a graph is built for one tenant. A repo from another tenant
|
|
23
|
+
// is not read at all (counted in `refusedOtherTenant`), and a link that would cross tenants is refused. Nothing here reads outside the facts passed in.
|
|
24
|
+
import { buildFleetDependencyGraph } from './fleet-graph.js';
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_BUDGETS = Object.freeze({ maxRepos: 200, maxNodes: 20000, maxEdges: 60000, maxDepth: 12, maxChains: 200 });
|
|
27
|
+
|
|
28
|
+
const nid = (tenant, repo, kind, key) => `${tenant}:${repo}:${kind}:${key}`;
|
|
29
|
+
const routeKey = (r) => `${(r.method || 'ANY').toUpperCase()} ${r.path}`;
|
|
30
|
+
const ver = (v) => String(v || '').replace(/^[^\d]*/, '').split('.').map((n) => parseInt(n, 10) || 0);
|
|
31
|
+
const cmp = (a, b) => { for (let i = 0; i < 3; i++) { const d = (a[i] || 0) - (b[i] || 0); if (d) return d; } return 0; };
|
|
32
|
+
function isVulnerable(version, advisories) {
|
|
33
|
+
if (!version || !Array.isArray(advisories)) return false;
|
|
34
|
+
const v = ver(version);
|
|
35
|
+
return advisories.some((a) => (a.exact && cmp(v, ver(a.exact)) === 0) || (a.lt && cmp(v, ver(a.lt)) < 0 && (!a.gte || cmp(v, ver(a.gte)) >= 0)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object[]} repos RepoFacts:
|
|
40
|
+
* { id, path, tenant, service?: string, environment?: string,
|
|
41
|
+
* routes?: [{method?, path, handler, classification?}], calls?: [{from, to}],
|
|
42
|
+
* outboundCalls?: [{from, service?, method?, path}],
|
|
43
|
+
* topics?: { publishes?: [{topic, from}], consumes?: [{topic, handler}] },
|
|
44
|
+
* libraryUses?: [{package, symbol, from}], exports?: [{package, symbol, qid}],
|
|
45
|
+
* functions?: [{qid, sinks?: [{kind, cwe?}]}], advisories?: [{exact?|lt?, gte?}], dependencyVersions?: {pkg: version},
|
|
46
|
+
* } `path` is the repo directory: its manifests (package.json, go.mod, ...) are read by fleet-graph.js to decide who publishes and who depends on what
|
|
47
|
+
* @param {{tenant:string, budgets?:object}} opts
|
|
48
|
+
*/
|
|
49
|
+
export function buildServiceChainGraph(repos, opts = {}) {
|
|
50
|
+
const tenant = opts.tenant;
|
|
51
|
+
if (typeof tenant !== 'string' || !tenant) throw new Error('tenant is required: a fleet graph is built for exactly one tenant');
|
|
52
|
+
const B = { ...DEFAULT_BUDGETS, ...(opts.budgets || {}) };
|
|
53
|
+
const truncated = []; const trunc = (name) => { if (!truncated.includes(name)) truncated.push(name); };
|
|
54
|
+
const refusedOtherTenant = []; const ambiguous = []; const unresolved = [];
|
|
55
|
+
let mine = [];
|
|
56
|
+
for (const r of repos || []) { if (r.tenant !== tenant) refusedOtherTenant.push(r.id); else mine.push(r); }
|
|
57
|
+
if (mine.length > B.maxRepos) { trunc('maxRepos'); mine = mine.slice(0, B.maxRepos); }
|
|
58
|
+
|
|
59
|
+
const nodes = new Map(); const edges = []; const edgeSet = new Set();
|
|
60
|
+
const addNode = (id, n) => { if (nodes.has(id)) return true; if (nodes.size >= B.maxNodes) { trunc('maxNodes'); return false; } nodes.set(id, { id, ...n }); return true; };
|
|
61
|
+
const addEdge = (from, to, e) => {
|
|
62
|
+
const key = `${e.kind}|${from}|${to}`; if (edgeSet.has(key)) return;
|
|
63
|
+
if (edges.length >= B.maxEdges) { trunc('maxEdges'); return; }
|
|
64
|
+
if (!addNode(from, {}) || !addNode(to, {})) return;
|
|
65
|
+
edgeSet.add(key); edges.push({ from, to, ...e });
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// the existing manifest-based dependency graph decides which library edges are allowed at all
|
|
69
|
+
const dep = buildFleetDependencyGraph(mine.map((r) => ({ id: r.id, path: r.path || '' })));
|
|
70
|
+
const dependsOn = new Set(dep.edges.map((e) => `${e.from}>${e.to}`));
|
|
71
|
+
const publishedBy = new Map();
|
|
72
|
+
for (const r of mine) { const n = (dep.nodes.find((x) => x.id === r.id) || {}).declaredName; if (n) publishedBy.set(n, r); }
|
|
73
|
+
|
|
74
|
+
for (const r of mine) {
|
|
75
|
+
for (const fn of r.functions || []) { addNode(nid(tenant, r.id, 'fn', fn.qid), { kind: 'function', repo: r.id, qid: fn.qid, sinks: (fn.sinks || []).map((s) => s.kind) }); }
|
|
76
|
+
for (const rt of r.routes || []) {
|
|
77
|
+
const rid = nid(tenant, r.id, 'route', routeKey(rt));
|
|
78
|
+
addNode(rid, { kind: 'route', repo: r.id, service: r.service || r.id, route: routeKey(rt), classification: rt.classification || null });
|
|
79
|
+
addEdge(rid, nid(tenant, r.id, 'fn', rt.handler), { kind: 'handler', confidence: 0.95, evidence: `route ${routeKey(rt)} is handled by ${rt.handler}`, repo: r.id });
|
|
80
|
+
}
|
|
81
|
+
for (const c of r.calls || []) addEdge(nid(tenant, r.id, 'fn', c.from), nid(tenant, r.id, 'fn', c.to), { kind: 'call', confidence: 0.9, evidence: 'resolved call edge in the semantic graph', repo: r.id });
|
|
82
|
+
for (const fn of r.functions || []) for (const s of fn.sinks || []) {
|
|
83
|
+
const sid = nid(tenant, r.id, 'sink', `${fn.qid}#${s.kind}`);
|
|
84
|
+
addNode(sid, { kind: 'sink', repo: r.id, sink: s.kind, cwe: s.cwe || null });
|
|
85
|
+
addEdge(nid(tenant, r.id, 'fn', fn.qid), sid, { kind: 'sink', confidence: 0.9, evidence: `${fn.qid} reaches a ${s.kind} sink`, repo: r.id });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// library edges: only along a declared dependency, and only for a symbol the library publishes
|
|
90
|
+
for (const r of mine) for (const u of r.libraryUses || []) {
|
|
91
|
+
const lib = publishedBy.get(u.package);
|
|
92
|
+
if (!lib) { unresolved.push({ repo: r.id, kind: 'library', package: u.package, reason: 'no repo in this tenant publishes this package' }); continue; }
|
|
93
|
+
if (lib.id === r.id) continue;
|
|
94
|
+
if (!dependsOn.has(`${r.id}>${lib.id}`)) { unresolved.push({ repo: r.id, kind: 'library', package: u.package, reason: 'the repo does not declare a dependency on the publishing repo' }); continue; }
|
|
95
|
+
const exp = (lib.exports || []).find((x) => x.symbol === u.symbol && (!x.package || x.package === u.package));
|
|
96
|
+
if (!exp) { unresolved.push({ repo: r.id, kind: 'library', package: u.package, symbol: u.symbol, reason: 'the publishing repo does not export this symbol' }); continue; }
|
|
97
|
+
const v = (r.dependencyVersions || {})[u.package];
|
|
98
|
+
const vulnerable = isVulnerable(v, lib.advisories);
|
|
99
|
+
addEdge(nid(tenant, r.id, 'fn', u.from), nid(tenant, lib.id, 'fn', exp.qid), { kind: 'library', confidence: 0.85, evidence: `${r.id} depends on ${lib.id} and uses ${u.package}.${u.symbol}`, repo: r.id, toRepo: lib.id, package: u.package, version: v || null, vulnerable });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// api edges: named service links only to that service; unnamed links only when exactly one service exposes the route
|
|
103
|
+
const exposers = (path, method) => mine.filter((x) => (x.routes || []).some((rt) => rt.path === path && (!method || !rt.method || rt.method.toUpperCase() === method.toUpperCase())));
|
|
104
|
+
for (const r of mine) for (const c of r.outboundCalls || []) {
|
|
105
|
+
const cands = exposers(c.path, c.method).filter((x) => x.id !== r.id);
|
|
106
|
+
let target = null;
|
|
107
|
+
if (c.service) {
|
|
108
|
+
target = cands.find((x) => (x.service || x.id) === c.service) || null;
|
|
109
|
+
if (!target) { unresolved.push({ repo: r.id, kind: 'api', path: c.path, service: c.service, reason: 'the named service does not expose this route' }); continue; }
|
|
110
|
+
} else if (cands.length === 1) target = cands[0];
|
|
111
|
+
else if (cands.length > 1) { ambiguous.push({ repo: r.id, from: c.from, path: c.path, candidates: cands.map((x) => x.service || x.id).sort() }); continue; }
|
|
112
|
+
else { unresolved.push({ repo: r.id, kind: 'api', path: c.path, reason: 'no service in this tenant exposes this route' }); continue; }
|
|
113
|
+
const rt = (target.routes || []).find((x) => x.path === c.path && (!c.method || !x.method || x.method.toUpperCase() === c.method.toUpperCase()));
|
|
114
|
+
addEdge(nid(tenant, r.id, 'fn', c.from), nid(tenant, target.id, 'route', routeKey(rt)), { kind: 'api', confidence: c.service ? 0.9 : 0.7, evidence: c.service ? `call names service ${c.service}` : `only service exposing ${c.path}`, repo: r.id, toRepo: target.id, classification: rt.classification || null });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// topic edges
|
|
118
|
+
for (const r of mine) for (const p of (r.topics && r.topics.publishes) || []) for (const o of mine) {
|
|
119
|
+
if (o.id === r.id) continue;
|
|
120
|
+
for (const cn of (o.topics && o.topics.consumes) || []) if (cn.topic === p.topic) addEdge(nid(tenant, r.id, 'fn', p.from), nid(tenant, o.id, 'fn', cn.handler), { kind: 'topic', confidence: 0.75, evidence: `topic ${p.topic}`, repo: r.id, toRepo: o.id, topic: p.topic });
|
|
121
|
+
}
|
|
122
|
+
return { tenant, nodes: [...nodes.values()], edges, ambiguous, unresolved, refusedOtherTenant, truncated, budgets: B, dependencyEdges: dep.edges.length };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Walk route -> ... -> sink. Cycles are not followed; depth and chain count are budgeted and reported. */
|
|
126
|
+
export function findChains(graph, { sinkKind = null, fromRepo = null } = {}) {
|
|
127
|
+
const B = graph.budgets; const out = new Map();
|
|
128
|
+
for (const e of graph.edges) { (out.get(e.from) || out.set(e.from, []).get(e.from)).push(e); }
|
|
129
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
130
|
+
const chains = []; const truncated = [];
|
|
131
|
+
for (const start of graph.nodes.filter((n) => n.kind === 'route' && (!fromRepo || n.repo === fromRepo))) {
|
|
132
|
+
const stack = [{ at: start.id, steps: [], seen: new Set([start.id]) }];
|
|
133
|
+
while (stack.length) {
|
|
134
|
+
const { at, steps, seen } = stack.pop();
|
|
135
|
+
const node = byId.get(at);
|
|
136
|
+
if (node && node.kind === 'sink' && (!sinkKind || node.sink === sinkKind)) {
|
|
137
|
+
if (chains.length >= B.maxChains) { if (!truncated.includes('maxChains')) truncated.push('maxChains'); break; }
|
|
138
|
+
chains.push({ route: start.route, service: start.service, repo: start.repo, sink: node.sink, steps, confidence: Math.round(steps.reduce((p, s) => p * s.confidence, 1) * 1000) / 1000, crossesServices: steps.some((s) => s.kind === 'api' || s.kind === 'topic'), throughLibrary: steps.filter((s) => s.kind === 'library').map((s) => ({ package: s.package, version: s.version, vulnerable: s.vulnerable })), repos: [...new Set([start.repo, ...steps.map((s) => s.toRepo || s.repo)])], dataClassifications: [...new Set([start.classification, ...steps.map((s) => s.classification)].filter(Boolean))].sort() });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (steps.length >= B.maxDepth) { if (!truncated.includes('maxDepth')) truncated.push('maxDepth'); continue; }
|
|
142
|
+
for (const e of out.get(at) || []) {
|
|
143
|
+
if (seen.has(e.to)) continue;
|
|
144
|
+
stack.push({ at: e.to, steps: [...steps, { kind: e.kind, from: e.from, to: e.to, confidence: e.confidence, evidence: e.evidence, ...(e.package ? { package: e.package, version: e.version, vulnerable: e.vulnerable } : {}), ...(e.toRepo ? { toRepo: e.toRepo } : {}), ...(e.repo ? { repo: e.repo } : {}), ...(e.classification ? { classification: e.classification } : {}) }], seen: new Set([...seen, e.to]) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
chains.sort((a, b) => b.confidence - a.confidence || (a.route < b.route ? -1 : 1));
|
|
149
|
+
return { chains, truncated };
|
|
150
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Static / runtime graph reconciliation (E5-04).
|
|
2
|
+
//
|
|
3
|
+
// The static graph says which calls, routes and service edges the CODE contains. A runtime trace says which ones a RUNNING BUILD exercised. This
|
|
4
|
+
// module lines the two up edge by edge and states, for each edge, what is actually known:
|
|
5
|
+
//
|
|
6
|
+
// statically_inferred the static graph has the edge; no usable runtime evidence about it
|
|
7
|
+
// runtime_observed a runtime record shows the edge; the static graph does not contain it (a gap in the static graph, or dynamic dispatch)
|
|
8
|
+
// both the static graph has it AND a record from a build matched to this snapshot shows it
|
|
9
|
+
// conflicted the two disagree: the same route reached a different handler, or the same call site's caller reached a callee the static
|
|
10
|
+
// graph never resolved it to while a matched build was running
|
|
11
|
+
// unknown evidence exists but cannot be tied to this snapshot (runtime records with no comparable build identity), so the edge is
|
|
12
|
+
// neither confirmed nor refuted
|
|
13
|
+
//
|
|
14
|
+
// `both` REQUIRES a matched build (deployment-identity.js). A record from a different build never confirms an edge: it is counted against the edge
|
|
15
|
+
// as `mismatchedRecords` and decays its confidence. Records with no build identity make the edge `unknown`, not `both`.
|
|
16
|
+
//
|
|
17
|
+
// CONFIDENCE is a number in [0,1] with the factors listed in `factors`, so a reader can see why it is what it is:
|
|
18
|
+
// base static edge 0.6; resolved call 0.7; runtime-only 0.5; both 0.95; conflicted 0.3; unknown 0.4
|
|
19
|
+
// x0.5 for each edge that also has records from a mismatched build (the evidence is about different code), once
|
|
20
|
+
// x0.85 when the trace's coverage is partial: sampled below 1 or sampling undeclared
|
|
21
|
+
// Matching is by NORMALIZED KEY: symbols lose a trailing @line and, per `opts.normalize`, a configured prefix and case; routes become their path
|
|
22
|
+
// with every `:param` and id-like segment collapsed to `:id`.
|
|
23
|
+
//
|
|
24
|
+
// NOT USED: spans. Static symbols carry a start line and no end line, so line-range matching is not attempted; matching is by qualified symbol.
|
|
25
|
+
import * as fs from 'node:fs';
|
|
26
|
+
import * as readline from 'node:readline';
|
|
27
|
+
import { linkObservationToSnapshot, normalizeIdentity } from '../lineage/deployment-identity.js';
|
|
28
|
+
import { normalizeRoute } from './runtime-trace-ingest.js';
|
|
29
|
+
|
|
30
|
+
export const EDGE_STATES = Object.freeze(['statically_inferred', 'runtime_observed', 'both', 'conflicted', 'unknown']);
|
|
31
|
+
const BASE = Object.freeze({ statically_inferred: 0.6, runtime_observed: 0.5, both: 0.95, conflicted: 0.3, unknown: 0.4 });
|
|
32
|
+
|
|
33
|
+
/** Normalise a symbol per the configurable rules. */
|
|
34
|
+
export function normalizeSymbol(q, norm = {}) {
|
|
35
|
+
if (typeof q !== 'string') return null;
|
|
36
|
+
let s = q.replace(/@\d+$/, '').replace(/\(\)$/, '');
|
|
37
|
+
// `rewrite` is [[pattern, replacement], ...]: how this project's collector spells a symbol versus how the IR does (the IR writes `app.js::<module>::run`)
|
|
38
|
+
for (const [pat, rep] of norm.rewrite || []) s = s.replace(new RegExp(pat), rep);
|
|
39
|
+
for (const pre of norm.stripPrefixes || []) if (s.startsWith(pre)) { s = s.slice(pre.length); break; }
|
|
40
|
+
if (norm.caseInsensitive) s = s.toLowerCase();
|
|
41
|
+
return s || null;
|
|
42
|
+
}
|
|
43
|
+
/** Normalise a route path: query dropped, ids and `:param` collapsed to `:id`. */
|
|
44
|
+
export function normalizeRouteKey(r) {
|
|
45
|
+
const n = normalizeRoute(typeof r === 'string' ? r.replace(/\{[^}/]+\}/g, ':id') : r);
|
|
46
|
+
return n ? n.split('/').map((seg) => (seg.startsWith(':') ? ':id' : seg)).join('/') : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const keyOf = (kind, a, b) => `${kind}|${a}|${b}`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Static edges from a SemanticGraph.
|
|
53
|
+
* @param {object} graph a SemanticGraph v1
|
|
54
|
+
* @param {{routes?:{path:string, handler:string}[]}} extra route -> handler pairs the caller knows (the IR has no route entity)
|
|
55
|
+
*/
|
|
56
|
+
export function staticEdgesFromGraph(graph, extra = {}, norm = {}) {
|
|
57
|
+
const byId = new Map((graph.symbols || []).map((s) => [s.id, s]));
|
|
58
|
+
const edges = new Map();
|
|
59
|
+
const add = (kind, a, b, over = {}) => { if (a && b) edges.set(keyOf(kind, a, b), { kind, from: a, to: b, resolved: true, ...over }); };
|
|
60
|
+
for (const c of graph.callSites || []) {
|
|
61
|
+
if (c.resolution !== 'resolved') continue;
|
|
62
|
+
add('call', normalizeSymbol(byId.get(c.caller)?.qid, norm), normalizeSymbol(byId.get(c.callee)?.qid, norm), { file: c.file ?? null, line: c.line ?? null });
|
|
63
|
+
}
|
|
64
|
+
for (const r of extra.routes || []) add('route', normalizeRouteKey(r.path), normalizeSymbol(r.handler, norm));
|
|
65
|
+
for (const b of graph.serviceBoundaries || []) if (b.from && b.to && b.from.service && b.to.service) add('service', b.from.service, b.to.service);
|
|
66
|
+
return edges;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read the trace file and collect runtime edges with their build links. Never throws on a bad line. */
|
|
70
|
+
export async function readRuntimeEdges(tracePath, opts = {}) {
|
|
71
|
+
const snap = opts.snapshotIdentity ? normalizeIdentity(opts.snapshotIdentity) : null;
|
|
72
|
+
const norm = opts.normalize || {};
|
|
73
|
+
const windowMs = (opts.windowDays || 30) * 86400_000; const now = opts.now ?? Date.now();
|
|
74
|
+
const edges = new Map(); let records = 0;
|
|
75
|
+
const touch = (key, kind, from, to, link) => {
|
|
76
|
+
const e = edges.get(key) || { kind, from, to, matched: 0, unattested: 0, mismatched: 0 };
|
|
77
|
+
e[link === 'matched' ? 'matched' : link === 'mismatched' ? 'mismatched' : 'unattested']++;
|
|
78
|
+
edges.set(key, e);
|
|
79
|
+
};
|
|
80
|
+
const rl = readline.createInterface({ input: fs.createReadStream(tracePath, { encoding: 'utf8' }), crlfDelay: Infinity });
|
|
81
|
+
for await (const line of rl) {
|
|
82
|
+
let r; try { r = JSON.parse(line); } catch { continue; }
|
|
83
|
+
if (!r || typeof r !== 'object') continue;
|
|
84
|
+
const t = r.ts ? Date.parse(r.ts) : NaN;
|
|
85
|
+
if (Number.isFinite(t) && now - t > windowMs) continue;
|
|
86
|
+
records++;
|
|
87
|
+
const link = snap ? linkObservationToSnapshot({ commit: r.commit, buildHash: r.buildHash, imageDigest: r.imageDigest, sbomRef: r.sbomRef, environment: r.environment }, snap).state : 'unknown';
|
|
88
|
+
if (r.kind === 'function-call' && r.caller && r.qid) { const a = normalizeSymbol(r.caller, norm); const b = normalizeSymbol(r.qid, norm); if (a && b) touch(keyOf('call', a, b), 'call', a, b, link); }
|
|
89
|
+
else if (r.kind === 'route-hit' && r.route && r.qid) { const a = normalizeRouteKey(r.route); const b = normalizeSymbol(r.qid, norm); if (a && b) touch(keyOf('route', a, b), 'route', a, b, link); }
|
|
90
|
+
else if (r.kind === 'service-call' && r.service && r.peer) touch(keyOf('service', r.service, r.peer), 'service', r.service, r.peer, link);
|
|
91
|
+
}
|
|
92
|
+
return { edges, records };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {Map} staticEdges from staticEdgesFromGraph
|
|
97
|
+
* @param {{edges:Map, records:number}} runtime from readRuntimeEdges
|
|
98
|
+
* @param {{sampling?:{state:string}}} opts sampling from runtime-correlation.js samplingOf(trace)
|
|
99
|
+
* @returns {{edges:object[], counts:Object<string,number>, limitations:string[]}}
|
|
100
|
+
*/
|
|
101
|
+
export function reconcileGraphs(staticEdges, runtime, opts = {}) {
|
|
102
|
+
const partial = !opts.sampling || opts.sampling.state !== 'complete';
|
|
103
|
+
const out = new Map();
|
|
104
|
+
const finish = (key, state, base, extra) => {
|
|
105
|
+
const factors = [{ name: 'base', value: BASE[state] ?? base }];
|
|
106
|
+
let c = BASE[state] ?? base;
|
|
107
|
+
if (base !== undefined && state === 'statically_inferred') { c = base; factors[0].value = base; }
|
|
108
|
+
if (extra.mismatchedRecords > 0) { c *= 0.5; factors.push({ name: 'records-from-a-different-build', value: 0.5 }); }
|
|
109
|
+
if (partial) { c *= 0.85; factors.push({ name: 'partial-or-undeclared-coverage', value: 0.85 }); }
|
|
110
|
+
out.set(key, { key, state, confidence: Math.round(c * 1000) / 1000, factors, ...extra });
|
|
111
|
+
};
|
|
112
|
+
const rt = runtime ? runtime.edges : new Map();
|
|
113
|
+
for (const [key, s] of staticEdges) {
|
|
114
|
+
const r = rt.get(key);
|
|
115
|
+
const base = s.kind === 'call' ? 0.7 : 0.6;
|
|
116
|
+
const common = { kind: s.kind, from: s.from, to: s.to, mismatchedRecords: r ? r.mismatched : 0, matchedRecords: r ? r.matched : 0, unattestedRecords: r ? r.unattested : 0 };
|
|
117
|
+
if (r && r.matched > 0) finish(key, 'both', base, common);
|
|
118
|
+
else if (r && r.unattested > 0) finish(key, 'unknown', base, { ...common, reason: 'runtime records show this edge but carry no build identity comparable to the snapshot' });
|
|
119
|
+
else finish(key, 'statically_inferred', base, { ...common, ...(r && r.mismatched ? { reason: 'only a different build was observed using this edge' } : {}) });
|
|
120
|
+
}
|
|
121
|
+
// runtime-only edges, and conflicts
|
|
122
|
+
const staticByFrom = new Map();
|
|
123
|
+
for (const s of staticEdges.values()) { const k = `${s.kind}|${s.from}`; (staticByFrom.get(k) || staticByFrom.set(k, []).get(k)).push(s); }
|
|
124
|
+
for (const [key, r] of rt) {
|
|
125
|
+
if (out.has(key)) continue;
|
|
126
|
+
const common = { kind: r.kind, from: r.from, to: r.to, mismatchedRecords: r.mismatched, matchedRecords: r.matched, unattestedRecords: r.unattested };
|
|
127
|
+
if (r.matched === 0 && r.unattested === 0) { finish(key, 'unknown', undefined, { ...common, reason: 'seen only in a different build: not evidence about this snapshot' }); continue; }
|
|
128
|
+
const rivals = (r.kind === 'route' || r.kind === 'call') ? (staticByFrom.get(`${r.kind}|${r.from}`) || []) : [];
|
|
129
|
+
const conflicting = r.matched > 0 && r.kind === 'route' && rivals.length > 0;
|
|
130
|
+
if (conflicting) {
|
|
131
|
+
finish(key, 'conflicted', undefined, { ...common, conflictsWith: rivals.map((x) => keyOf(x.kind, x.from, x.to)), reason: 'the same route reached a different handler than the static graph names' });
|
|
132
|
+
for (const x of rivals) { const k = keyOf(x.kind, x.from, x.to); const cur = out.get(k); if (cur && cur.state !== 'both') finish(k, 'conflicted', undefined, { kind: cur.kind, from: cur.from, to: cur.to, mismatchedRecords: cur.mismatchedRecords, matchedRecords: cur.matchedRecords, unattestedRecords: cur.unattestedRecords, conflictsWith: [key], reason: 'a matched build routed this path to a different handler' }); }
|
|
133
|
+
} else finish(key, 'runtime_observed', undefined, { ...common, ...(r.matched === 0 ? { reason: 'build identity not comparable: observed, not corroborating' } : {}) });
|
|
134
|
+
}
|
|
135
|
+
const edges = [...out.values()].sort((a, b) => (a.key < b.key ? -1 : 1));
|
|
136
|
+
const counts = Object.fromEntries(EDGE_STATES.map((s) => [s, edges.filter((e) => e.state === s).length]));
|
|
137
|
+
return { edges, counts, partialCoverage: partial, limitations: ['Spans are not used: static symbols carry no end line, so matching is by qualified symbol.', 'Route-to-handler static edges exist only when the caller supplies them; the IR has no route entity.', 'Conflicts are detected for routes only; a call edge to a callee the static graph did not resolve is runtime_observed.'] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Can an exploit path be called runtime-corroborated? Only when EVERY edge of it is `both`. A conflicted edge makes the path conflicted; an edge
|
|
142
|
+
* the reconciliation has no entry for counts as uncorroborated.
|
|
143
|
+
* @param {string[]} pathKeys edge keys along the path
|
|
144
|
+
*/
|
|
145
|
+
export function pathCorroboration(reconciled, pathKeys) {
|
|
146
|
+
const byKey = new Map(reconciled.edges.map((e) => [e.key, e]));
|
|
147
|
+
const states = pathKeys.map((k) => (byKey.get(k) || { state: 'statically_inferred' }).state);
|
|
148
|
+
if (!states.length) return { state: 'uncorroborated', reason: 'the path has no edges' };
|
|
149
|
+
if (states.includes('conflicted')) return { state: 'conflicted' };
|
|
150
|
+
if (states.every((s) => s === 'both')) return { state: 'corroborated' };
|
|
151
|
+
return { state: states.some((s) => s === 'both') ? 'partial' : 'uncorroborated', missing: pathKeys.filter((k, i) => states[i] !== 'both') };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { keyOf as edgeKey };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// How much of the work of attributing findings to commits was actually possible, and how much of that was done (E7-02).
|
|
2
|
+
//
|
|
3
|
+
// A single "attributed / total" rate mixes two different things: findings the pipeline could have attributed and did not (a gap in the pipeline), and findings no
|
|
4
|
+
// pipeline could attribute (a file git ignores, generated code, a file git does not track, a checkout with no history). Counting the second kind as failures
|
|
5
|
+
// punishes the tool for the repository, and hiding them inflates the rate. This module keeps them apart and states both denominators:
|
|
6
|
+
//
|
|
7
|
+
// attributed complete: a commit introduced it. uncommitted: it is in the working tree and in no commit yet (resolved, but not to a commit)
|
|
8
|
+
// ineligible ignored, generated, untracked, or history-unavailable (no repository, git failed, or a shallow clone cut the history short)
|
|
9
|
+
// eligible, unattributed the pipeline could have and did not, with its reason: budget_exhausted, project-wide (a whole-project analysis that a
|
|
10
|
+
// single-file replay cannot reproduce), unconfirmed (the finding's condition was not confirmed in the history it could see),
|
|
11
|
+
// not_available, or error
|
|
12
|
+
//
|
|
13
|
+
// `eligibleRate` = attributed / (attributed + eligible unattributed). It is a rate over what attribution could reach, and it is reported next to `overallRate`
|
|
14
|
+
// (attributed / all) so neither can stand in for the other. The target of 95% is aspirational: `targetStatus` says `met`, `not_met`, or
|
|
15
|
+
// `insufficient_eligible` when there are too few eligible findings (under 30) for a rate to mean anything. Nothing here invents an author or a date.
|
|
16
|
+
import * as cp from 'node:child_process';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import { hardenGitArgs, hardenGitEnv } from '../../util/git-hardening.js';
|
|
19
|
+
|
|
20
|
+
export const ATTRIBUTION_TARGET = 0.95;
|
|
21
|
+
export const MIN_ELIGIBLE_FOR_RATE = 30;
|
|
22
|
+
export const ATTRIBUTION_CATEGORIES = Object.freeze([
|
|
23
|
+
'attributed', 'uncommitted',
|
|
24
|
+
'ineligible:ignored', 'ineligible:generated', 'ineligible:untracked', 'ineligible:history-unavailable',
|
|
25
|
+
'eligible:budget_exhausted', 'eligible:project-wide', 'eligible:unconfirmed', 'eligible:not_available', 'eligible:error',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Findings made by a pass over the WHOLE project (a declared set of states found in one file and violated in another, a race between two files, a prompt
|
|
29
|
+
// stored in one file and used in another, a call between services). Replaying a single file at an old commit cannot reproduce them, so the origin walk ends
|
|
30
|
+
// "never confirmed" for every one. They are reported under their own name because the remedy (a project-scoped replay) is different from the remedy for
|
|
31
|
+
// an ordinary unconfirmed finding.
|
|
32
|
+
const PROJECT_WIDE_PARSERS = new Set(['STATE-MACHINE', 'CONCURRENCY', 'LLM-STORED-PROMPT-XFILE', 'CROSS-SERVICE', 'CROSS-REPO']);
|
|
33
|
+
const PROJECT_WIDE_FAMILIES = new Set(['spec-drift', 'state-machine-bypass', 'concurrency-bug']);
|
|
34
|
+
|
|
35
|
+
const GENERATED_PATH = /(?:^|\/)(?:generated|gen|__generated__|dist|build|vendor|node_modules)\/|\.min\.(?:js|css)$|\.pb\.(?:go|js|ts|py)$|_pb2(?:_grpc)?\.py$|\.g\.dart$|\.generated\.\w+$/i;
|
|
36
|
+
const GENERATED_HEADER = /@generated\b|\bDO NOT EDIT\b|\bCode generated by\b|\bauto-?generated\b.*\bdo not (?:edit|modify)\b/i;
|
|
37
|
+
|
|
38
|
+
const git = (root, args, input) => {
|
|
39
|
+
try { return { ok: true, out: cp.execFileSync('git', hardenGitArgs(args), { cwd: root, encoding: 'utf8', input, timeout: 30000, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024, env: hardenGitEnv() }) }; }
|
|
40
|
+
catch (e) { return { ok: false, out: '', error: e }; }
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** What git says about a set of files: tracked, ignored, and whether there is usable history. @param {string[]} files paths relative to `root` */
|
|
44
|
+
export function inspectRepository(root, files) {
|
|
45
|
+
const inside = git(root, ['rev-parse', '--is-inside-work-tree']);
|
|
46
|
+
if (!inside.ok || inside.out.trim() !== 'true') return { repo: false, shallow: false, tracked: new Set(), ignored: new Set() };
|
|
47
|
+
const shallow = git(root, ['rev-parse', '--is-shallow-repository']).out.trim() === 'true';
|
|
48
|
+
const head = git(root, ['rev-parse', '--verify', 'HEAD']);
|
|
49
|
+
const prefix = git(root, ['rev-parse', '--show-prefix']).out.trim();
|
|
50
|
+
const tracked = new Set(git(root, ['ls-files', '-z']).out.split('\0').filter(Boolean).map((p) => (prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p)));
|
|
51
|
+
const ignored = new Set();
|
|
52
|
+
const list = [...new Set(files)].filter((f) => !tracked.has(f));
|
|
53
|
+
if (list.length) {
|
|
54
|
+
const r = git(root, ['check-ignore', '--stdin', '-z'], list.join('\0') + '\0');
|
|
55
|
+
for (const p of r.out.split('\0').filter(Boolean)) ignored.add(p);
|
|
56
|
+
}
|
|
57
|
+
return { repo: true, shallow, hasHead: head.ok, tracked, ignored };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const normalise = (root, f) => { const rel = path.isAbsolute(f) ? path.relative(root, f) : f; return rel.split(path.sep).join('/'); };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {object[]} findings findings carrying `file` and `findingProvenance`
|
|
64
|
+
* @param {{root:string, readHeader?:(file:string)=>string|null, repo?:object}} ctx `readHeader` returns the first lines of a file (for generated-code markers)
|
|
65
|
+
* @returns {{categories:Object<string,number>, total:number, attributed:number, ineligible:number, eligible:number, eligibleUnattributed:number, overallRate:number|null,
|
|
66
|
+
* eligibleRate:number|null, target:number, targetStatus:string, perFinding:{id:string, file:string|null, category:string, reason:string|null}[]}}
|
|
67
|
+
*/
|
|
68
|
+
export function attributionCoverage(findings, { root, readHeader = null, repo = null } = {}) {
|
|
69
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
70
|
+
const files = list.map((f) => (f && typeof f.file === 'string' ? normalise(root || '.', f.file) : null)).filter(Boolean);
|
|
71
|
+
const info = repo || inspectRepository(root || '.', files);
|
|
72
|
+
const categories = Object.fromEntries(ATTRIBUTION_CATEGORIES.map((c) => [c, 0]));
|
|
73
|
+
const perFinding = [];
|
|
74
|
+
for (const f of list) {
|
|
75
|
+
const file = f && typeof f.file === 'string' ? normalise(root || '.', f.file) : null;
|
|
76
|
+
const fp = f && f.findingProvenance;
|
|
77
|
+
const status = fp && fp.status;
|
|
78
|
+
let category; let reason = null;
|
|
79
|
+
if (status === 'complete') category = 'attributed';
|
|
80
|
+
else if (status === 'uncommitted') category = 'uncommitted';
|
|
81
|
+
else if (!info.repo) { category = 'ineligible:history-unavailable'; reason = 'the directory is not inside a git work tree'; }
|
|
82
|
+
else if (file && info.ignored.has(file)) { category = 'ineligible:ignored'; reason = 'git ignores this file'; }
|
|
83
|
+
else if (file && (GENERATED_PATH.test(file) || (readHeader && GENERATED_HEADER.test(readHeader(file) || '')))) { category = 'ineligible:generated'; reason = 'the file is generated code, not authored'; }
|
|
84
|
+
else if (file && !info.tracked.has(file)) { category = 'ineligible:untracked'; reason = 'git does not track this file'; }
|
|
85
|
+
else if (info.hasHead === false) { category = 'ineligible:history-unavailable'; reason = 'the repository has no commits'; }
|
|
86
|
+
else if (info.shallow && (status === 'partial' || status === 'not_available') && fp && fp.historyCoverage && fp.historyCoverage.shallow === true) { category = 'ineligible:history-unavailable'; reason = 'a shallow clone cut the history short'; }
|
|
87
|
+
else if (status === 'budget_exhausted') { category = 'eligible:budget_exhausted'; reason = (fp.limitations || [])[0] || 'the analysis budget ran out'; }
|
|
88
|
+
else if (status === 'partial' && (PROJECT_WIDE_PARSERS.has(f.parser) || PROJECT_WIDE_FAMILIES.has(f.family))) { category = 'eligible:project-wide'; reason = 'the finding comes from a whole-project analysis; replaying one file at an old commit cannot reproduce it'; }
|
|
89
|
+
else if (status === 'partial') { category = 'eligible:unconfirmed'; reason = (fp.limitations || [])[0] || 'the finding was not confirmed in the history that could be seen'; }
|
|
90
|
+
else if (status === 'error') { category = 'eligible:error'; reason = (fp.limitations || [])[0] || 'resolution failed'; }
|
|
91
|
+
else { category = 'eligible:not_available'; reason = (fp && (fp.limitations || [])[0]) || 'no provenance was recorded for this finding'; }
|
|
92
|
+
categories[category]++;
|
|
93
|
+
perFinding.push({ id: String((f && (f.stableId || f.id)) ?? ''), file, category, reason });
|
|
94
|
+
}
|
|
95
|
+
const total = list.length;
|
|
96
|
+
const attributed = categories.attributed + categories.uncommitted;
|
|
97
|
+
const ineligible = Object.entries(categories).filter(([k]) => k.startsWith('ineligible:')).reduce((a, [, n]) => a + n, 0);
|
|
98
|
+
const eligibleUnattributed = Object.entries(categories).filter(([k]) => k.startsWith('eligible:')).reduce((a, [, n]) => a + n, 0);
|
|
99
|
+
const eligible = attributed + eligibleUnattributed;
|
|
100
|
+
const eligibleRate = eligible === 0 ? null : Number((attributed / eligible).toFixed(4));
|
|
101
|
+
const targetStatus = eligible < MIN_ELIGIBLE_FOR_RATE ? 'insufficient_eligible' : eligibleRate >= ATTRIBUTION_TARGET ? 'met' : 'not_met';
|
|
102
|
+
return { categories, total, attributed, ineligible, eligible, eligibleUnattributed, overallRate: total === 0 ? null : Number((attributed / total).toFixed(4)), eligibleRate, target: ATTRIBUTION_TARGET, targetStatus, perFinding };
|
|
103
|
+
}
|