@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.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 +46 -0
- package/bin/agentic-security.js +98 -2
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +59 -8
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +38 -2
- package/dist/8798.index.js +1 -1
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +2336 -27
- package/dist/957.index.js +1 -1
- package/dist/{1905.index.js → 9999.index.js} +306 -13
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +39 -7
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +18 -0
- package/src/discovery/index.js +43 -1
- package/src/discovery/lenses.js +18 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +80 -15
- package/src/fix/apply-fix-service.js +33 -3
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +14 -3
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +30 -5
- package/src/posture/CLAUDE.md +505 -0
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +531 -0
- package/src/posture/oracles/conformance.js +276 -0
- package/src/posture/oracles/oracle.js +453 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +250 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +226 -0
- package/src/posture/verification/patch-promotion.js +113 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +100 -1
- package/src/sandbox/CLAUDE.md +51 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +151 -34
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// cross-repo.js: resolve deployment boundaries across repositories (X-304).
|
|
2
|
+
//
|
|
3
|
+
// Extends the idea behind cross-repo-link.js (an operator declares that a node
|
|
4
|
+
// in one repository depends on a node in another) from the data-flow graph to
|
|
5
|
+
// the deployment boundary graph, and adds the two things that file's
|
|
6
|
+
// declared-link flavour did not need: an exact revision on both ends, and an
|
|
7
|
+
// access scope.
|
|
8
|
+
//
|
|
9
|
+
// Inputs are all local and all explicit: boundary graph exports the operator
|
|
10
|
+
// supplies (one per repository), a list of declared dependencies, and the set
|
|
11
|
+
// of repositories the operator is authorized to read. This module has no
|
|
12
|
+
// network access and no way to fetch a missing repository: an unavailable one
|
|
13
|
+
// is a gap, not a lookup. The source file contains no import of any network or
|
|
14
|
+
// process module, and a test pins that.
|
|
15
|
+
//
|
|
16
|
+
// A link resolves only when every one of these holds, each tested in both
|
|
17
|
+
// directions:
|
|
18
|
+
// - both repositories are in the access scope and a graph was supplied for
|
|
19
|
+
// each (otherwise: access-denied / repository-unavailable);
|
|
20
|
+
// - each supplied graph passes validation and says it is the repository it
|
|
21
|
+
// was supplied as (otherwise: graph-integrity / identity-collision);
|
|
22
|
+
// - the graph's revision equals the revision the declaration binds, exactly
|
|
23
|
+
// (otherwise: version-mismatch, or revision-unknown when the graph has none);
|
|
24
|
+
// - the declared service exists, resolved, in the graph of the repository it
|
|
25
|
+
// is declared in (otherwise: unresolved-identity), and no second supplied
|
|
26
|
+
// repository also resolves that identity (otherwise: identity-collision);
|
|
27
|
+
// - a declared entry route exists in the callee graph and reaches the callee
|
|
28
|
+
// service (otherwise the link stays unresolved; no route is guessed).
|
|
29
|
+
//
|
|
30
|
+
// An unresolved link is not dropped. It is kept in `links` with every gap code
|
|
31
|
+
// that blocked it, and, when both revisions are bound by the declaration, as
|
|
32
|
+
// an unresolved edge to an unresolved callee node in the federated graph, so
|
|
33
|
+
// the dependency stays visible to path queries as a path nobody could verify.
|
|
34
|
+
|
|
35
|
+
import { semanticId, digestOf } from '../../posture/assurance/identity.js';
|
|
36
|
+
import {
|
|
37
|
+
buildBoundaryGraph, importBoundaryGraph, validateBoundaryGraph, nodeIdOf, indexGraph, findPath, boundaryCrossings,
|
|
38
|
+
} from './boundary-graph.js';
|
|
39
|
+
|
|
40
|
+
export const CROSS_REPO_PARSER = 'cross-repo-declaration';
|
|
41
|
+
export const CROSS_REPO_PARSER_VERSION = '1';
|
|
42
|
+
export const DEFAULT_DECLARATION_FILE = 'cross-repo-declarations.json';
|
|
43
|
+
|
|
44
|
+
const REV = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
45
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
46
|
+
const SVC = /^[^\u0000-\u001f\u007f]{1,256}$/;
|
|
47
|
+
|
|
48
|
+
function isObj(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
|
|
49
|
+
|
|
50
|
+
/** Check one declaration's shape. Returns an error message or null. Pure. */
|
|
51
|
+
export function checkDeclaration(d) {
|
|
52
|
+
if (!isObj(d)) return 'declaration must be an object';
|
|
53
|
+
for (const side of ['caller', 'callee']) {
|
|
54
|
+
const s = d[side];
|
|
55
|
+
if (!isObj(s)) return `${side} is required`;
|
|
56
|
+
if (typeof s.repository !== 'string' || !NAME.test(s.repository)) return `${side}.repository must be a repository name`;
|
|
57
|
+
if (typeof s.revision !== 'string' || !REV.test(s.revision)) return `${side}.revision must be an exact 40 or 64 hex commit`;
|
|
58
|
+
if (typeof s.service !== 'string' || !SVC.test(s.service)) return `${side}.service must be a service name`;
|
|
59
|
+
if (typeof s.environment !== 'string' || !NAME.test(s.environment)) return `${side}.environment must be an environment name`;
|
|
60
|
+
if (s.tenant !== undefined && s.tenant !== null && (typeof s.tenant !== 'string' || !NAME.test(s.tenant))) return `${side}.tenant must be null or a name`;
|
|
61
|
+
}
|
|
62
|
+
if (d.caller.environment !== d.callee.environment) return 'a cross-repository link cannot join two environments';
|
|
63
|
+
if ((d.caller.tenant ?? null) !== (d.callee.tenant ?? null)) return 'a cross-repository link declares one tenant; a tenant transition needs its own declaration of both sides';
|
|
64
|
+
if (d.callee.route !== undefined && d.callee.route !== null) {
|
|
65
|
+
const r = d.callee.route;
|
|
66
|
+
if (!isObj(r) || (r.host !== undefined && typeof r.host !== 'string') || (r.path !== undefined && typeof r.path !== 'string')) return 'callee.route must be {host?, path?}';
|
|
67
|
+
if (r.host === undefined && r.path === undefined) return 'callee.route needs a host or a path';
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function declarationId(d) {
|
|
73
|
+
return semanticId('blink', {
|
|
74
|
+
callerRepository: d?.caller?.repository, callerRevision: d?.caller?.revision, callerService: d?.caller?.service,
|
|
75
|
+
calleeRepository: d?.callee?.repository, calleeRevision: d?.callee?.revision, calleeService: d?.callee?.service,
|
|
76
|
+
environment: d?.caller?.environment, tenant: d?.caller?.tenant ?? null, route: d?.callee?.route ?? null,
|
|
77
|
+
}, ['callerRepository', 'callerRevision', 'callerService', 'calleeRepository', 'calleeRevision', 'calleeService', 'environment', 'tenant', 'route']);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pathUnder(prefix, p) {
|
|
81
|
+
if (typeof prefix !== 'string' || typeof p !== 'string') return false;
|
|
82
|
+
if (prefix === '/') return true;
|
|
83
|
+
return p === prefix || p.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {object} p
|
|
88
|
+
* @param {Array<{repository: string, text?: string, graph?: object}>} p.graphs supplied exports, one per repository
|
|
89
|
+
* @param {object[]} p.declarations
|
|
90
|
+
* @param {{authorizedRepositories: string[]}} p.accessScope
|
|
91
|
+
* @param {{file?: string}} [p.declarationSource]
|
|
92
|
+
*/
|
|
93
|
+
export function resolveCrossRepoBoundaries({ graphs = [], declarations = [], accessScope, declarationSource = {} } = {}) {
|
|
94
|
+
const gaps = [];
|
|
95
|
+
const gap = (code, subject, message) => gaps.push({ code, subject, file: null, message });
|
|
96
|
+
if (!isObj(accessScope) || !Array.isArray(accessScope.authorizedRepositories)) {
|
|
97
|
+
return { status: 'invalid-input', reason: 'accessScope.authorizedRepositories is required; with no scope nothing is authorized', graph: null, links: [], gaps: [] };
|
|
98
|
+
}
|
|
99
|
+
const authorized = new Set(accessScope.authorizedRepositories.filter(r => typeof r === 'string'));
|
|
100
|
+
const declFile = declarationSource.file ?? DEFAULT_DECLARATION_FILE;
|
|
101
|
+
|
|
102
|
+
// ---- load the supplied graphs
|
|
103
|
+
const supplied = new Map(); // repository -> { graph, index } for usable ones
|
|
104
|
+
const claimed = new Map(); // repository -> count of supplied entries claiming it
|
|
105
|
+
for (const g of graphs) if (isObj(g) && typeof g.repository === 'string') claimed.set(g.repository, (claimed.get(g.repository) ?? 0) + 1);
|
|
106
|
+
const usable = [];
|
|
107
|
+
for (const entry of graphs) {
|
|
108
|
+
if (!isObj(entry) || typeof entry.repository !== 'string') { gap('malformed-input', 'graphs', 'a supplied graph without a repository name is ignored'); continue; }
|
|
109
|
+
const repo = entry.repository;
|
|
110
|
+
if (!authorized.has(repo)) { gap('access-denied', repo, `repository '${repo}' is not in the access scope; its graph was supplied but is not read`); continue; }
|
|
111
|
+
if (claimed.get(repo) > 1) { gap('identity-collision', repo, `${claimed.get(repo)} supplied graphs claim to be repository '${repo}'; none is used`); continue; }
|
|
112
|
+
let graph;
|
|
113
|
+
if (typeof entry.text === 'string') {
|
|
114
|
+
const imp = importBoundaryGraph(entry.text);
|
|
115
|
+
if (imp.status !== 'ok') { gap('graph-integrity', repo, `the graph supplied for '${repo}' is ${imp.status}: ${imp.reason ?? imp.errors.map(e => e.message).slice(0, 3).join('; ')}`); continue; }
|
|
116
|
+
graph = imp.graph;
|
|
117
|
+
} else if (isObj(entry.graph)) {
|
|
118
|
+
const v = validateBoundaryGraph(entry.graph);
|
|
119
|
+
if (!v.ok) { gap('graph-integrity', repo, `the graph supplied for '${repo}' failed validation: ${v.errors.slice(0, 3).map(e => e.message).join('; ')}`); continue; }
|
|
120
|
+
graph = entry.graph;
|
|
121
|
+
} else { gap('malformed-input', repo, 'a supplied graph needs text or graph'); continue; }
|
|
122
|
+
if (graph.repository !== repo) { gap('identity-collision', repo, `the graph supplied as '${repo}' says it is repository '${graph.repository}'; it is not used`); continue; }
|
|
123
|
+
supplied.set(repo, { graph, index: indexGraph(graph) });
|
|
124
|
+
usable.push(graph);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- resolve declarations
|
|
128
|
+
const links = [];
|
|
129
|
+
const crossEdges = [];
|
|
130
|
+
const extraNodes = [];
|
|
131
|
+
const sortedDecls = declarations.map((d, i) => ({ d, i })).sort((a, b) => (declarationId(a.d) < declarationId(b.d) ? -1 : declarationId(a.d) > declarationId(b.d) ? 1 : a.i - b.i));
|
|
132
|
+
const declDigest = digestOf(sortedDecls.map(x => x.d)); // order independent: the same declarations give the same evidence digest
|
|
133
|
+
for (const { d } of sortedDecls) {
|
|
134
|
+
const bad = checkDeclaration(d);
|
|
135
|
+
if (bad) { gap('malformed-input', 'declaration', bad); links.push({ declarationId: null, status: 'invalid', gapCodes: ['malformed-input'], evidence: null, transitions: null, edgeId: null }); continue; }
|
|
136
|
+
const id = declarationId(d);
|
|
137
|
+
const blocked = [];
|
|
138
|
+
const block = (code, message) => { blocked.push(code); gap(code, id, message); };
|
|
139
|
+
const env = d.caller.environment, tenant = d.caller.tenant ?? null;
|
|
140
|
+
|
|
141
|
+
const callerOk = authorized.has(d.caller.repository), calleeOk = authorized.has(d.callee.repository);
|
|
142
|
+
if (!callerOk) block('access-denied', `caller repository '${d.caller.repository}' is not in the access scope`);
|
|
143
|
+
if (!calleeOk) block('access-denied', `callee repository '${d.callee.repository}' is not in the access scope`);
|
|
144
|
+
const callerG = callerOk ? supplied.get(d.caller.repository) : null;
|
|
145
|
+
const calleeG = calleeOk ? supplied.get(d.callee.repository) : null;
|
|
146
|
+
if (callerOk && !callerG) block('repository-unavailable', `no usable graph was supplied for caller repository '${d.caller.repository}'`);
|
|
147
|
+
if (calleeOk && !calleeG) block('repository-unavailable', `no usable graph was supplied for callee repository '${d.callee.repository}'`);
|
|
148
|
+
|
|
149
|
+
for (const [side, g, decl] of [['caller', callerG, d.caller], ['callee', calleeG, d.callee]]) {
|
|
150
|
+
if (!g) continue;
|
|
151
|
+
if (g.graph.revision === null) block('revision-unknown', `the ${side} graph for '${decl.repository}' records no revision, so the declared revision cannot be verified`);
|
|
152
|
+
else if (g.graph.revision !== decl.revision) block('version-mismatch', `the ${side} graph for '${decl.repository}' is at ${g.graph.revision.slice(0, 12)}, the declaration binds ${decl.revision.slice(0, 12)}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const callerNodeId = nodeIdOf({ kind: 'service', name: d.caller.service, environment: env, tenant });
|
|
156
|
+
const calleeNodeId = nodeIdOf({ kind: 'service', name: d.callee.service, environment: env, tenant });
|
|
157
|
+
let callerNode = null, calleeNode = null;
|
|
158
|
+
if (callerG && !blocked.length) {
|
|
159
|
+
callerNode = callerG.index.nodesById.get(callerNodeId);
|
|
160
|
+
if (!callerNode || !callerNode.resolved) block('unresolved-identity', `service '${d.caller.service}' (${env}) is not a resolved service in '${d.caller.repository}' at the bound revision`);
|
|
161
|
+
else if (callerNode.repository !== null && callerNode.repository !== d.caller.repository) block('identity-collision', `service '${d.caller.service}' is owned by '${callerNode.repository}', not '${d.caller.repository}'`);
|
|
162
|
+
}
|
|
163
|
+
if (calleeG && !blocked.length) {
|
|
164
|
+
calleeNode = calleeG.index.nodesById.get(calleeNodeId);
|
|
165
|
+
if (!calleeNode || !calleeNode.resolved) block('unresolved-identity', `service '${d.callee.service}' (${env}) is not a resolved service in '${d.callee.repository}' at the bound revision`);
|
|
166
|
+
else if (calleeNode.repository !== null && calleeNode.repository !== d.callee.repository) block('identity-collision', `service '${d.callee.service}' is owned by '${calleeNode.repository}', not '${d.callee.repository}'`);
|
|
167
|
+
else {
|
|
168
|
+
const others = [...supplied.entries()].filter(([repo, s]) => repo !== d.callee.repository && s.index.nodesById.get(calleeNodeId)?.resolved);
|
|
169
|
+
if (others.length) block('identity-collision', `service '${d.callee.service}' (${env}) is also defined by ${others.map(([r]) => `'${r}'`).sort().join(', ')}; the callee identity is ambiguous`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// route and privilege transitions
|
|
174
|
+
let routeNode = null, routeChain = null;
|
|
175
|
+
if (!blocked.length && d.callee.route) {
|
|
176
|
+
const want = d.callee.route;
|
|
177
|
+
const candidates = calleeG.graph.nodes.filter(n => n.kind === 'route' && n.environment === env && (n.tenant ?? null) === tenant
|
|
178
|
+
&& (want.host === undefined || n.attrs.host === want.host || n.attrs.host === '*') && (want.path === undefined || pathUnder(n.attrs.path, want.path)));
|
|
179
|
+
for (const r of candidates.sort((a, b) => (a.id < b.id ? -1 : 1))) {
|
|
180
|
+
const chain = findPath(calleeG.index.out, r.id, calleeNodeId, { accept: (e) => e.relation === 'routes-to' && e.pathState !== 'blocked', maxHops: 4 });
|
|
181
|
+
if (chain) { routeNode = r; routeChain = chain; break; }
|
|
182
|
+
}
|
|
183
|
+
if (!routeNode) block('unresolved-identity', `no route matching ${JSON.stringify(want)} reaches '${d.callee.service}' in '${d.callee.repository}'`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const crossRepo = { callerRepository: d.caller.repository, callerRevision: d.caller.revision, calleeRepository: d.callee.repository, calleeRevision: d.callee.revision };
|
|
187
|
+
const source = { file: declFile, digest: declDigest, parser: CROSS_REPO_PARSER, parserVersion: CROSS_REPO_PARSER_VERSION };
|
|
188
|
+
const baseEdge = { relation: 'calls', from: callerNodeId, environment: env, tenant, effect: 'none', provenance: 'static-config', confidence: 'high', observationInterval: null, completeness: null, sourceRevision: d.caller.revision, source, discriminator: `xrepo:${d.callee.repository}`, crossRepo, attrs: {} };
|
|
189
|
+
|
|
190
|
+
if (blocked.length) {
|
|
191
|
+
links.push({ declarationId: id, status: 'unresolved', caller: { ...d.caller }, callee: { ...d.callee }, gapCodes: [...new Set(blocked)].sort(), evidence: null, transitions: null, edgeId: null });
|
|
192
|
+
// Keep the dependency visible as an unresolved path, bound to the revisions the operator declared.
|
|
193
|
+
extraNodes.push(
|
|
194
|
+
{ kind: 'service', name: d.caller.service, environment: env, tenant, repository: null, trustZone: 'unknown', resolved: false, unresolvedReason: 'cross-repository caller could not be verified', attrs: {}, declaredBy: [] },
|
|
195
|
+
{ kind: 'service', name: d.callee.service, environment: env, tenant, repository: null, trustZone: 'unknown', resolved: false, unresolvedReason: `cross-repository callee could not be resolved (${[...new Set(blocked)].sort().join(', ')})`, attrs: {}, declaredBy: [] },
|
|
196
|
+
);
|
|
197
|
+
crossEdges.push({ ...baseEdge, to: calleeNodeId, pathState: 'unresolved', confidence: 'low' });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const targetId = routeNode ? routeNode.id : calleeNodeId;
|
|
202
|
+
const edge = { ...baseEdge, to: targetId, pathState: 'possible' };
|
|
203
|
+
crossEdges.push(edge);
|
|
204
|
+
|
|
205
|
+
const calleeAssumes = calleeG.graph.edges.filter(e => e.relation === 'assumes' && e.from === calleeNodeId);
|
|
206
|
+
const callerAssumes = callerG.graph.edges.filter(e => e.relation === 'assumes' && e.from === callerNodeId);
|
|
207
|
+
const privileges = calleeAssumes.map(a => ({
|
|
208
|
+
identityNodeId: a.to,
|
|
209
|
+
identity: calleeG.index.nodesById.get(a.to)?.name ?? null,
|
|
210
|
+
identityResolved: calleeG.index.nodesById.get(a.to)?.resolved ?? false,
|
|
211
|
+
assumesEdgeId: a.id,
|
|
212
|
+
grants: calleeG.graph.edges.filter(e => e.relation === 'grants' && e.from === a.to)
|
|
213
|
+
.map(e => ({ edgeId: e.id, resource: calleeG.index.nodesById.get(e.to)?.name ?? null, effect: e.effect, pathState: e.pathState, discriminator: e.discriminator }))
|
|
214
|
+
.sort((x, y) => (x.edgeId < y.edgeId ? -1 : 1)),
|
|
215
|
+
})).sort((x, y) => (x.identityNodeId < y.identityNodeId ? -1 : 1));
|
|
216
|
+
const nodeRefs = (g, nodeId) => (g.index.nodesById.get(nodeId)?.declaredBy ?? []).map(r => ({ ...r }));
|
|
217
|
+
links.push({
|
|
218
|
+
declarationId: id,
|
|
219
|
+
status: 'resolved',
|
|
220
|
+
caller: { ...d.caller }, callee: { ...d.callee },
|
|
221
|
+
gapCodes: [],
|
|
222
|
+
edgeId: null, // filled once the federated graph assigns ids
|
|
223
|
+
evidence: {
|
|
224
|
+
declaration: { file: declFile, digest: declDigest },
|
|
225
|
+
caller: { repository: d.caller.repository, revision: d.caller.revision, graphDigest: callerG.graph.digest, nodeId: callerNodeId, declaredBy: nodeRefs(callerG, callerNodeId) },
|
|
226
|
+
callee: { repository: d.callee.repository, revision: d.callee.revision, graphDigest: calleeG.graph.digest, nodeId: calleeNodeId, declaredBy: nodeRefs(calleeG, calleeNodeId) },
|
|
227
|
+
route: routeNode ? { nodeId: routeNode.id, name: routeNode.name, declaredBy: nodeRefs(calleeG, routeNode.id), chainEdgeIds: routeChain.map(e => e.id) } : null,
|
|
228
|
+
},
|
|
229
|
+
transitions: {
|
|
230
|
+
entry: routeNode ? { routeNodeId: routeNode.id, trustZone: routeNode.trustZone } : null,
|
|
231
|
+
callerIdentities: callerAssumes.map(a => ({ identityNodeId: a.to, identity: callerG.index.nodesById.get(a.to)?.name ?? null })).sort((x, y) => (x.identityNodeId < y.identityNodeId ? -1 : 1)),
|
|
232
|
+
calleePrivileges: privileges,
|
|
233
|
+
},
|
|
234
|
+
_edge: edge,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- federated graph
|
|
239
|
+
const nodes = [...usable.flatMap(g => g.nodes), ...extraNodes];
|
|
240
|
+
const edges = [...usable.flatMap(g => g.edges), ...crossEdges];
|
|
241
|
+
const sources = [...usable.flatMap(g => g.sources), { file: declFile, digest: declDigest, parser: CROSS_REPO_PARSER, parserVersion: CROSS_REPO_PARSER_VERSION, bytes: JSON.stringify(declarations).length }];
|
|
242
|
+
const built = buildBoundaryGraph({ repository: null, revision: null, nodes, edges, gaps: [...usable.flatMap(g => g.gaps), ...gaps], sources });
|
|
243
|
+
if (!built.ok) return { status: 'invalid-graph', reason: 'the federated graph failed validation', graph: null, links: links.map(({ _edge, ...l }) => l), gaps, errors: built.errors };
|
|
244
|
+
|
|
245
|
+
// attach edge ids and boundary crossings to resolved links
|
|
246
|
+
const fIndex = indexGraph(built.graph);
|
|
247
|
+
const finalLinks = links.map(({ _edge, ...l }) => {
|
|
248
|
+
if (l.status !== 'resolved') return l;
|
|
249
|
+
const match = built.graph.edges.find(e => e.crossRepo && e.relation === 'calls' && e.from === _edge.from && e.to === _edge.to && e.crossRepo.calleeRepository === _edge.crossRepo.calleeRepository && e.crossRepo.calleeRevision === _edge.crossRepo.calleeRevision && e.crossRepo.callerRevision === _edge.crossRepo.callerRevision);
|
|
250
|
+
return { ...l, edgeId: match?.id ?? null, transitions: { ...l.transitions, crossings: match ? boundaryCrossings(match, fIndex.nodesById) : [] } };
|
|
251
|
+
});
|
|
252
|
+
return { status: 'ok', reason: null, graph: built.graph, links: finalLinks, gaps: built.graph.gaps };
|
|
253
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// drift.js: boundary drift between two revisions, with dependency-aware invalidation and impact-driven rescans (X-306).
|
|
2
|
+
//
|
|
3
|
+
// Four jobs, all pure (graphs in, records out; no clock, no file or network access):
|
|
4
|
+
//
|
|
5
|
+
// 1. `diffBoundaryGraphs` compares two validated graphs at stable node and edge ids and names what changed in terms a
|
|
6
|
+
// reviewer cares about: new exposure, changed privilege, tenant-boundary weakening, removed controls. Those four are
|
|
7
|
+
// `material`. Every other change to a non-structural relation is reported too (it still changes how a finding is
|
|
8
|
+
// conditioned) but is not material. It also diffs the SOURCES the two graphs were built from and names the edges each
|
|
9
|
+
// changed file supported, so a drift can be explained from the file that moved.
|
|
10
|
+
// 2. `planRescan` maps each change to the hypotheses (findings) it can affect and lists the focused rescans. A hypothesis is
|
|
11
|
+
// affected only when its bound service is the service a change touches, or can reach it, or is reachable from it, in
|
|
12
|
+
// either revision. Unchanged components are not rescanned. A hypothesis that cannot be tied to a service cannot be proven
|
|
13
|
+
// independent of a change, so it makes the plan INCOMPLETE; so does a graph gap that stands for a missing dependency.
|
|
14
|
+
// 3. `incrementalAnnotate` recomputes only the affected hypotheses and carries the rest forward. `compareScoped` then lets a
|
|
15
|
+
// test (or a caller) check the incremental result against a complete recomputation. Equivalence is the property that
|
|
16
|
+
// justifies not rescanning; an incomplete plan never claims it.
|
|
17
|
+
// 4. `invalidatePathsForSources` drops exactly the paths a changed source file supported (through the edges and node
|
|
18
|
+
// declarations that cite it), reusing `invalidateChangedSources` for the graph itself.
|
|
19
|
+
|
|
20
|
+
import { indexGraph, validateBoundaryGraph } from './boundary-graph.js';
|
|
21
|
+
import { invalidateChangedSources } from './ingest.js';
|
|
22
|
+
import { bindFindingToService, exposureReach, TRAVERSED_RELATIONS } from './attack-paths.js';
|
|
23
|
+
import { semanticId, digestOf, hypothesisIdFromFinding } from '../../posture/assurance/identity.js';
|
|
24
|
+
|
|
25
|
+
export const DRIFT_SCHEMA = 'agentic-security/boundary-drift';
|
|
26
|
+
export const DRIFT_VERSION = '1.0.0';
|
|
27
|
+
|
|
28
|
+
export const DRIFT_CATEGORIES = Object.freeze([
|
|
29
|
+
'new-exposure', 'changed-privilege', 'tenant-weakening', 'removed-control',
|
|
30
|
+
'control-added', 'privilege-reduced', 'exposure-reduced', 'connectivity-added', 'connectivity-removed', 'edge-changed',
|
|
31
|
+
'zone-changed', 'node-resolution-changed', 'service-added', 'service-removed',
|
|
32
|
+
]);
|
|
33
|
+
export const MATERIAL_CATEGORIES = Object.freeze(['new-exposure', 'changed-privilege', 'tenant-weakening', 'removed-control']);
|
|
34
|
+
const MATERIAL = new Set(MATERIAL_CATEGORIES);
|
|
35
|
+
|
|
36
|
+
// A gap of these kinds means a dependency of the graph is missing, so a conclusion drawn from it is incomplete.
|
|
37
|
+
export const DEPENDENCY_GAP_CODES = Object.freeze([
|
|
38
|
+
'source-missing', 'source-changed', 'repository-unavailable', 'access-denied', 'version-mismatch', 'revision-unknown',
|
|
39
|
+
'graph-integrity', 'limit-exceeded', 'unresolved-identity',
|
|
40
|
+
]);
|
|
41
|
+
const DEPENDENCY_GAPS = new Set(DEPENDENCY_GAP_CODES);
|
|
42
|
+
|
|
43
|
+
const TRAVERSED = new Set(TRAVERSED_RELATIONS);
|
|
44
|
+
const STRUCTURAL = new Set(['defined-in', 'deployed-in', 'scoped-to']);
|
|
45
|
+
const MAX_CLOSURE_HOPS = 8;
|
|
46
|
+
|
|
47
|
+
const sortedUnique = (a) => [...new Set(a)].sort();
|
|
48
|
+
const keyOf = (n) => `${n.kind}|${n.name}|${n.environment ?? ''}`;
|
|
49
|
+
const isDeny = (e) => e.effect === 'deny' || e.relation === 'network-denies';
|
|
50
|
+
|
|
51
|
+
// ------------------------------------------------------------ diff
|
|
52
|
+
|
|
53
|
+
function stable(v) { return JSON.stringify(v, Object.keys(v ?? {}).sort()); }
|
|
54
|
+
|
|
55
|
+
function sameEdgeBody(a, b) {
|
|
56
|
+
return a.pathState === b.pathState && a.confidence === b.confidence && stable(a.attrs ?? {}) === stable(b.attrs ?? {});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compare two boundary graphs.
|
|
61
|
+
*
|
|
62
|
+
* @returns {{ ok: boolean, diff: object|null, errors: object[] }} never throws
|
|
63
|
+
*/
|
|
64
|
+
export function diffBoundaryGraphs(before, after) {
|
|
65
|
+
const vb = validateBoundaryGraph(before), va = validateBoundaryGraph(after);
|
|
66
|
+
if (!vb.ok || !va.ok) return { ok: false, diff: null, errors: [...vb.errors.map((e) => ({ ...e, side: 'before' })), ...va.errors.map((e) => ({ ...e, side: 'after' }))] };
|
|
67
|
+
if (before.repository !== null && after.repository !== null && before.repository !== after.repository) {
|
|
68
|
+
return { ok: false, diff: null, errors: [{ code: 'RULE_VIOLATION', path: 'repository', message: `graphs are for different repositories ('${before.repository}' and '${after.repository}'); a diff across repositories would hide every id as added or removed` }] };
|
|
69
|
+
}
|
|
70
|
+
const bi = indexGraph(before), ai = indexGraph(after);
|
|
71
|
+
const bEdges = new Map(before.edges.map((e) => [e.id, e]));
|
|
72
|
+
const aEdges = new Map(after.edges.map((e) => [e.id, e]));
|
|
73
|
+
const nodeOf = (id) => ai.nodesById.get(id) ?? bi.nodesById.get(id);
|
|
74
|
+
const changes = [];
|
|
75
|
+
const push = (category, o) => {
|
|
76
|
+
const nodeIds = sortedUnique(o.nodeIds ?? []);
|
|
77
|
+
const edgeIds = sortedUnique(o.edgeIds ?? []);
|
|
78
|
+
const env = o.environment ?? null;
|
|
79
|
+
changes.push({
|
|
80
|
+
id: semanticId('bdrift', { category, nodeIds, edgeIds, detail: o.detail ?? null }, ['category', 'nodeIds', 'edgeIds', 'detail']),
|
|
81
|
+
category, material: MATERIAL.has(category), nodeIds, edgeIds, environment: env, tenant: o.tenant ?? null,
|
|
82
|
+
description: o.description, before: o.before ?? null, after: o.after ?? null,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const endpointsOf = (e) => [e.from, e.to];
|
|
86
|
+
|
|
87
|
+
// 1. exposure
|
|
88
|
+
const rb = exposureReach(before), ra = exposureReach(after);
|
|
89
|
+
const rank = { definite: 2, unresolved: 1 };
|
|
90
|
+
for (const [id, a] of [...ra].sort((x, y) => (x[0] < y[0] ? -1 : 1))) {
|
|
91
|
+
const n = ai.nodesById.get(id);
|
|
92
|
+
if (!n || !['service', 'resource', 'route'].includes(n.kind)) continue;
|
|
93
|
+
const b = rb.get(id);
|
|
94
|
+
if (!b || rank[a.state] > rank[b.state]) {
|
|
95
|
+
push('new-exposure', {
|
|
96
|
+
nodeIds: [id], edgeIds: a.via, environment: n.environment, tenant: n.tenant, detail: a.state,
|
|
97
|
+
description: `${n.kind} '${n.name}' is ${a.state === 'definite' ? 'reachable' : 'possibly reachable'} from an exposed entry point${b ? ' (it was only possibly reachable before)' : ' and was not before'}`,
|
|
98
|
+
before: b ? b.state : null, after: a.state,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const [id, b] of [...rb].sort((x, y) => (x[0] < y[0] ? -1 : 1))) {
|
|
103
|
+
const n = bi.nodesById.get(id);
|
|
104
|
+
if (!n || !['service', 'resource', 'route'].includes(n.kind)) continue;
|
|
105
|
+
const a = ra.get(id);
|
|
106
|
+
if (!a || rank[a.state] < rank[b.state]) {
|
|
107
|
+
push('exposure-reduced', { nodeIds: [id], edgeIds: b.via, environment: n.environment, tenant: n.tenant, detail: a ? a.state : 'gone', description: `${n.kind} '${n.name}' is ${a ? 'less certainly' : 'no longer'} reachable from an exposed entry point`, before: b.state, after: a ? a.state : null });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. edges added
|
|
112
|
+
for (const e of after.edges) {
|
|
113
|
+
if (bEdges.has(e.id) || STRUCTURAL.has(e.relation)) continue;
|
|
114
|
+
const f = nodeOf(e.from), t = nodeOf(e.to);
|
|
115
|
+
const label = `${e.relation} ${f?.name} -> ${t?.name}`;
|
|
116
|
+
const base = { nodeIds: endpointsOf(e), edgeIds: [e.id], environment: e.environment, tenant: e.tenant };
|
|
117
|
+
if (f?.tenant != null && t?.tenant != null && f.tenant !== t.tenant) {
|
|
118
|
+
push('tenant-weakening', { ...base, detail: 'cross-tenant-edge', description: `new ${label} crosses from tenant ${f.tenant} to tenant ${t.tenant}`, after: e.relation });
|
|
119
|
+
}
|
|
120
|
+
if (isDeny(e)) push('control-added', { ...base, description: `new explicit deny: ${label}`, after: e.relation });
|
|
121
|
+
else if (e.relation === 'grants' && e.effect === 'allow') push('changed-privilege', { ...base, detail: 'grant-added', description: `new grant: ${label}`, after: e.attrs ?? {} });
|
|
122
|
+
else if (e.relation === 'assumes') push('changed-privilege', { ...base, detail: 'identity-assumed', description: `workload ${f?.name} now assumes identity ${t?.name}`, after: e.relation });
|
|
123
|
+
else if (TRAVERSED.has(e.relation)) push('connectivity-added', { ...base, description: `new ${label}`, after: e.relation });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 3. edges removed
|
|
127
|
+
for (const e of before.edges) {
|
|
128
|
+
if (aEdges.has(e.id) || STRUCTURAL.has(e.relation)) continue;
|
|
129
|
+
const f = nodeOf(e.from), t = nodeOf(e.to);
|
|
130
|
+
const label = `${e.relation} ${f?.name} -> ${t?.name}`;
|
|
131
|
+
const base = { nodeIds: endpointsOf(e), edgeIds: [e.id], environment: e.environment, tenant: e.tenant };
|
|
132
|
+
if (isDeny(e)) push('removed-control', { ...base, description: `explicit deny removed: ${label}`, before: e.relation });
|
|
133
|
+
else if (e.relation === 'grants' && e.effect === 'allow') push('privilege-reduced', { ...base, description: `grant removed: ${label}`, before: e.attrs ?? {} });
|
|
134
|
+
else if (TRAVERSED.has(e.relation)) push('connectivity-removed', { ...base, description: `removed ${label}`, before: e.relation });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 4. edges present in both whose body changed
|
|
138
|
+
for (const e of after.edges) {
|
|
139
|
+
const old = bEdges.get(e.id);
|
|
140
|
+
if (!old || STRUCTURAL.has(e.relation) || sameEdgeBody(old, e)) continue;
|
|
141
|
+
const f = nodeOf(e.from), t = nodeOf(e.to);
|
|
142
|
+
const label = `${e.relation} ${f?.name} -> ${t?.name}`;
|
|
143
|
+
const base = { nodeIds: endpointsOf(e), edgeIds: [e.id], environment: e.environment, tenant: e.tenant };
|
|
144
|
+
if (e.relation === 'grants' && stable(old.attrs ?? {}) !== stable(e.attrs ?? {})) {
|
|
145
|
+
push('changed-privilege', { ...base, detail: 'grant-changed', description: `grant changed: ${label}`, before: old.attrs ?? {}, after: e.attrs ?? {} });
|
|
146
|
+
} else {
|
|
147
|
+
push('edge-changed', { ...base, detail: `${old.pathState}>${e.pathState}`, description: `${label} changed (state ${old.pathState} -> ${e.pathState}, confidence ${old.confidence} -> ${e.confidence})`, before: { pathState: old.pathState, confidence: old.confidence }, after: { pathState: e.pathState, confidence: e.confidence } });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 5. nodes
|
|
152
|
+
const afterByKey = new Map();
|
|
153
|
+
for (const n of after.nodes) { if (!afterByKey.has(keyOf(n))) afterByKey.set(keyOf(n), []); afterByKey.get(keyOf(n)).push(n); }
|
|
154
|
+
for (const n of before.nodes) {
|
|
155
|
+
const same = ai.nodesById.get(n.id);
|
|
156
|
+
if (same) {
|
|
157
|
+
if (same.trustZone !== n.trustZone) push('zone-changed', { nodeIds: [n.id], environment: n.environment, tenant: n.tenant, detail: `${n.trustZone}>${same.trustZone}`, description: `${n.kind} '${n.name}' moved from trust zone ${n.trustZone} to ${same.trustZone}`, before: n.trustZone, after: same.trustZone });
|
|
158
|
+
if (same.resolved !== n.resolved) push('node-resolution-changed', { nodeIds: [n.id], environment: n.environment, tenant: n.tenant, detail: `${n.resolved}>${same.resolved}`, description: `${n.kind} '${n.name}' is now ${same.resolved ? 'resolved' : 'unresolved'}`, before: n.resolved, after: same.resolved });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// The id is gone. A tenant binding that was dropped keeps (kind, name, environment) but loses its tenant.
|
|
162
|
+
const twin = (afterByKey.get(keyOf(n)) ?? []).find((m) => m.tenant === null);
|
|
163
|
+
if (n.tenant !== null && twin) {
|
|
164
|
+
push('tenant-weakening', { nodeIds: [n.id, twin.id], environment: n.environment, tenant: n.tenant, detail: 'tenant-binding-lost', description: `${n.kind} '${n.name}' is no longer bound to tenant ${n.tenant}`, before: n.tenant, after: null });
|
|
165
|
+
} else if (n.kind === 'service' && !(afterByKey.get(keyOf(n)) ?? []).length) {
|
|
166
|
+
push('service-removed', { nodeIds: [n.id], environment: n.environment, tenant: n.tenant, description: `service '${n.name}' is no longer in the graph`, before: n.name });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const n of after.nodes) {
|
|
170
|
+
if (!bi.nodesById.has(n.id) && n.kind === 'service' && !before.nodes.some((m) => keyOf(m) === keyOf(n))) {
|
|
171
|
+
push('service-added', { nodeIds: [n.id], environment: n.environment, tenant: n.tenant, description: `service '${n.name}' is new`, after: n.name });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Deterministic order, and one record per id.
|
|
176
|
+
const byId = new Map(changes.map((c) => [c.id, c]));
|
|
177
|
+
const list = [...byId.values()].sort((a, b) => (Number(b.material) - Number(a.material)) || (a.category < b.category ? -1 : a.category > b.category ? 1 : a.id < b.id ? -1 : 1));
|
|
178
|
+
|
|
179
|
+
// Sources: what moved on disk, and which edges each moved file supported.
|
|
180
|
+
const bSrc = new Map(before.sources.map((s) => [s.file, s]));
|
|
181
|
+
const aSrc = new Map(after.sources.map((s) => [s.file, s]));
|
|
182
|
+
const supportedBy = (graph, file) => graph.edges.filter((e) => e.source?.file === file).map((e) => e.id).sort();
|
|
183
|
+
const sources = { added: [], removed: [], changed: [] };
|
|
184
|
+
for (const [file, s] of [...aSrc].sort()) {
|
|
185
|
+
const o = bSrc.get(file);
|
|
186
|
+
if (!o) sources.added.push({ file, digest: s.digest, edgeIds: supportedBy(after, file) });
|
|
187
|
+
else if (o.digest !== s.digest) sources.changed.push({ file, beforeDigest: o.digest, afterDigest: s.digest, edgeIdsBefore: supportedBy(before, file), edgeIdsAfter: supportedBy(after, file) });
|
|
188
|
+
}
|
|
189
|
+
for (const [file, s] of [...bSrc].sort()) if (!aSrc.has(file)) sources.removed.push({ file, digest: s.digest, edgeIds: supportedBy(before, file) });
|
|
190
|
+
|
|
191
|
+
const incomplete = after.gaps.filter((g) => DEPENDENCY_GAPS.has(g.code)).map((g) => ({ code: g.code, subject: g.subject, message: g.message }));
|
|
192
|
+
const byCategory = {};
|
|
193
|
+
for (const c of list) byCategory[c.category] = (byCategory[c.category] ?? 0) + 1;
|
|
194
|
+
const diff = {
|
|
195
|
+
schema: DRIFT_SCHEMA, schemaVersion: DRIFT_VERSION,
|
|
196
|
+
repository: after.repository ?? before.repository, beforeDigest: before.digest, afterDigest: after.digest,
|
|
197
|
+
beforeRevision: before.revision, afterRevision: after.revision,
|
|
198
|
+
changes: list, sources, incomplete,
|
|
199
|
+
summary: { total: list.length, material: list.filter((c) => c.material).length, byCategory },
|
|
200
|
+
};
|
|
201
|
+
return { ok: true, diff, errors: [] };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Human-readable lines for a diff. States what changed and what it does not show; never says a change is safe. */
|
|
205
|
+
export function explainDrift(diff) {
|
|
206
|
+
const lines = [];
|
|
207
|
+
if (!diff || !Array.isArray(diff.changes)) return ['no drift record'];
|
|
208
|
+
lines.push(`Boundary drift: ${diff.summary.total} change(s), ${diff.summary.material} material`);
|
|
209
|
+
for (const c of diff.changes) lines.push(` ${c.material ? '[material] ' : ''}${c.category}: ${c.description}`);
|
|
210
|
+
if (!diff.changes.length) lines.push(' no change to a non-structural relation was found between these two graphs');
|
|
211
|
+
for (const s of diff.sources.changed) lines.push(` source changed: ${s.file} (supported ${s.edgeIdsBefore.length} edge(s) before, ${s.edgeIdsAfter.length} now)`);
|
|
212
|
+
for (const s of diff.sources.removed) lines.push(` source removed: ${s.file} (supported ${s.edgeIds.length} edge(s))`);
|
|
213
|
+
for (const s of diff.sources.added) lines.push(` source added: ${s.file}`);
|
|
214
|
+
if (diff.incomplete.length) lines.push(` incomplete inputs: ${sortedUnique(diff.incomplete.map((g) => g.code)).join(', ')}; a change behind them may be missing from this diff`);
|
|
215
|
+
lines.push(' This compares two configured graphs; it does not show how the deployed system behaves.');
|
|
216
|
+
return lines;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ------------------------------------------------------------ impact: which hypotheses a change can affect
|
|
220
|
+
|
|
221
|
+
function adjacency(graphs) {
|
|
222
|
+
const fwd = new Map(), rev = new Map();
|
|
223
|
+
for (const g of graphs) {
|
|
224
|
+
for (const e of g.edges) {
|
|
225
|
+
if (!TRAVERSED.has(e.relation) && !isDeny(e)) continue;
|
|
226
|
+
if (!fwd.has(e.from)) fwd.set(e.from, new Set());
|
|
227
|
+
fwd.get(e.from).add(e.to);
|
|
228
|
+
if (!rev.has(e.to)) rev.set(e.to, new Set());
|
|
229
|
+
rev.get(e.to).add(e.from);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { fwd, rev };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function closure(start, map) {
|
|
236
|
+
const seen = new Set(start);
|
|
237
|
+
let frontier = [...start];
|
|
238
|
+
for (let hop = 0; hop < MAX_CLOSURE_HOPS && frontier.length; hop++) {
|
|
239
|
+
const next = [];
|
|
240
|
+
for (const id of frontier) for (const m of map.get(id) ?? []) if (!seen.has(m)) { seen.add(m); next.push(m); }
|
|
241
|
+
frontier = next;
|
|
242
|
+
}
|
|
243
|
+
return seen;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The (kind|name|environment) keys of every service a change can affect: its own, those that reach it, those it reaches. */
|
|
247
|
+
function affectedServiceKeys(change, before, after) {
|
|
248
|
+
const nodes = new Map();
|
|
249
|
+
for (const g of [before, after]) for (const n of g.nodes) nodes.set(n.id, n);
|
|
250
|
+
const { fwd, rev } = adjacency([before, after]);
|
|
251
|
+
const seeds = change.nodeIds.filter((id) => nodes.has(id));
|
|
252
|
+
const reach = new Set([...closure(seeds, fwd), ...closure(seeds, rev)]);
|
|
253
|
+
const keys = new Set();
|
|
254
|
+
for (const id of reach) { const n = nodes.get(id); if (n && n.kind === 'service') keys.add(keyOf(n)); }
|
|
255
|
+
return keys;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function serviceKeyOfBinding(finding, bindings, before, after) {
|
|
259
|
+
for (const g of [after, before]) {
|
|
260
|
+
const b = bindFindingToService(finding, bindings, g);
|
|
261
|
+
if (b.status === 'bound') return { status: 'bound', key: keyOf(b.service), reason: b.reason };
|
|
262
|
+
if (b.status === 'unbound' || b.status === 'ambiguous') return { status: b.status, key: null, reason: b.reason };
|
|
263
|
+
}
|
|
264
|
+
// The service exists in neither graph (renamed or removed). Use the binding's own name so a removal still maps.
|
|
265
|
+
const b = bindFindingToService(finding, bindings, after);
|
|
266
|
+
if (b.binding && b.binding.environment) return { status: 'bound', key: `service|${b.binding.service}|${b.binding.environment}`, reason: 'service is absent from both graphs; matched by the binding name' };
|
|
267
|
+
return { status: b.status, key: null, reason: b.reason };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The focused rescan plan for a diff.
|
|
272
|
+
*
|
|
273
|
+
* @param {object} args
|
|
274
|
+
* @param {object} args.diff from `diffBoundaryGraphs`
|
|
275
|
+
* @param {object} args.before
|
|
276
|
+
* @param {object} args.after
|
|
277
|
+
* @param {Array<{hypothesisId?:string, finding:object}>} args.hypotheses
|
|
278
|
+
* @param {Array} args.bindings see `bindFindingToService`
|
|
279
|
+
*/
|
|
280
|
+
export function planRescan({ diff, before, after, hypotheses = [], bindings = [] } = {}) {
|
|
281
|
+
const affecting = diff.changes;
|
|
282
|
+
const incomplete = diff.incomplete.map((g) => ({ hypothesisId: null, code: g.code, reason: `${g.subject}: ${g.message}` }));
|
|
283
|
+
const serviceKeysByChange = new Map(affecting.map((c) => [c.id, affectedServiceKeys(c, before, after)]));
|
|
284
|
+
const rescan = [], skipped = [];
|
|
285
|
+
for (const h of hypotheses) {
|
|
286
|
+
const finding = h.finding ?? h;
|
|
287
|
+
const hypothesisId = h.hypothesisId ?? hypothesisIdFromFinding(finding);
|
|
288
|
+
const file = finding?.file ?? null;
|
|
289
|
+
const bound = serviceKeyOfBinding(finding, bindings, before, after);
|
|
290
|
+
if (bound.status !== 'bound') {
|
|
291
|
+
if (affecting.length) incomplete.push({ hypothesisId, code: 'unbound-hypothesis', reason: `${bound.reason}; it cannot be shown independent of the ${affecting.length} change(s), so it is neither rescanned nor cleared` });
|
|
292
|
+
else skipped.push({ hypothesisId, file, reason: 'no change between the two graphs' });
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const reasons = affecting.filter((c) => serviceKeysByChange.get(c.id).has(bound.key)).map((c) => c.id);
|
|
296
|
+
if (reasons.length) rescan.push({ hypothesisId, file, serviceKey: bound.key, reasons });
|
|
297
|
+
else skipped.push({ hypothesisId, file, reason: `no changed relation touches, reaches or is reached by ${bound.key.split('|')[1]}` });
|
|
298
|
+
}
|
|
299
|
+
const sortById = (a, b) => (String(a.hypothesisId) < String(b.hypothesisId) ? -1 : 1);
|
|
300
|
+
rescan.sort(sortById); skipped.sort(sortById);
|
|
301
|
+
incomplete.sort((a, b) => `${a.code}|${a.hypothesisId}`.localeCompare(`${b.code}|${b.hypothesisId}`));
|
|
302
|
+
return {
|
|
303
|
+
status: incomplete.length ? 'incomplete' : 'complete',
|
|
304
|
+
changeIds: affecting.map((c) => c.id),
|
|
305
|
+
rescan, skipped, incomplete,
|
|
306
|
+
rescanFiles: sortedUnique(rescan.map((r) => r.file).filter(Boolean)),
|
|
307
|
+
statement: incomplete.length
|
|
308
|
+
? `incomplete: ${incomplete.length} dependency problem(s) mean the plan cannot show that every unaffected hypothesis is independent of the change`
|
|
309
|
+
: `${rescan.length} hypothesis(es) to rescan, ${skipped.length} unaffected and not rescanned`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ------------------------------------------------------------ incremental vs complete
|
|
314
|
+
|
|
315
|
+
/** The deployment-conditioned result for one hypothesis, without graph-wide metadata that moves between revisions. */
|
|
316
|
+
export function scopedSignature(context) {
|
|
317
|
+
if (!context) return null;
|
|
318
|
+
return digestOf({
|
|
319
|
+
hypothesisId: context.hypothesisId, binding: context.binding.status, service: context.binding.service?.id ?? null,
|
|
320
|
+
exposure: context.exposure.state,
|
|
321
|
+
paths: context.paths.map((p) => ({ id: p.id, kind: p.kind, state: p.state, conditions: p.conditions.map((c) => c.kind), blockers: p.blockers.map((b) => b.edgeId), exploitability: p.exploitability.label })),
|
|
322
|
+
verification: context.verification.map((v) => [v.recordId, v.applicable]),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Recompute only the hypotheses the plan names and carry the rest forward.
|
|
328
|
+
*
|
|
329
|
+
* @param {object} args
|
|
330
|
+
* @param {object} args.plan
|
|
331
|
+
* @param {Array<{hypothesisId?:string, finding:object}>} args.hypotheses
|
|
332
|
+
* @param {Object<string, object>} args.previous hypothesisId -> context from the earlier revision
|
|
333
|
+
* @param {(h: object) => object} args.annotate recompute one hypothesis against the new revision
|
|
334
|
+
*/
|
|
335
|
+
export function incrementalAnnotate({ plan, hypotheses, previous = {}, annotate }) {
|
|
336
|
+
const idOf = (h) => h.hypothesisId ?? hypothesisIdFromFinding(h.finding ?? h);
|
|
337
|
+
const redo = new Set(plan.rescan.map((r) => r.hypothesisId));
|
|
338
|
+
const unproven = new Set(plan.incomplete.map((i) => i.hypothesisId).filter(Boolean));
|
|
339
|
+
const results = {}, recomputed = [], carried = [], stale = [];
|
|
340
|
+
for (const h of hypotheses) {
|
|
341
|
+
const id = idOf(h);
|
|
342
|
+
if (redo.has(id) || !(id in previous)) { results[id] = annotate(h); recomputed.push(id); }
|
|
343
|
+
else { results[id] = previous[id]; (unproven.has(id) ? stale : carried).push(id); }
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
status: plan.status, results, recomputed: recomputed.sort(), carried: carried.sort(), stale: stale.sort(),
|
|
347
|
+
incomplete: plan.incomplete,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Compare an incremental result with a complete one on the scoped signature. An incomplete incremental result never matches. */
|
|
352
|
+
export function compareScoped(incremental, complete) {
|
|
353
|
+
const ids = sortedUnique([...Object.keys(incremental.results), ...Object.keys(complete)]);
|
|
354
|
+
const differing = ids.filter((id) => scopedSignature(incremental.results[id]) !== scopedSignature(complete[id]));
|
|
355
|
+
const equivalent = incremental.status === 'complete' && differing.length === 0;
|
|
356
|
+
return { equivalent, differing, status: incremental.status };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ------------------------------------------------------------ invalidation of paths by changed sources
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Which paths a set of changed or removed source files invalidates, and the graph with those relations dropped.
|
|
363
|
+
* A path is invalidated exactly when one of its supporting edges, or the declaration of one of its nodes, cites a changed file.
|
|
364
|
+
*
|
|
365
|
+
* @param {object[]} paths path records (`computeAttackPaths`)
|
|
366
|
+
* @param {object} graph
|
|
367
|
+
* @param {Object<string,string>|Map} currentDigests file -> sha256 now (absent means removed)
|
|
368
|
+
*/
|
|
369
|
+
export function invalidatePathsForSources(paths, graph, currentDigests) {
|
|
370
|
+
const inv = invalidateChangedSources(graph, currentDigests);
|
|
371
|
+
if (!inv.ok) return { ok: false, valid: [], invalidated: [], graph: null, staleFiles: inv.staleFiles, errors: inv.errors };
|
|
372
|
+
const staleEdges = new Set(inv.invalidatedEdgeIds);
|
|
373
|
+
const stale = new Set(inv.staleFiles);
|
|
374
|
+
const declaredOnlyByStale = new Set(graph.nodes.filter((n) => n.declaredBy.length && n.declaredBy.every((d) => stale.has(d.file))).map((n) => n.id));
|
|
375
|
+
const valid = [], invalidated = [];
|
|
376
|
+
for (const p of paths) {
|
|
377
|
+
const edgeIds = p.supportingEdgeIds.filter((id) => staleEdges.has(id));
|
|
378
|
+
const nodeIds = sortedUnique(p.hops.flatMap((h) => [h.from.id, h.to.id]).filter((id) => declaredOnlyByStale.has(id)));
|
|
379
|
+
if (edgeIds.length || nodeIds.length) invalidated.push({ pathId: p.id, edgeIds: sortedUnique(edgeIds), nodeIds });
|
|
380
|
+
else valid.push(p);
|
|
381
|
+
}
|
|
382
|
+
return { ok: true, valid, invalidated, graph: inv.graph, staleFiles: inv.staleFiles, errors: [] };
|
|
383
|
+
}
|