@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,443 @@
|
|
|
1
|
+
// attack-paths.js: deployment-conditioned attack paths (X-305).
|
|
2
|
+
//
|
|
3
|
+
// Combines a finding's code location with the deployment boundary graph to answer two questions, and states what it could not
|
|
4
|
+
// answer next to each result:
|
|
5
|
+
// exposure : can an exposed entry point reach the deployed service that runs this code?
|
|
6
|
+
// impact : from that service, which privileged resources can it reach, across which trust boundaries?
|
|
7
|
+
//
|
|
8
|
+
// Rules the module keeps:
|
|
9
|
+
// - A path is a walk over configured or observed edges. Its state is one of the graph's PATH_STATES: `blocked` only because an
|
|
10
|
+
// explicit deny edge sits on a hop; `unresolved` when an endpoint is unresolved, a hop's configuration is unresolved, or a
|
|
11
|
+
// tenant transition makes reachability CONDITIONAL on a binding the configuration does not establish; `runtime-supported`
|
|
12
|
+
// only when every network hop has a runtime observation (identity hops are configuration and are listed as such);
|
|
13
|
+
// otherwise `possible`. No path found is never reported as "none exists": the graph is only what was supplied.
|
|
14
|
+
// - Traffic coverage is never complete. Observed, sampled and stale hops are reported separately and a path that is
|
|
15
|
+
// `runtime-supported` still says its coverage is `not-established`.
|
|
16
|
+
// - Exploitability is NOT derived from reachability. A path is labelled `exploitable-confirmed` only when a verification record
|
|
17
|
+
// for the same hypothesis passes the trust rules here: it validates, its outcome is `confirmed`, its confirmation level
|
|
18
|
+
// RECOMPUTED from its evidence is `runtime-confirmed` (a claimed level is never read) and its oracle is the one the finding's
|
|
19
|
+
// class names. Confidence scores, model agreement and an unvalidated record never satisfy it.
|
|
20
|
+
// - Pure: a graph in, a context out. No clock (a caller passes `now` to judge staleness), no file or network access.
|
|
21
|
+
//
|
|
22
|
+
// A finding is tied to a service by an explicit binding (a path prefix to a service name). Nothing is guessed from names: an
|
|
23
|
+
// unbound finding yields a context that says so and carries no path.
|
|
24
|
+
|
|
25
|
+
import { indexGraph, validateBoundaryGraph, boundaryCrossings } from './boundary-graph.js';
|
|
26
|
+
import { semanticId, hypothesisIdFromFinding } from '../../posture/assurance/identity.js';
|
|
27
|
+
import { validateVerificationRecord, deriveConfirmationLevel } from '../../posture/assurance/verification-record.js';
|
|
28
|
+
import { nonTaintClassOf, SCENARIO_CLASSES } from '../../posture/oracles/scenario-classes.js';
|
|
29
|
+
|
|
30
|
+
export const BOUNDARY_CONTEXT_SCHEMA = 'agentic-security/boundary-context';
|
|
31
|
+
export const BOUNDARY_CONTEXT_VERSION = '1.0.0';
|
|
32
|
+
|
|
33
|
+
export const TRAVERSED_RELATIONS = Object.freeze(['routes-to', 'calls', 'depends-on', 'network-allows', 'assumes', 'grants']);
|
|
34
|
+
const TRAVERSED = new Set(TRAVERSED_RELATIONS);
|
|
35
|
+
const NETWORK_RELATIONS = new Set(['routes-to', 'calls', 'depends-on', 'network-allows']);
|
|
36
|
+
|
|
37
|
+
export const PATH_LIMITS = Object.freeze({ maxHops: 8, maxPaths: 200, maxExpansions: 20000 });
|
|
38
|
+
export const DEFAULT_STALE_AFTER_MS = 7 * 24 * 3600 * 1000;
|
|
39
|
+
|
|
40
|
+
export const EXPLOITABILITY_LABELS = Object.freeze([
|
|
41
|
+
'not-established', 'exploitable-confirmed', 'oracle-confirmed-path-blocked', 'oracle-confirmed-path-unresolved',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const STATE_RANK = { 'runtime-supported': 0, possible: 1, unresolved: 2, blocked: 3 };
|
|
45
|
+
|
|
46
|
+
const ASSUME = Object.freeze({
|
|
47
|
+
edges: 'edges are declared or observed relationships; none of them shows that a request succeeded',
|
|
48
|
+
layers: 'only the supplied configuration was read; host firewalls, service meshes and organisation level policies were not',
|
|
49
|
+
route: 'a routing rule from configuration states where traffic is forwarded; it was not exercised',
|
|
50
|
+
depends: 'a declared dependency implies connectivity, not that a call happens',
|
|
51
|
+
identity: 'the workload runs as the identity named in configuration',
|
|
52
|
+
grant: 'the policy as written is the policy in force',
|
|
53
|
+
tenantShared: 'a tenant-scoped service reaches a resource with no tenant binding in the supplied configuration',
|
|
54
|
+
lowConfidence: 'an edge on this path has low confidence',
|
|
55
|
+
inferred: 'an edge on this path is an inference from naming, not a configured or observed relationship',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const nul = (v) => (v === undefined ? null : v);
|
|
59
|
+
const sortedUnique = (a) => [...new Set(a)].sort();
|
|
60
|
+
|
|
61
|
+
function nodeSummary(n) {
|
|
62
|
+
return {
|
|
63
|
+
id: n.id, kind: n.kind, name: n.name, environment: nul(n.environment), tenant: nul(n.tenant), repository: nul(n.repository),
|
|
64
|
+
trustZone: n.trustZone, resolved: n.resolved,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function edgeSummary(e) {
|
|
69
|
+
return {
|
|
70
|
+
id: e.id, relation: e.relation, effect: e.effect, provenance: e.provenance, confidence: e.confidence, pathState: e.pathState,
|
|
71
|
+
environment: nul(e.environment), tenant: nul(e.tenant), sourceRevision: nul(e.sourceRevision),
|
|
72
|
+
source: e.source ? { file: e.source.file, digest: e.source.digest, parser: e.source.parser, parserVersion: e.source.parserVersion } : null,
|
|
73
|
+
observationInterval: e.observationInterval ? { start: e.observationInterval.start, end: e.observationInterval.end } : null,
|
|
74
|
+
completeness: nul(e.completeness), attrs: e.attrs && typeof e.attrs === 'object' ? { ...e.attrs } : {},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ------------------------------------------------------------ binding a finding to a service
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tie a finding's code location to a deployed service by an explicit binding. The longest matching path prefix wins; two equally
|
|
82
|
+
* long prefixes naming different services are ambiguous and bind nothing.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} finding needs `file`
|
|
85
|
+
* @param {Array<{pathPrefix:string, service:string, repository?:string, environment?:string, tenant?:string}>} bindings
|
|
86
|
+
* @param {object} graph a validated boundary graph
|
|
87
|
+
* @returns {{status:'bound'|'unbound'|'ambiguous'|'service-not-in-graph'|'ambiguous-service', service:object|null, binding:object|null, reason:string}}
|
|
88
|
+
*/
|
|
89
|
+
export function bindFindingToService(finding, bindings, graph) {
|
|
90
|
+
const file = typeof finding?.file === 'string' ? finding.file.replace(/\\/g, '/').replace(/^\.\//, '') : '';
|
|
91
|
+
if (!file) return { status: 'unbound', service: null, binding: null, reason: 'the finding has no file, so it cannot be tied to a service' };
|
|
92
|
+
const list = (Array.isArray(bindings) ? bindings : []).filter((b) => b && typeof b.pathPrefix === 'string' && b.pathPrefix.trim() && typeof b.service === 'string' && b.service);
|
|
93
|
+
const matches = [];
|
|
94
|
+
for (const b of list) {
|
|
95
|
+
const prefix = b.pathPrefix.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
96
|
+
if (prefix.split('/').includes('..')) continue;
|
|
97
|
+
if (b.repository && graph.repository && b.repository !== graph.repository) continue;
|
|
98
|
+
const hit = prefix.endsWith('/') ? file.startsWith(prefix) : (file === prefix || file.startsWith(`${prefix}/`));
|
|
99
|
+
if (hit) matches.push({ b, len: prefix.length });
|
|
100
|
+
}
|
|
101
|
+
if (!matches.length) return { status: 'unbound', service: null, binding: null, reason: list.length ? `no service binding covers ${file}` : 'no service bindings were supplied' };
|
|
102
|
+
const top = Math.max(...matches.map((m) => m.len));
|
|
103
|
+
const best = matches.filter((m) => m.len === top);
|
|
104
|
+
const names = sortedUnique(best.map((m) => `${m.b.service}|${m.b.environment ?? ''}|${m.b.tenant ?? ''}`));
|
|
105
|
+
if (names.length > 1) return { status: 'ambiguous', service: null, binding: null, reason: `${file} is covered by equally specific bindings for different services` };
|
|
106
|
+
const b = best[0].b;
|
|
107
|
+
const nodes = graph.nodes.filter((n) => n.kind === 'service' && n.name === b.service
|
|
108
|
+
&& (b.environment === undefined || n.environment === b.environment) && (b.tenant === undefined || n.tenant === b.tenant));
|
|
109
|
+
const binding = { pathPrefix: b.pathPrefix, service: b.service, repository: nul(b.repository), environment: nul(b.environment), tenant: nul(b.tenant) };
|
|
110
|
+
if (!nodes.length) return { status: 'service-not-in-graph', service: null, binding, reason: `service '${b.service}' is not in the supplied boundary graph` };
|
|
111
|
+
if (nodes.length > 1) return { status: 'ambiguous-service', service: null, binding, reason: `service '${b.service}' exists in several environments or tenants; the binding must name one` };
|
|
112
|
+
return { status: 'bound', service: nodeSummary(nodes[0]), binding, reason: `${file} is bound to service '${b.service}'` };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ------------------------------------------------------------ path computation
|
|
116
|
+
|
|
117
|
+
function hopsFrom(ix, nodeId) {
|
|
118
|
+
const hops = new Map();
|
|
119
|
+
for (const e of ix.out.get(nodeId) ?? []) {
|
|
120
|
+
const h = hops.get(e.to) ?? { edges: [], denies: [] };
|
|
121
|
+
if (e.effect === 'deny' || e.relation === 'network-denies') h.denies.push(e);
|
|
122
|
+
else if (TRAVERSED.has(e.relation)) h.edges.push(e);
|
|
123
|
+
hops.set(e.to, h);
|
|
124
|
+
}
|
|
125
|
+
for (const [k, h] of [...hops]) if (!h.edges.length) hops.delete(k);
|
|
126
|
+
return hops;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function hopState(h, from, to) {
|
|
130
|
+
if (h.denies.length) return 'blocked';
|
|
131
|
+
if (h.edges.some((e) => e.pathState === 'runtime-supported')) return 'runtime-supported';
|
|
132
|
+
if (!from.resolved || !to.resolved || h.edges.every((e) => e.pathState === 'unresolved' || e.pathState === 'blocked')) return 'unresolved';
|
|
133
|
+
return 'possible';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isStale(e, nowMs, staleAfterMs) {
|
|
137
|
+
if (e.attrs?.stale === true) return true;
|
|
138
|
+
if (nowMs === null || !e.observationInterval) return false;
|
|
139
|
+
return nowMs - Date.parse(e.observationInterval.end) > staleAfterMs;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildPath(ix, seq, hopList, nowMs, staleAfterMs) {
|
|
143
|
+
const nodes = seq.map((id) => ix.nodesById.get(id));
|
|
144
|
+
const hops = [];
|
|
145
|
+
const conditions = [];
|
|
146
|
+
const blockers = [];
|
|
147
|
+
const assumptions = new Set([ASSUME.edges, ASSUME.layers]);
|
|
148
|
+
const crossings = [];
|
|
149
|
+
const cov = { observed: [], sampled: [], stale: [], unobserved: [] };
|
|
150
|
+
let from = null;
|
|
151
|
+
hopList.forEach((h, i) => {
|
|
152
|
+
from = nodes[i];
|
|
153
|
+
const to = nodes[i + 1];
|
|
154
|
+
const state = hopState(h, from, to);
|
|
155
|
+
const crossing = boundaryCrossings(h.edges[0], ix.nodesById);
|
|
156
|
+
for (const c of crossing) crossings.push({ hop: i, type: c.type, detail: c.detail });
|
|
157
|
+
hops.push({
|
|
158
|
+
index: i, from: nodeSummary(from), to: nodeSummary(to), relations: sortedUnique(h.edges.map((e) => e.relation)), state,
|
|
159
|
+
edges: h.edges.map(edgeSummary).sort((a, b) => (a.id < b.id ? -1 : 1)),
|
|
160
|
+
});
|
|
161
|
+
for (const d of h.denies) {
|
|
162
|
+
blockers.push({ hop: i, edgeId: d.id, relation: d.relation, provenance: d.provenance, source: d.source ? { file: d.source.file, digest: d.source.digest } : null,
|
|
163
|
+
detail: `${d.relation} (deny) from ${from.name} to ${to.name}` });
|
|
164
|
+
}
|
|
165
|
+
if (from.tenant !== null && to.tenant !== null && from.tenant !== to.tenant) {
|
|
166
|
+
conditions.push({ kind: 'tenant-binding', hop: i, detail: `${from.name} (tenant ${from.tenant}) reaches ${to.name} (tenant ${to.tenant}): reachable only if no tenant binding is enforced on this transition, and the configuration does not establish either` });
|
|
167
|
+
} else if (from.tenant !== null && to.tenant === null && to.kind === 'resource') {
|
|
168
|
+
assumptions.add(ASSUME.tenantShared);
|
|
169
|
+
}
|
|
170
|
+
const rels = new Set(h.edges.map((e) => e.relation));
|
|
171
|
+
if (rels.has('routes-to')) assumptions.add(ASSUME.route);
|
|
172
|
+
if (rels.has('depends-on')) assumptions.add(ASSUME.depends);
|
|
173
|
+
if (rels.has('assumes')) assumptions.add(ASSUME.identity);
|
|
174
|
+
if (rels.has('grants')) assumptions.add(ASSUME.grant);
|
|
175
|
+
if (h.edges.some((e) => e.confidence === 'low')) assumptions.add(ASSUME.lowConfidence);
|
|
176
|
+
if (h.edges.some((e) => e.provenance === 'inferred')) assumptions.add(ASSUME.inferred);
|
|
177
|
+
if (h.edges.some((e) => NETWORK_RELATIONS.has(e.relation))) {
|
|
178
|
+
const obs = h.edges.filter((e) => e.provenance === 'runtime-observation');
|
|
179
|
+
if (!obs.length) cov.unobserved.push(i);
|
|
180
|
+
else {
|
|
181
|
+
cov.observed.push(i);
|
|
182
|
+
if (obs.some((e) => e.completeness === 'sampled')) cov.sampled.push(i);
|
|
183
|
+
if (obs.some((e) => isStale(e, nowMs, staleAfterMs))) cov.stale.push(i);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
for (const n of nodes) if (!n.resolved) conditions.push({ kind: 'unresolved-node', hop: null, detail: `${n.kind} '${n.name}' is not defined in the supplied configuration${n.unresolvedReason ? ` (${n.unresolvedReason})` : ''}` });
|
|
188
|
+
|
|
189
|
+
const networkHops = hops.filter((h) => h.relations.some((r) => NETWORK_RELATIONS.has(r)));
|
|
190
|
+
let state;
|
|
191
|
+
if (hops.some((h) => h.state === 'blocked')) state = 'blocked';
|
|
192
|
+
else if (hops.some((h) => h.state === 'unresolved') || conditions.length) state = 'unresolved';
|
|
193
|
+
else if (networkHops.length && networkHops.every((h) => h.state === 'runtime-supported')) state = 'runtime-supported';
|
|
194
|
+
else state = 'possible';
|
|
195
|
+
|
|
196
|
+
const observedEnds = hops.flatMap((h) => h.edges).filter((e) => e.observationInterval).map((e) => e.observationInterval);
|
|
197
|
+
const supportingEdgeIds = sortedUnique(hops.flatMap((h) => h.edges.map((e) => e.id)));
|
|
198
|
+
const envs = sortedUnique([...hops.flatMap((h) => h.edges.map((e) => e.environment)), ...nodes.map((n) => n.environment)].filter(Boolean));
|
|
199
|
+
const tenants = sortedUnique(nodes.map((n) => n.tenant).filter(Boolean));
|
|
200
|
+
const dedupCross = [...new Map(crossings.map((c) => [`${c.type}|${c.detail}`, c])).values()];
|
|
201
|
+
return {
|
|
202
|
+
id: semanticId('bpath', { hops: hops.map((h) => h.edges.map((e) => e.id).sort()) }, ['hops']),
|
|
203
|
+
entry: nodeSummary(nodes[0]), target: nodeSummary(nodes[nodes.length - 1]),
|
|
204
|
+
state, conditional: conditions.length > 0, conditions, blockers, assumptions: [...assumptions].sort(),
|
|
205
|
+
environments: envs, tenants,
|
|
206
|
+
provenance: sortedUnique(hops.flatMap((h) => h.edges.map((e) => e.provenance))),
|
|
207
|
+
crossings: dedupCross, supportingEdgeIds, hops,
|
|
208
|
+
coverage: {
|
|
209
|
+
trafficCoverage: 'not-established',
|
|
210
|
+
observedHops: cov.observed, sampledHops: cov.sampled, staleHops: cov.stale, unobservedHops: cov.unobserved,
|
|
211
|
+
observedFrom: observedEnds.length ? observedEnds.map((o) => o.start).sort()[0] : null,
|
|
212
|
+
observedUntil: observedEnds.length ? observedEnds.map((o) => o.end).sort().slice(-1)[0] : null,
|
|
213
|
+
staleEvaluated: nowMs !== null,
|
|
214
|
+
},
|
|
215
|
+
exploitability: { label: 'not-established', recordId: null, reason: 'no verification record for this hypothesis was supplied; reachability alone does not show exploitability' },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function defaultEntries(graph) {
|
|
220
|
+
return graph.nodes.filter((n) => (n.kind === 'route' || n.kind === 'service') && n.trustZone === 'public' && n.resolved).map((n) => n.id).sort();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A privileged resource: one an identity is granted (allow) access to, or one the configuration places in the privileged zone. */
|
|
224
|
+
function defaultTargets(graph) {
|
|
225
|
+
const granted = new Set(graph.edges.filter((e) => e.relation === 'grants' && e.effect === 'allow').map((e) => e.to));
|
|
226
|
+
return graph.nodes.filter((n) => n.kind === 'resource' && (n.trustZone === 'privileged' || granted.has(n.id))).map((n) => n.id).sort();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Enumerate simple paths from entry points to targets, bounded in hops, paths and expansions.
|
|
231
|
+
*
|
|
232
|
+
* @param {object} graph
|
|
233
|
+
* @param {object} [o]
|
|
234
|
+
* @param {string[]} [o.entries] node ids; default: resolved public routes and services
|
|
235
|
+
* @param {string[]} [o.targets] node ids; default: privileged resources
|
|
236
|
+
* @param {boolean} [o.includeSelf] also report an entry that is itself a target as a zero-hop path
|
|
237
|
+
* @param {number|string} [o.now] a clock value, only to judge staleness of observed hops
|
|
238
|
+
* @returns {{ ok: boolean, paths: object[], entries: string[], targets: string[], truncated: boolean, limits: object, statement: string, errors: object[] }}
|
|
239
|
+
*/
|
|
240
|
+
export function computeAttackPaths(graph, o = {}) {
|
|
241
|
+
const v = validateBoundaryGraph(graph);
|
|
242
|
+
if (!v.ok) return { ok: false, paths: [], entries: [], targets: [], truncated: false, limits: { ...PATH_LIMITS }, statement: 'the graph failed validation, so no path was computed', errors: v.errors };
|
|
243
|
+
const limits = {
|
|
244
|
+
maxHops: Math.min(o.maxHops ?? PATH_LIMITS.maxHops, PATH_LIMITS.maxHops), maxPaths: Math.min(o.maxPaths ?? PATH_LIMITS.maxPaths, PATH_LIMITS.maxPaths),
|
|
245
|
+
maxExpansions: Math.min(o.maxExpansions ?? PATH_LIMITS.maxExpansions, PATH_LIMITS.maxExpansions),
|
|
246
|
+
};
|
|
247
|
+
const ix = indexGraph(graph);
|
|
248
|
+
const entries = (o.entries ?? defaultEntries(graph)).filter((id) => ix.nodesById.has(id));
|
|
249
|
+
const targets = (o.targets ?? defaultTargets(graph)).filter((id) => ix.nodesById.has(id));
|
|
250
|
+
const targetSet = new Set(targets);
|
|
251
|
+
const nowMs = o.now === undefined || o.now === null ? null : (typeof o.now === 'number' ? o.now : Date.parse(o.now));
|
|
252
|
+
const staleAfterMs = o.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
253
|
+
const found = [];
|
|
254
|
+
let expansions = 0;
|
|
255
|
+
let truncated = false;
|
|
256
|
+
|
|
257
|
+
const walk = (seq, hopList) => {
|
|
258
|
+
if (truncated) return;
|
|
259
|
+
const here = seq[seq.length - 1];
|
|
260
|
+
if (hopList.length && targetSet.has(here)) {
|
|
261
|
+
found.push(buildPath(ix, seq, hopList, Number.isNaN(nowMs) ? null : nowMs, staleAfterMs));
|
|
262
|
+
if (found.length >= limits.maxPaths) { truncated = true; return; }
|
|
263
|
+
}
|
|
264
|
+
if (hopList.length >= limits.maxHops) return;
|
|
265
|
+
for (const [to, h] of [...hopsFrom(ix, here)].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
266
|
+
if (seq.includes(to)) continue;
|
|
267
|
+
if (++expansions > limits.maxExpansions) { truncated = true; return; }
|
|
268
|
+
walk([...seq, to], [...hopList, h]);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
if (o.includeSelf && targetSet.has(entry)) {
|
|
273
|
+
const p = buildPath(ix, [entry], [], Number.isNaN(nowMs) ? null : nowMs, staleAfterMs);
|
|
274
|
+
p.assumptions = sortedUnique([...p.assumptions, 'the entry point is itself the target; its public trust zone is declared in configuration']);
|
|
275
|
+
found.push(p);
|
|
276
|
+
}
|
|
277
|
+
walk([entry], []);
|
|
278
|
+
}
|
|
279
|
+
found.sort((a, b) => STATE_RANK[a.state] - STATE_RANK[b.state] || a.hops.length - b.hops.length || (a.id < b.id ? -1 : 1));
|
|
280
|
+
const n = found.length;
|
|
281
|
+
return {
|
|
282
|
+
ok: true, paths: found, entries, targets, truncated, limits,
|
|
283
|
+
statement: `${n} path(s) found in the supplied configuration${truncated ? ' (search stopped at a limit; more may exist)' : ''}; a path that is not listed has not been shown to exist or to be absent`,
|
|
284
|
+
errors: [],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* What the entry points can reach under the supplied configuration, without enumerating paths. `definite` means every hop on
|
|
290
|
+
* the way is possible or observed; `unresolved` means at least one hop or endpoint is unresolved. A node behind an explicit
|
|
291
|
+
* deny is not reached. Used by drift detection to compare exposure between two revisions.
|
|
292
|
+
*
|
|
293
|
+
* @returns {Map<string, {state:'definite'|'unresolved', via:string[]}>} node id to how it is reached (an entry maps to itself)
|
|
294
|
+
*/
|
|
295
|
+
export function exposureReach(graph, { entries } = {}) {
|
|
296
|
+
const ix = indexGraph(graph);
|
|
297
|
+
const start = (entries ?? defaultEntries(graph)).filter((id) => ix.nodesById.has(id));
|
|
298
|
+
const best = new Map();
|
|
299
|
+
const queue = [];
|
|
300
|
+
for (const id of start) {
|
|
301
|
+
best.set(id, { state: ix.nodesById.get(id).resolved ? 'definite' : 'unresolved', via: [] });
|
|
302
|
+
queue.push(id);
|
|
303
|
+
}
|
|
304
|
+
while (queue.length) {
|
|
305
|
+
const id = queue.shift();
|
|
306
|
+
const cur = best.get(id);
|
|
307
|
+
for (const [to, h] of hopsFrom(ix, id)) {
|
|
308
|
+
const st = hopState(h, ix.nodesById.get(id), ix.nodesById.get(to));
|
|
309
|
+
if (st === 'blocked') continue;
|
|
310
|
+
const state = cur.state === 'unresolved' || st === 'unresolved' ? 'unresolved' : 'definite';
|
|
311
|
+
const prev = best.get(to);
|
|
312
|
+
if (prev && (prev.state === 'definite' || state === 'unresolved')) continue;
|
|
313
|
+
best.set(to, { state, via: [...cur.via, ...h.edges.map((e) => e.id).sort()] });
|
|
314
|
+
queue.push(to);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return best;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ------------------------------------------------------------ trusted verification
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Judge whether a verification record is an applicable trusted oracle result for this finding. Nothing is read from a record's
|
|
324
|
+
* own claim: the record is validated, its confirmation level is recomputed from its evidence, and its oracle must be the one the
|
|
325
|
+
* finding's class names (a finding of an unsupported class has no applicable oracle).
|
|
326
|
+
*/
|
|
327
|
+
export function assessVerification(finding, records, hypothesisId = hypothesisIdFromFinding(finding)) {
|
|
328
|
+
const out = [];
|
|
329
|
+
const cls = nonTaintClassOf(finding);
|
|
330
|
+
for (const rec of Array.isArray(records) ? records : []) {
|
|
331
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
332
|
+
const valid = validateVerificationRecord(rec);
|
|
333
|
+
if (!valid.ok) { out.push({ recordId: typeof rec.id === 'string' ? rec.id : null, outcome: null, applicable: false, reasons: ['the record failed validation'] }); continue; }
|
|
334
|
+
if (rec.hypothesisId !== hypothesisId) continue;
|
|
335
|
+
const reasons = [];
|
|
336
|
+
if (rec.outcome !== 'confirmed') reasons.push(`the outcome is '${rec.outcome}', not confirmed`);
|
|
337
|
+
else if (deriveConfirmationLevel(rec) !== 'runtime-confirmed') reasons.push('no trusted runtime proof from a trusted runner backs the confirmation');
|
|
338
|
+
if (cls && !cls.supported) reasons.push(`the finding's class '${cls.classId}' has no supported oracle`);
|
|
339
|
+
else if (cls) {
|
|
340
|
+
const adapter = SCENARIO_CLASSES.find((c) => c.id === cls.classId)?.adapterId;
|
|
341
|
+
if (!rec.oracle || rec.oracle.id !== adapter) reasons.push(`the record's oracle is not '${adapter}', the oracle for class '${cls.classId}'`);
|
|
342
|
+
}
|
|
343
|
+
out.push({ recordId: rec.id, outcome: rec.outcome, applicable: reasons.length === 0, reasons });
|
|
344
|
+
}
|
|
345
|
+
return out.sort((a, b) => String(a.recordId).localeCompare(String(b.recordId)));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function labelPath(path, assessments) {
|
|
349
|
+
const ok = assessments.find((a) => a.applicable);
|
|
350
|
+
if (!ok) {
|
|
351
|
+
const why = assessments.length ? assessments.flatMap((a) => a.reasons).slice(0, 3).join('; ') : 'no verification record for this hypothesis was supplied';
|
|
352
|
+
return { label: 'not-established', recordId: null, reason: `${why}; reachability alone does not show exploitability` };
|
|
353
|
+
}
|
|
354
|
+
if (path.state === 'blocked') return { label: 'oracle-confirmed-path-blocked', recordId: ok.recordId, reason: 'the oracle confirmed the effect in its declared scenario, but configuration explicitly denies a hop on this path' };
|
|
355
|
+
if (path.state === 'unresolved') return { label: 'oracle-confirmed-path-unresolved', recordId: ok.recordId, reason: 'the oracle confirmed the effect in its declared scenario, but deployment reachability along this path is unresolved' };
|
|
356
|
+
return { label: 'exploitable-confirmed', recordId: ok.recordId, reason: 'a trusted oracle confirmed the effect in its declared scenario; deployment reachability comes from configuration or sampled observation, not from a production probe' };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ------------------------------------------------------------ the per-finding context
|
|
360
|
+
|
|
361
|
+
const EXPOSURE_ORDER = ['runtime-supported', 'possible', 'unresolved', 'blocked'];
|
|
362
|
+
|
|
363
|
+
function summarizeExposure(paths) {
|
|
364
|
+
const states = new Set(paths.map((p) => p.state));
|
|
365
|
+
const state = EXPOSURE_ORDER.find((s) => states.has(s)) ?? 'none-found';
|
|
366
|
+
const statement = {
|
|
367
|
+
'runtime-supported': 'an exposed entry point reaches the service and every network hop was observed in the supplied traces; traffic coverage is not established',
|
|
368
|
+
possible: 'an exposed entry point reaches the service according to configuration; no hop was exercised',
|
|
369
|
+
unresolved: 'an exposed entry point might reach the service, but a hop or condition is unresolved',
|
|
370
|
+
blocked: 'every path found from an exposed entry point crosses an explicit deny',
|
|
371
|
+
'none-found': 'no path from an exposed entry point was found in the supplied configuration; this does not show that none exists',
|
|
372
|
+
}[state];
|
|
373
|
+
return { state, pathCount: paths.length, statement };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The deployment context of one finding: binding, exposure paths to its service, impact paths from it, trusted-verification
|
|
378
|
+
* assessments, and what the analysis could not cover. Never throws.
|
|
379
|
+
*
|
|
380
|
+
* @param {object} args
|
|
381
|
+
* @param {object} args.graph
|
|
382
|
+
* @param {object} args.finding
|
|
383
|
+
* @param {Array} [args.bindings] see `bindFindingToService`
|
|
384
|
+
* @param {Array} [args.records] version-1 verification records
|
|
385
|
+
* @param {number|string} [args.now]
|
|
386
|
+
*/
|
|
387
|
+
export function analyzeFindingBoundaries({ graph, finding, bindings = [], records = [], now, maxHops, maxPaths } = {}) {
|
|
388
|
+
const v = validateBoundaryGraph(graph);
|
|
389
|
+
if (!v.ok) return { ok: false, context: null, errors: v.errors };
|
|
390
|
+
const hypothesisId = hypothesisIdFromFinding(finding);
|
|
391
|
+
const binding = bindFindingToService(finding, bindings, graph);
|
|
392
|
+
const assessments = assessVerification(finding, records, hypothesisId);
|
|
393
|
+
const paths = [];
|
|
394
|
+
let truncated = false;
|
|
395
|
+
const popts = { now, maxHops, maxPaths };
|
|
396
|
+
if (binding.status === 'bound') {
|
|
397
|
+
const exposure = computeAttackPaths(graph, { ...popts, targets: [binding.service.id], includeSelf: true });
|
|
398
|
+
const impact = computeAttackPaths(graph, { ...popts, entries: [binding.service.id] });
|
|
399
|
+
truncated = exposure.truncated || impact.truncated;
|
|
400
|
+
for (const p of exposure.paths) paths.push({ kind: 'exposure', ...p, exploitability: labelPath(p, assessments) });
|
|
401
|
+
for (const p of impact.paths) paths.push({ kind: 'impact', ...p, exploitability: labelPath(p, assessments) });
|
|
402
|
+
}
|
|
403
|
+
const exposurePaths = paths.filter((p) => p.kind === 'exposure');
|
|
404
|
+
const exposure = binding.status === 'bound' ? summarizeExposure(exposurePaths)
|
|
405
|
+
: { state: 'not-assessed', pathCount: 0, statement: `exposure was not assessed: ${binding.reason}` };
|
|
406
|
+
|
|
407
|
+
const uncovered = [];
|
|
408
|
+
if (binding.status !== 'bound') uncovered.push({ type: 'binding', detail: binding.reason, pathId: null });
|
|
409
|
+
const seen = new Set();
|
|
410
|
+
for (const p of paths) {
|
|
411
|
+
const unobserved = new Set(p.coverage.unobservedHops);
|
|
412
|
+
for (const c of p.crossings) {
|
|
413
|
+
if (c.type === 'identity' || unobserved.has(c.hop)) {
|
|
414
|
+
const key = `${c.type}|${c.detail}`;
|
|
415
|
+
if (!seen.has(key)) { seen.add(key); uncovered.push({ type: c.type, detail: c.detail, pathId: p.id, reason: 'configured only; no runtime observation of this boundary' }); }
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
for (const g of graph.gaps) uncovered.push({ type: 'gap', detail: `${g.code}: ${g.subject}`, pathId: null });
|
|
420
|
+
uncovered.sort((a, b) => `${a.type}|${a.detail}`.localeCompare(`${b.type}|${b.detail}`));
|
|
421
|
+
|
|
422
|
+
const warnings = ['Runtime traffic coverage is not established by any supplied observation; configured relationships were not exercised.'];
|
|
423
|
+
if (binding.status !== 'bound') warnings.push(`This finding is not tied to a deployed service (${binding.status}); no path was computed.`);
|
|
424
|
+
if (graph.gaps.length) warnings.push(`The boundary graph has ${graph.gaps.length} gap(s) (${sortedUnique(graph.gaps.map((g) => g.code)).join(', ')}); relationships behind them are missing, not absent.`);
|
|
425
|
+
if (truncated) warnings.push('The path search stopped at a limit; more paths may exist.');
|
|
426
|
+
|
|
427
|
+
const context = {
|
|
428
|
+
schema: BOUNDARY_CONTEXT_SCHEMA, schemaVersion: BOUNDARY_CONTEXT_VERSION, id: '', hypothesisId,
|
|
429
|
+
finding: { id: nul(finding?.id), stableId: nul(finding?.stableId), file: nul(finding?.file), line: Number.isInteger(finding?.line) ? finding.line : null, vuln: nul(finding?.vuln), cwe: nul(finding?.cwe) },
|
|
430
|
+
binding: { status: binding.status, reason: binding.reason, rule: binding.binding, service: binding.service },
|
|
431
|
+
graph: {
|
|
432
|
+
repository: graph.repository, revision: graph.revision, digest: graph.digest,
|
|
433
|
+
environments: sortedUnique(graph.nodes.map((n) => n.environment).filter(Boolean)),
|
|
434
|
+
repositories: sortedUnique([graph.repository, ...graph.nodes.map((n) => n.repository)].filter(Boolean)),
|
|
435
|
+
tenants: sortedUnique(graph.nodes.map((n) => n.tenant).filter(Boolean)),
|
|
436
|
+
gapCodes: sortedUnique(graph.gaps.map((g) => g.code)), gapCount: graph.gaps.length,
|
|
437
|
+
},
|
|
438
|
+
exposure, paths, truncated,
|
|
439
|
+
verification: assessments, uncovered, warnings,
|
|
440
|
+
};
|
|
441
|
+
context.id = semanticId('bctx', { hypothesisId, graph: graph.digest, binding: binding.binding, service: binding.service?.id ?? null, paths: paths.map((p) => p.id) }, ['hypothesisId', 'graph', 'binding', 'service', 'paths']);
|
|
442
|
+
return { ok: true, context, errors: [] };
|
|
443
|
+
}
|