@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,567 @@
|
|
|
1
|
+
// boundary-graph.js: the deployment boundary graph contract (X-301).
|
|
2
|
+
//
|
|
3
|
+
// A SEPARATE graph from `DataFlowGraph v1`. That graph answers "where does a
|
|
4
|
+
// data element flow"; this one answers "what can reach what, as deployed":
|
|
5
|
+
// repositories, services, routes, identities, tenants, resources and
|
|
6
|
+
// environments, joined by typed relations that each state which trust
|
|
7
|
+
// boundary they cross. It lives in lineage because it is a graph contract, but
|
|
8
|
+
// it shares no mutable state with the taint or vulnerability engines. The only
|
|
9
|
+
// things imported from elsewhere are pure helpers (the assurance schema kit,
|
|
10
|
+
// canonical serialization and content hashing) and three assurance enums, so
|
|
11
|
+
// the vocabulary for provenance, path state and observation completeness is
|
|
12
|
+
// the one the verification layer already speaks.
|
|
13
|
+
//
|
|
14
|
+
// Three rules the whole design hangs on:
|
|
15
|
+
//
|
|
16
|
+
// 1. Static configuration, runtime observation and inference are three
|
|
17
|
+
// different kinds of evidence. `provenance` is one of the assurance
|
|
18
|
+
// BINDING_PROVENANCE values and is part of an edge's identity, so a
|
|
19
|
+
// configured edge and an observed edge between the same two nodes are two
|
|
20
|
+
// edges that stay distinguishable, never merged into one "stronger" edge.
|
|
21
|
+
// 2. A path is possible, blocked, unresolved or runtime-supported
|
|
22
|
+
// (assurance PATH_STATES). Blocked comes only from an explicit deny.
|
|
23
|
+
// Unresolved is a first-class answer, carried by unresolved nodes and
|
|
24
|
+
// gaps, never papered over with a guessed edge.
|
|
25
|
+
// 3. An old or sampled trace cannot establish complete production coverage.
|
|
26
|
+
// The edge completeness enum here omits `complete` on purpose: this
|
|
27
|
+
// version of the contract has no way to assert it, so no producer can.
|
|
28
|
+
//
|
|
29
|
+
// Identity: node ids hash (kind, name, environment, tenant), edge ids hash the
|
|
30
|
+
// relation, endpoints, environment, tenant, effect, provenance, discriminator
|
|
31
|
+
// and cross-repository binding. Order, clocks and source file names never
|
|
32
|
+
// enter an id, so ids survive reordering, and two environments or tenants with
|
|
33
|
+
// the same service name are two nodes. The owning repository is deliberately
|
|
34
|
+
// NOT part of a node id: a caller's reference to "payments in prod" and the
|
|
35
|
+
// callee repository's own declaration of it must resolve to the same node, and
|
|
36
|
+
// two repositories both claiming one declared identity is an identity
|
|
37
|
+
// collision (X-304), which is only detectable if the ids coincide.
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
SCHEMA_VERSION, makeCtx, result, guardObject, checkHeader, checkFields, checkEnum, checkDigest, checkString,
|
|
41
|
+
isPlainObject, isNonEmptyString,
|
|
42
|
+
} from '../../posture/assurance/schema-kit.js';
|
|
43
|
+
import { semanticId, digestOf, canonicalize } from '../../posture/assurance/identity.js';
|
|
44
|
+
import { BINDING_PROVENANCE, PATH_STATES, OBSERVATION_COMPLETENESS } from '../../posture/assurance/contracts.js';
|
|
45
|
+
|
|
46
|
+
export const BOUNDARY_GRAPH_SCHEMA = 'agentic-security/deployment-boundary-graph';
|
|
47
|
+
const BOUNDARY_GRAPH_VERSION = SCHEMA_VERSION;
|
|
48
|
+
|
|
49
|
+
export const NODE_KINDS = Object.freeze(['repository', 'service', 'route', 'identity', 'tenant', 'resource', 'environment']);
|
|
50
|
+
export const TRUST_ZONES = Object.freeze(['public', 'edge', 'internal', 'privileged', 'tenant-isolated', 'unknown']);
|
|
51
|
+
export const BOUNDARY_TYPES = Object.freeze(['network', 'identity', 'tenant', 'environment', 'repository']);
|
|
52
|
+
export const EDGE_EFFECTS = Object.freeze(['allow', 'deny', 'none']);
|
|
53
|
+
export const EDGE_CONFIDENCE = Object.freeze(['high', 'medium', 'low']);
|
|
54
|
+
// `complete` is deliberately absent: see rule 3 in the header.
|
|
55
|
+
export const EDGE_COMPLETENESS = Object.freeze(OBSERVATION_COMPLETENESS.filter(c => c !== 'complete'));
|
|
56
|
+
export { BINDING_PROVENANCE as EDGE_PROVENANCE, PATH_STATES };
|
|
57
|
+
|
|
58
|
+
export const GAP_CODES = Object.freeze([
|
|
59
|
+
'unsupported-syntax', 'unsupported-format', 'malformed-input', 'unresolved-identity', 'conflicting-configuration',
|
|
60
|
+
'identity-collision', 'repository-unavailable', 'access-denied', 'version-mismatch', 'revision-unknown',
|
|
61
|
+
'graph-integrity', 'source-changed', 'source-missing', 'limit-exceeded',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
// Explicit edge semantics: which node kinds a relation may join, the trust
|
|
65
|
+
// boundary it crosses, and what it means. A relation outside this table is
|
|
66
|
+
// refused by the validator.
|
|
67
|
+
export const RELATION_SEMANTICS = Object.freeze({
|
|
68
|
+
'defined-in': { from: ['service', 'route', 'identity', 'resource'], to: ['repository'], boundary: 'repository', structural: true, meaning: 'the declaring repository owns this element' },
|
|
69
|
+
'deployed-in': { from: ['service', 'resource', 'route', 'identity'], to: ['environment'], boundary: 'environment', structural: true, meaning: 'the element exists in this deployment environment' },
|
|
70
|
+
'scoped-to': { from: ['service', 'resource', 'identity', 'route'], to: ['tenant'], boundary: 'tenant', structural: true, meaning: 'the element is bound to this tenant' },
|
|
71
|
+
'routes-to': { from: ['route'], to: ['service', 'route'], boundary: 'network', structural: false, meaning: 'a gateway or ingress forwards matching requests to the target' },
|
|
72
|
+
'calls': { from: ['service'], to: ['service', 'route', 'resource'], boundary: 'network', structural: false, meaning: 'the source invokes the target over the network' },
|
|
73
|
+
'depends-on': { from: ['service'], to: ['service', 'resource'], boundary: 'network', structural: false, meaning: 'declared dependency; implies connectivity, not that a call happens' },
|
|
74
|
+
'network-allows': { from: ['service', 'route'], to: ['service', 'resource'], boundary: 'network', structural: false, meaning: 'a network policy or security rule permits traffic from source to target' },
|
|
75
|
+
'network-denies': { from: ['service', 'route'], to: ['service', 'resource'], boundary: 'network', structural: false, meaning: 'a network policy or security rule explicitly refuses traffic from source to target' },
|
|
76
|
+
'assumes': { from: ['service'], to: ['identity'], boundary: 'identity', structural: false, meaning: 'the workload runs as this identity' },
|
|
77
|
+
'grants': { from: ['identity'], to: ['resource'], boundary: 'identity', structural: false, meaning: 'a policy allows or denies the identity actions on the resource' },
|
|
78
|
+
});
|
|
79
|
+
export const RELATIONS = Object.freeze(Object.keys(RELATION_SEMANTICS));
|
|
80
|
+
|
|
81
|
+
const NODE_FIELDS = ['id', 'kind', 'name', 'environment', 'tenant', 'repository', 'trustZone', 'resolved', 'unresolvedReason', 'attrs', 'declaredBy'];
|
|
82
|
+
const NODE_REQUIRED = NODE_FIELDS;
|
|
83
|
+
const EDGE_FIELDS = [
|
|
84
|
+
'id', 'relation', 'from', 'to', 'environment', 'tenant', 'effect', 'provenance', 'confidence', 'pathState',
|
|
85
|
+
'observationInterval', 'completeness', 'sourceRevision', 'source', 'discriminator', 'crossRepo', 'attrs',
|
|
86
|
+
];
|
|
87
|
+
const EDGE_REQUIRED = EDGE_FIELDS;
|
|
88
|
+
const GRAPH_FIELDS = ['schema', 'schemaVersion', 'repository', 'revision', 'nodes', 'edges', 'gaps', 'sources', 'digest'];
|
|
89
|
+
const SOURCE_FIELDS = ['file', 'digest', 'parser', 'parserVersion', 'bytes'];
|
|
90
|
+
const GAP_FIELDS = ['code', 'subject', 'file', 'message'];
|
|
91
|
+
|
|
92
|
+
export const MAX_ATTR_KEYS = 16;
|
|
93
|
+
const ATTR_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
|
|
94
|
+
const ATTR_VALUE = /^[A-Za-z0-9_.:\/*,@ +=-]{0,256}$/;
|
|
95
|
+
const NAME_RE = /^[^\u0000-\u001f\u007f]{1,256}$/;
|
|
96
|
+
const REVISION_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
97
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
98
|
+
|
|
99
|
+
const NODE_ID_FIELDS = ['kind', 'name', 'environment', 'tenant'];
|
|
100
|
+
const EDGE_ID_FIELDS = ['relation', 'from', 'to', 'environment', 'tenant', 'effect', 'provenance', 'discriminator', 'crossRepo'];
|
|
101
|
+
|
|
102
|
+
function nul(v) { return v === undefined ? null : v; }
|
|
103
|
+
function sortedUnique(arr) { return [...new Set(arr)].sort(); }
|
|
104
|
+
|
|
105
|
+
export function nodeIdOf(node) {
|
|
106
|
+
return semanticId('bnode', { kind: node.kind, name: node.name, environment: nul(node.environment), tenant: nul(node.tenant) }, NODE_ID_FIELDS);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function edgeIdOf(edge) {
|
|
110
|
+
return semanticId('bedge', {
|
|
111
|
+
relation: edge.relation, from: edge.from, to: edge.to, environment: nul(edge.environment), tenant: nul(edge.tenant),
|
|
112
|
+
effect: edge.effect ?? 'none', provenance: edge.provenance, discriminator: nul(edge.discriminator), crossRepo: nul(edge.crossRepo),
|
|
113
|
+
}, EDGE_ID_FIELDS);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The id of a gap, used to dedupe and order gaps. */
|
|
117
|
+
function gapKey(g) { return canonicalize({ code: g.code, subject: g.subject, file: nul(g.file), message: g.message }); }
|
|
118
|
+
|
|
119
|
+
// ------------------------------------------------------------ normalization
|
|
120
|
+
|
|
121
|
+
function normalizeAttrs(attrs) {
|
|
122
|
+
if (!isPlainObject(attrs)) return {};
|
|
123
|
+
const out = {};
|
|
124
|
+
for (const k of Object.keys(attrs).sort()) out[k] = attrs[k];
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Fill defaults and compute the id. Does not validate. */
|
|
129
|
+
export function normalizeNode(n) {
|
|
130
|
+
const kind = n.kind;
|
|
131
|
+
const node = {
|
|
132
|
+
id: '',
|
|
133
|
+
kind,
|
|
134
|
+
name: n.name,
|
|
135
|
+
environment: kind === 'environment' ? n.name : nul(n.environment),
|
|
136
|
+
tenant: kind === 'tenant' ? n.name : nul(n.tenant),
|
|
137
|
+
repository: nul(n.repository),
|
|
138
|
+
trustZone: n.trustZone ?? 'unknown',
|
|
139
|
+
resolved: n.resolved === false ? false : true,
|
|
140
|
+
unresolvedReason: n.resolved === false ? (n.unresolvedReason ?? 'unresolved') : null,
|
|
141
|
+
attrs: normalizeAttrs(n.attrs),
|
|
142
|
+
declaredBy: Array.isArray(n.declaredBy) ? n.declaredBy.map(d => ({ file: d.file, digest: d.digest })).sort(compareRef) : [],
|
|
143
|
+
};
|
|
144
|
+
node.id = nodeIdOf(node);
|
|
145
|
+
return node;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function compareRef(a, b) { return a.file < b.file ? -1 : a.file > b.file ? 1 : a.digest < b.digest ? -1 : a.digest > b.digest ? 1 : 0; }
|
|
149
|
+
|
|
150
|
+
export function normalizeEdge(e) {
|
|
151
|
+
const edge = {
|
|
152
|
+
id: '',
|
|
153
|
+
relation: e.relation,
|
|
154
|
+
from: e.from,
|
|
155
|
+
to: e.to,
|
|
156
|
+
environment: nul(e.environment),
|
|
157
|
+
tenant: nul(e.tenant),
|
|
158
|
+
effect: e.effect ?? 'none',
|
|
159
|
+
provenance: e.provenance,
|
|
160
|
+
confidence: e.confidence ?? 'medium',
|
|
161
|
+
pathState: e.pathState ?? 'possible',
|
|
162
|
+
observationInterval: e.observationInterval ? { start: e.observationInterval.start, end: e.observationInterval.end } : null,
|
|
163
|
+
completeness: nul(e.completeness),
|
|
164
|
+
sourceRevision: nul(e.sourceRevision),
|
|
165
|
+
source: e.source ? { file: e.source.file, digest: e.source.digest, parser: e.source.parser, parserVersion: e.source.parserVersion } : null,
|
|
166
|
+
discriminator: nul(e.discriminator),
|
|
167
|
+
crossRepo: e.crossRepo ? {
|
|
168
|
+
callerRepository: e.crossRepo.callerRepository, callerRevision: nul(e.crossRepo.callerRevision),
|
|
169
|
+
calleeRepository: e.crossRepo.calleeRepository, calleeRevision: nul(e.crossRepo.calleeRevision),
|
|
170
|
+
} : null,
|
|
171
|
+
attrs: normalizeAttrs(e.attrs),
|
|
172
|
+
};
|
|
173
|
+
edge.id = edgeIdOf(edge);
|
|
174
|
+
return edge;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ------------------------------------------------------------ validation
|
|
178
|
+
|
|
179
|
+
function checkAttrs(ctx, path, attrs) {
|
|
180
|
+
if (!isPlainObject(attrs)) { ctx.err('BAD_TYPE', path, 'attrs must be an object'); return; }
|
|
181
|
+
const keys = Object.keys(attrs);
|
|
182
|
+
if (keys.length > MAX_ATTR_KEYS) ctx.err('RULE_VIOLATION', path, `attrs may carry at most ${MAX_ATTR_KEYS} keys`);
|
|
183
|
+
for (const k of keys) {
|
|
184
|
+
if (!ATTR_KEY.test(k)) { ctx.err('BAD_TYPE', `${path}.${k}`, 'attribute key is not identifier-shaped'); continue; }
|
|
185
|
+
const v = attrs[k];
|
|
186
|
+
if (typeof v === 'string') { if (!ATTR_VALUE.test(v)) ctx.err('BAD_TYPE', `${path}.${k}`, 'attribute value contains characters outside the metadata grammar'); }
|
|
187
|
+
else if (typeof v === 'number') { if (!Number.isFinite(v)) ctx.err('BAD_TYPE', `${path}.${k}`, 'attribute number must be finite'); }
|
|
188
|
+
else if (typeof v !== 'boolean') ctx.err('BAD_TYPE', `${path}.${k}`, 'attribute value must be a string, number or boolean');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function checkRef(ctx, path, ref) {
|
|
193
|
+
if (!isPlainObject(ref)) { ctx.err('BAD_TYPE', path, 'must be an object'); return; }
|
|
194
|
+
for (const k of Object.keys(ref)) if (!SOURCE_FIELDS.includes(k)) ctx.err('UNKNOWN_FIELD', `${path}.${k}`, 'not part of a source reference');
|
|
195
|
+
checkString(ctx, `${path}.file`, ref.file);
|
|
196
|
+
checkDigest(ctx, `${path}.digest`, ref.digest);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validateNode(ctx, n, i) {
|
|
200
|
+
const p = `nodes[${i}]`;
|
|
201
|
+
if (!isPlainObject(n)) { ctx.err('BAD_TYPE', p, 'node must be an object'); return; }
|
|
202
|
+
for (const k of Object.keys(n)) if (!NODE_FIELDS.includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.${k}`, `field '${k}' is not part of a node`);
|
|
203
|
+
for (const k of NODE_REQUIRED) if (n[k] === undefined) ctx.err('MISSING_FIELD', `${p}.${k}`, `required field '${k}' is missing`);
|
|
204
|
+
const kindOk = checkEnum(ctx, `${p}.kind`, n.kind, NODE_KINDS);
|
|
205
|
+
if (typeof n.name !== 'string' || !NAME_RE.test(n.name)) ctx.err('BAD_TYPE', `${p}.name`, 'name must be 1-256 printable characters');
|
|
206
|
+
checkEnum(ctx, `${p}.trustZone`, n.trustZone, TRUST_ZONES);
|
|
207
|
+
for (const f of ['environment', 'tenant', 'repository']) {
|
|
208
|
+
if (n[f] !== null && n[f] !== undefined && (typeof n[f] !== 'string' || !NAME_RE.test(n[f]))) ctx.err('BAD_TYPE', `${p}.${f}`, `${f} must be null or a printable name`);
|
|
209
|
+
}
|
|
210
|
+
if (typeof n.resolved !== 'boolean') ctx.err('BAD_TYPE', `${p}.resolved`, 'resolved must be a boolean');
|
|
211
|
+
else if (n.resolved === false && !isNonEmptyString(n.unresolvedReason)) ctx.err('RULE_VIOLATION', `${p}.unresolvedReason`, 'an unresolved node must say why');
|
|
212
|
+
else if (n.resolved === true && n.unresolvedReason !== null) ctx.err('RULE_VIOLATION', `${p}.unresolvedReason`, 'a resolved node has no unresolvedReason');
|
|
213
|
+
checkAttrs(ctx, `${p}.attrs`, n.attrs);
|
|
214
|
+
if (!Array.isArray(n.declaredBy)) ctx.err('BAD_TYPE', `${p}.declaredBy`, 'must be an array');
|
|
215
|
+
else n.declaredBy.forEach((d, j) => checkRef(ctx, `${p}.declaredBy[${j}]`, d));
|
|
216
|
+
if (kindOk && n.kind === 'environment' && n.environment !== n.name) ctx.err('RULE_VIOLATION', `${p}.environment`, 'an environment node carries its own name as environment');
|
|
217
|
+
if (kindOk && n.kind === 'tenant' && n.tenant !== n.name) ctx.err('RULE_VIOLATION', `${p}.tenant`, 'a tenant node carries its own name as tenant');
|
|
218
|
+
if (kindOk && typeof n.name === 'string' && typeof n.id === 'string') {
|
|
219
|
+
const expected = nodeIdOf(n);
|
|
220
|
+
if (!n.id.startsWith('bnode:')) ctx.err('BAD_ID', `${p}.id`, "node id must start with 'bnode:'");
|
|
221
|
+
else if (n.id !== expected) ctx.err('ID_MISMATCH', `${p}.id`, `node id does not match its content (expected ${expected})`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function validateEdge(ctx, e, i, nodesById) {
|
|
226
|
+
const p = `edges[${i}]`;
|
|
227
|
+
if (!isPlainObject(e)) { ctx.err('BAD_TYPE', p, 'edge must be an object'); return; }
|
|
228
|
+
for (const k of Object.keys(e)) if (!EDGE_FIELDS.includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.${k}`, `field '${k}' is not part of an edge`);
|
|
229
|
+
for (const k of EDGE_REQUIRED) if (e[k] === undefined) ctx.err('MISSING_FIELD', `${p}.${k}`, `required field '${k}' is missing`);
|
|
230
|
+
const relOk = checkEnum(ctx, `${p}.relation`, e.relation, RELATIONS);
|
|
231
|
+
const effOk = checkEnum(ctx, `${p}.effect`, e.effect, EDGE_EFFECTS);
|
|
232
|
+
const provOk = checkEnum(ctx, `${p}.provenance`, e.provenance, BINDING_PROVENANCE);
|
|
233
|
+
checkEnum(ctx, `${p}.confidence`, e.confidence, EDGE_CONFIDENCE);
|
|
234
|
+
const stateOk = checkEnum(ctx, `${p}.pathState`, e.pathState, PATH_STATES);
|
|
235
|
+
for (const f of ['environment', 'tenant', 'discriminator', 'sourceRevision']) {
|
|
236
|
+
if (e[f] !== null && e[f] !== undefined && (typeof e[f] !== 'string' || !NAME_RE.test(e[f]))) ctx.err('BAD_TYPE', `${p}.${f}`, `${f} must be null or a printable string`);
|
|
237
|
+
}
|
|
238
|
+
checkAttrs(ctx, `${p}.attrs`, e.attrs);
|
|
239
|
+
|
|
240
|
+
// source reference (X-302.AC03)
|
|
241
|
+
if (e.source !== null && e.source !== undefined) {
|
|
242
|
+
if (!isPlainObject(e.source)) ctx.err('BAD_TYPE', `${p}.source`, 'must be an object or null');
|
|
243
|
+
else {
|
|
244
|
+
for (const k of Object.keys(e.source)) if (!SOURCE_FIELDS.includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.source.${k}`, 'not part of a source reference');
|
|
245
|
+
checkString(ctx, `${p}.source.file`, e.source.file);
|
|
246
|
+
checkDigest(ctx, `${p}.source.digest`, e.source.digest);
|
|
247
|
+
checkString(ctx, `${p}.source.parser`, e.source.parser);
|
|
248
|
+
checkString(ctx, `${p}.source.parserVersion`, e.source.parserVersion);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (provOk && e.provenance === 'static-config' && !isPlainObject(e.source)) {
|
|
252
|
+
ctx.err('RULE_VIOLATION', `${p}.source`, 'a statically configured edge must reference the exact file or artifact it came from');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// observation interval and completeness (X-301.AC02, X-303.AC02)
|
|
256
|
+
if (provOk) {
|
|
257
|
+
if (e.provenance === 'runtime-observation') {
|
|
258
|
+
const iv = e.observationInterval;
|
|
259
|
+
if (!isPlainObject(iv) || !ISO.test(iv.start ?? '') || !ISO.test(iv.end ?? '') || !(Date.parse(iv.start) <= Date.parse(iv.end))) {
|
|
260
|
+
ctx.err('RULE_VIOLATION', `${p}.observationInterval`, 'a runtime edge must state a valid observation interval {start <= end}');
|
|
261
|
+
}
|
|
262
|
+
if (e.completeness !== null && !EDGE_COMPLETENESS.includes(e.completeness)) ctx.err('UNKNOWN_ENUM', `${p}.completeness`, `completeness must be one of: ${EDGE_COMPLETENESS.join(', ')} (this contract cannot assert 'complete')`);
|
|
263
|
+
if (e.completeness === null) ctx.err('RULE_VIOLATION', `${p}.completeness`, 'a runtime edge must state whether it is sampled or unknown');
|
|
264
|
+
if (stateOk && !['runtime-supported', 'unresolved'].includes(e.pathState)) ctx.err('RULE_VIOLATION', `${p}.pathState`, `a runtime edge is 'runtime-supported' or 'unresolved', not '${e.pathState}'`);
|
|
265
|
+
} else {
|
|
266
|
+
if (e.observationInterval !== null) ctx.err('RULE_VIOLATION', `${p}.observationInterval`, 'only a runtime edge has an observation interval');
|
|
267
|
+
if (e.completeness !== null) ctx.err('RULE_VIOLATION', `${p}.completeness`, 'only a runtime edge has an observation completeness');
|
|
268
|
+
if (stateOk && e.pathState === 'runtime-supported') ctx.err('RULE_VIOLATION', `${p}.pathState`, "'runtime-supported' requires provenance 'runtime-observation'");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (effOk && stateOk) {
|
|
272
|
+
if (e.effect === 'deny' && e.pathState !== 'blocked') ctx.err('RULE_VIOLATION', `${p}.pathState`, "an explicit deny is 'blocked'");
|
|
273
|
+
if (e.pathState === 'blocked' && e.effect !== 'deny') ctx.err('RULE_VIOLATION', `${p}.pathState`, "'blocked' requires an explicit deny; absence of an allow or of an observation never blocks a path");
|
|
274
|
+
}
|
|
275
|
+
if (relOk && effOk) {
|
|
276
|
+
if (e.relation === 'network-denies' && e.effect !== 'deny') ctx.err('RULE_VIOLATION', `${p}.effect`, "'network-denies' has effect 'deny'");
|
|
277
|
+
if (RELATION_SEMANTICS[e.relation].structural && e.effect !== 'none') ctx.err('RULE_VIOLATION', `${p}.effect`, `'${e.relation}' is structural and has no allow/deny effect`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// cross-repository binding
|
|
281
|
+
if (e.crossRepo !== null && e.crossRepo !== undefined) {
|
|
282
|
+
if (!isPlainObject(e.crossRepo)) ctx.err('BAD_TYPE', `${p}.crossRepo`, 'must be an object or null');
|
|
283
|
+
else {
|
|
284
|
+
for (const k of Object.keys(e.crossRepo)) if (!['callerRepository', 'callerRevision', 'calleeRepository', 'calleeRevision'].includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.crossRepo.${k}`, 'not part of a cross-repository binding');
|
|
285
|
+
checkString(ctx, `${p}.crossRepo.callerRepository`, e.crossRepo.callerRepository);
|
|
286
|
+
checkString(ctx, `${p}.crossRepo.calleeRepository`, e.crossRepo.calleeRepository);
|
|
287
|
+
for (const f of ['callerRevision', 'calleeRevision']) {
|
|
288
|
+
if (!(typeof e.crossRepo[f] === 'string' && REVISION_RE.test(e.crossRepo[f]))) ctx.err('RULE_VIOLATION', `${p}.crossRepo.${f}`, 'a cross-repository edge binds an exact 40 or 64 hex commit revision');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// endpoints
|
|
294
|
+
const from = typeof e.from === 'string' ? nodesById.get(e.from) : undefined;
|
|
295
|
+
const to = typeof e.to === 'string' ? nodesById.get(e.to) : undefined;
|
|
296
|
+
if (!from) ctx.err('DANGLING_REF', `${p}.from`, 'from does not reference a node in this graph');
|
|
297
|
+
if (!to) ctx.err('DANGLING_REF', `${p}.to`, 'to does not reference a node in this graph');
|
|
298
|
+
if (from && to && relOk) {
|
|
299
|
+
const sem = RELATION_SEMANTICS[e.relation];
|
|
300
|
+
if (!sem.from.includes(from.kind)) ctx.err('RULE_VIOLATION', `${p}.from`, `'${e.relation}' cannot start at a ${from.kind}`);
|
|
301
|
+
if (!sem.to.includes(to.kind)) ctx.err('RULE_VIOLATION', `${p}.to`, `'${e.relation}' cannot end at a ${to.kind}`);
|
|
302
|
+
// Environments are never merged: a non-structural edge joins nodes of one environment.
|
|
303
|
+
if (!sem.structural) {
|
|
304
|
+
if (from.environment !== null && to.environment !== null && from.environment !== to.environment && !(e.crossRepo)) {
|
|
305
|
+
ctx.err('RULE_VIOLATION', `${p}.environment`, `edge would join environments '${from.environment}' and '${to.environment}'`);
|
|
306
|
+
}
|
|
307
|
+
const envs = sortedUnique([from.environment, to.environment].filter(x => x !== null));
|
|
308
|
+
if (envs.length === 1 && e.environment !== envs[0]) ctx.err('RULE_VIOLATION', `${p}.environment`, `edge environment must be '${envs[0]}' to match its endpoints`);
|
|
309
|
+
if (envs.length === 0 && e.environment !== null) ctx.err('RULE_VIOLATION', `${p}.environment`, 'edge environment is set but neither endpoint has one');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (relOk && typeof e.from === 'string' && typeof e.to === 'string' && provOk && effOk) {
|
|
313
|
+
const expected = edgeIdOf(e);
|
|
314
|
+
if (typeof e.id !== 'string' || !e.id.startsWith('bedge:')) ctx.err('BAD_ID', `${p}.id`, "edge id must start with 'bedge:'");
|
|
315
|
+
else if (e.id !== expected) ctx.err('ID_MISMATCH', `${p}.id`, `edge id does not match its content (expected ${expected})`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function graphMaterial(g) {
|
|
320
|
+
return { schema: g.schema, schemaVersion: g.schemaVersion, repository: g.repository, revision: g.revision, nodes: g.nodes, edges: g.edges, gaps: g.gaps, sources: g.sources };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function computeGraphDigest(g) { return digestOf(graphMaterial(g)); }
|
|
324
|
+
|
|
325
|
+
export function validateBoundaryGraph(g) {
|
|
326
|
+
const gd = guardObject(g);
|
|
327
|
+
const ctx = gd.ctx;
|
|
328
|
+
if (!gd.ok) return result(ctx);
|
|
329
|
+
if (!checkHeader(ctx, g, BOUNDARY_GRAPH_SCHEMA)) return result(ctx);
|
|
330
|
+
for (const k of Object.keys(g)) if (!GRAPH_FIELDS.includes(k)) ctx.err('UNKNOWN_FIELD', k, `field '${k}' is not part of this schema`);
|
|
331
|
+
for (const k of GRAPH_FIELDS) if (g[k] === undefined) ctx.err('MISSING_FIELD', k, `required field '${k}' is missing`);
|
|
332
|
+
if (g.repository !== null && (typeof g.repository !== 'string' || !NAME_RE.test(g.repository))) ctx.err('BAD_TYPE', 'repository', 'repository must be null or a printable name');
|
|
333
|
+
if (g.revision !== null && !(typeof g.revision === 'string' && REVISION_RE.test(g.revision))) ctx.err('BAD_TYPE', 'revision', 'revision must be null or an exact 40/64 hex commit');
|
|
334
|
+
if (!Array.isArray(g.nodes) || !Array.isArray(g.edges) || !Array.isArray(g.gaps) || !Array.isArray(g.sources)) {
|
|
335
|
+
ctx.err('BAD_TYPE', '', 'nodes, edges, gaps and sources must be arrays');
|
|
336
|
+
return result(ctx);
|
|
337
|
+
}
|
|
338
|
+
const nodesById = new Map();
|
|
339
|
+
g.nodes.forEach((n, i) => {
|
|
340
|
+
validateNode(ctx, n, i);
|
|
341
|
+
if (isPlainObject(n) && typeof n.id === 'string') {
|
|
342
|
+
if (nodesById.has(n.id)) ctx.err('DUPLICATE_ID', `nodes[${i}].id`, `duplicate node id ${n.id}`);
|
|
343
|
+
else nodesById.set(n.id, n);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
const edgeIds = new Set();
|
|
347
|
+
g.edges.forEach((e, i) => {
|
|
348
|
+
validateEdge(ctx, e, i, nodesById);
|
|
349
|
+
if (isPlainObject(e) && typeof e.id === 'string') {
|
|
350
|
+
if (edgeIds.has(e.id)) ctx.err('DUPLICATE_ID', `edges[${i}].id`, `duplicate edge id ${e.id}`);
|
|
351
|
+
edgeIds.add(e.id);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
g.gaps.forEach((gap, i) => {
|
|
355
|
+
if (!isPlainObject(gap)) { ctx.err('BAD_TYPE', `gaps[${i}]`, 'gap must be an object'); return; }
|
|
356
|
+
for (const k of Object.keys(gap)) if (!GAP_FIELDS.includes(k)) ctx.err('UNKNOWN_FIELD', `gaps[${i}].${k}`, 'not part of a gap');
|
|
357
|
+
checkEnum(ctx, `gaps[${i}].code`, gap.code, GAP_CODES);
|
|
358
|
+
checkString(ctx, `gaps[${i}].subject`, gap.subject);
|
|
359
|
+
checkString(ctx, `gaps[${i}].message`, gap.message);
|
|
360
|
+
if (gap.file !== null && gap.file !== undefined && typeof gap.file !== 'string') ctx.err('BAD_TYPE', `gaps[${i}].file`, 'file must be null or a string');
|
|
361
|
+
});
|
|
362
|
+
g.sources.forEach((s, i) => {
|
|
363
|
+
if (!isPlainObject(s)) { ctx.err('BAD_TYPE', `sources[${i}]`, 'source must be an object'); return; }
|
|
364
|
+
for (const k of Object.keys(s)) if (!SOURCE_FIELDS.includes(k)) ctx.err('UNKNOWN_FIELD', `sources[${i}].${k}`, 'not part of a source');
|
|
365
|
+
checkString(ctx, `sources[${i}].file`, s.file);
|
|
366
|
+
checkDigest(ctx, `sources[${i}].digest`, s.digest);
|
|
367
|
+
checkString(ctx, `sources[${i}].parser`, s.parser);
|
|
368
|
+
checkString(ctx, `sources[${i}].parserVersion`, s.parserVersion);
|
|
369
|
+
if (!Number.isInteger(s.bytes) || s.bytes < 0) ctx.err('BAD_TYPE', `sources[${i}].bytes`, 'bytes must be a non-negative integer');
|
|
370
|
+
});
|
|
371
|
+
if (checkDigest(ctx, 'digest', g.digest) && ctx.errors.length === 0 && g.digest !== computeGraphDigest(g)) {
|
|
372
|
+
ctx.err('BAD_DIGEST', 'digest', 'digest does not match the graph content');
|
|
373
|
+
}
|
|
374
|
+
return result(ctx);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ------------------------------------------------------------ building
|
|
378
|
+
|
|
379
|
+
function compareById(a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }
|
|
380
|
+
|
|
381
|
+
function mergeNodes(rawNodes, gaps) {
|
|
382
|
+
const byId = new Map();
|
|
383
|
+
for (const raw of rawNodes) {
|
|
384
|
+
const n = normalizeNode(raw);
|
|
385
|
+
const prev = byId.get(n.id);
|
|
386
|
+
if (!prev) { byId.set(n.id, n); continue; }
|
|
387
|
+
const m = prev;
|
|
388
|
+
m.declaredBy = [...new Map([...m.declaredBy, ...n.declaredBy].map(d => [`${d.file}\n${d.digest}`, d])).values()].sort(compareRef);
|
|
389
|
+
if (m.trustZone !== n.trustZone) {
|
|
390
|
+
if (m.trustZone !== 'unknown' || n.trustZone !== 'unknown') {
|
|
391
|
+
gaps.push({ code: 'conflicting-configuration', subject: m.id, file: null, message: `node '${m.name}' is declared with trust zones '${m.trustZone}' and '${n.trustZone}'; recorded as 'unknown'` });
|
|
392
|
+
}
|
|
393
|
+
m.trustZone = 'unknown';
|
|
394
|
+
}
|
|
395
|
+
if (m.repository !== n.repository) {
|
|
396
|
+
if (m.repository !== null && n.repository !== null) {
|
|
397
|
+
gaps.push({ code: 'identity-collision', subject: m.id, file: null, message: `node '${m.name}' is claimed by repositories '${m.repository}' and '${n.repository}'` });
|
|
398
|
+
m.repository = null; m.resolved = false; m.unresolvedReason = 'identity-collision';
|
|
399
|
+
} else if (m.repository === null) m.repository = n.repository;
|
|
400
|
+
}
|
|
401
|
+
if (m.resolved !== n.resolved) {
|
|
402
|
+
// A resolved declaration wins over an unresolved reference to the same identity.
|
|
403
|
+
if (m.unresolvedReason !== 'identity-collision') { m.resolved = true; m.unresolvedReason = null; }
|
|
404
|
+
}
|
|
405
|
+
for (const k of Object.keys(n.attrs)) {
|
|
406
|
+
if (m.attrs[k] === undefined) m.attrs[k] = n.attrs[k];
|
|
407
|
+
else if (m.attrs[k] !== n.attrs[k]) {
|
|
408
|
+
gaps.push({ code: 'conflicting-configuration', subject: m.id, file: null, message: `node '${m.name}' attribute '${k}' has conflicting values; the attribute is dropped` });
|
|
409
|
+
delete m.attrs[k];
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
m.attrs = normalizeAttrs(m.attrs);
|
|
413
|
+
}
|
|
414
|
+
return byId;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Build a validated graph from loose parts. Never throws. Returns
|
|
419
|
+
* `{ ok, graph, errors }`; on `ok:false` the graph is null and `errors` are
|
|
420
|
+
* typed validator errors, so a bad adapter cannot smuggle an invalid graph out.
|
|
421
|
+
*
|
|
422
|
+
* Normalisation (ids, defaults, ordering, merging of identical declarations) is
|
|
423
|
+
* the only thing done here. Conflicts are recorded as gaps and downgrade the
|
|
424
|
+
* conflicting allow edge to `unresolved`; no edge or node is invented.
|
|
425
|
+
*/
|
|
426
|
+
export function buildBoundaryGraph({ repository = null, revision = null, nodes = [], edges = [], gaps = [], sources = [] } = {}) {
|
|
427
|
+
const gapList = gaps.map(g => ({ code: g.code, subject: g.subject, file: nul(g.file), message: g.message }));
|
|
428
|
+
const nodeMap = mergeNodes(nodes, gapList);
|
|
429
|
+
|
|
430
|
+
const edgeMap = new Map();
|
|
431
|
+
for (const raw of edges) {
|
|
432
|
+
const e = normalizeEdge(raw);
|
|
433
|
+
const prev = edgeMap.get(e.id);
|
|
434
|
+
if (!prev || canonicalize(e) < canonicalize(prev)) edgeMap.set(e.id, e); // deterministic pick among true duplicates
|
|
435
|
+
}
|
|
436
|
+
let edgeList = [...edgeMap.values()];
|
|
437
|
+
|
|
438
|
+
// Conflicting allow/deny between the same endpoints and discriminator.
|
|
439
|
+
const groups = new Map();
|
|
440
|
+
for (const e of edgeList) {
|
|
441
|
+
if (e.provenance === 'runtime-observation') continue;
|
|
442
|
+
const k = canonicalize([e.relation, e.from, e.to, e.environment, e.tenant, e.discriminator, e.provenance]);
|
|
443
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
444
|
+
groups.get(k).push(e);
|
|
445
|
+
}
|
|
446
|
+
for (const group of groups.values()) {
|
|
447
|
+
const effects = new Set(group.map(e => e.effect));
|
|
448
|
+
if (effects.has('allow') && effects.has('deny')) {
|
|
449
|
+
const sample = group[0];
|
|
450
|
+
gapList.push({ code: 'conflicting-configuration', subject: sample.id, file: sample.source?.file ?? null, message: `'${sample.relation}' is both allowed and denied between the same endpoints; the allow is recorded as unresolved, the deny is kept` });
|
|
451
|
+
for (const e of group) if (e.effect === 'allow') { e.pathState = 'unresolved'; e.confidence = 'low'; }
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// An unresolved endpoint makes a static or inferred path unresolved, never possible.
|
|
455
|
+
for (const e of edgeList) {
|
|
456
|
+
if (e.provenance === 'runtime-observation') continue;
|
|
457
|
+
const f = nodeMap.get(e.from), t = nodeMap.get(e.to);
|
|
458
|
+
if ((f && !f.resolved) || (t && !t.resolved)) {
|
|
459
|
+
if (e.pathState === 'possible') e.pathState = 'unresolved';
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
edgeList = edgeList.sort(compareById);
|
|
463
|
+
|
|
464
|
+
const uniqueGaps = [...new Map(gapList.map(g => [gapKey(g), g])).values()]
|
|
465
|
+
.sort((a, b) => { const x = gapKey(a), y = gapKey(b); return x < y ? -1 : x > y ? 1 : 0; });
|
|
466
|
+
const uniqueSources = [...new Map(sources.map(s => [`${s.file}\n${s.digest}`, {
|
|
467
|
+
file: s.file, digest: s.digest, parser: s.parser, parserVersion: s.parserVersion, bytes: s.bytes ?? 0,
|
|
468
|
+
}])).values()].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.digest < b.digest ? -1 : a.digest > b.digest ? 1 : 0));
|
|
469
|
+
|
|
470
|
+
const graph = {
|
|
471
|
+
schema: BOUNDARY_GRAPH_SCHEMA,
|
|
472
|
+
schemaVersion: BOUNDARY_GRAPH_VERSION,
|
|
473
|
+
repository: nul(repository),
|
|
474
|
+
revision: nul(revision),
|
|
475
|
+
nodes: [...nodeMap.values()].sort(compareById),
|
|
476
|
+
edges: edgeList,
|
|
477
|
+
gaps: uniqueGaps,
|
|
478
|
+
sources: uniqueSources,
|
|
479
|
+
digest: '',
|
|
480
|
+
};
|
|
481
|
+
graph.digest = computeGraphDigest(graph);
|
|
482
|
+
const v = validateBoundaryGraph(graph);
|
|
483
|
+
if (!v.ok) return { ok: false, graph: null, errors: v.errors };
|
|
484
|
+
return { ok: true, graph, errors: [] };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ------------------------------------------------------------ export / import
|
|
488
|
+
|
|
489
|
+
/** Deterministic text form: canonical key order, no clock, trailing newline. */
|
|
490
|
+
export function exportBoundaryGraph(graph) {
|
|
491
|
+
return `${canonicalize(graph)}\n`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const IMPORT_MAX_BYTES = 16 * 1024 * 1024;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Parse and validate an exported graph. Returns a typed result, never throws:
|
|
498
|
+
* `{ status: 'ok', graph }`, `{ status: 'malformed' }` for text that is not
|
|
499
|
+
* JSON or is too large, `{ status: 'invalid', errors }` for a graph that parses
|
|
500
|
+
* but fails the contract (including a tampered digest or id).
|
|
501
|
+
*/
|
|
502
|
+
export function importBoundaryGraph(text, { maxBytes = IMPORT_MAX_BYTES } = {}) {
|
|
503
|
+
if (typeof text !== 'string') return { status: 'malformed', reason: 'input must be text', graph: null, errors: [] };
|
|
504
|
+
if (Buffer.byteLength(text, 'utf8') > maxBytes) return { status: 'malformed', reason: `input is over the ${maxBytes} byte limit`, graph: null, errors: [] };
|
|
505
|
+
let doc;
|
|
506
|
+
try { doc = JSON.parse(text); } catch (e) { return { status: 'malformed', reason: `not valid JSON: ${String(e.message).split('\n')[0]}`, graph: null, errors: [] }; }
|
|
507
|
+
const v = validateBoundaryGraph(doc);
|
|
508
|
+
if (!v.ok) return { status: 'invalid', reason: 'graph failed validation', graph: null, errors: v.errors };
|
|
509
|
+
return { status: 'ok', reason: null, graph: doc, errors: [] };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ------------------------------------------------------------ queries
|
|
513
|
+
|
|
514
|
+
/** Index a graph for lookups. Pure. */
|
|
515
|
+
export function indexGraph(graph) {
|
|
516
|
+
const nodesById = new Map(graph.nodes.map(n => [n.id, n]));
|
|
517
|
+
const out = new Map(), inn = new Map();
|
|
518
|
+
for (const e of graph.edges) {
|
|
519
|
+
if (!out.has(e.from)) out.set(e.from, []);
|
|
520
|
+
out.get(e.from).push(e);
|
|
521
|
+
if (!inn.has(e.to)) inn.set(e.to, []);
|
|
522
|
+
inn.get(e.to).push(e);
|
|
523
|
+
}
|
|
524
|
+
return { nodesById, out, inn };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Which trust boundaries an edge crosses, from its endpoints and relation.
|
|
529
|
+
* Environment is never crossed (the validator forbids it); tenant and zone
|
|
530
|
+
* crossings come from the endpoints; identity and repository from the relation.
|
|
531
|
+
*/
|
|
532
|
+
export function boundaryCrossings(edge, nodesById) {
|
|
533
|
+
const from = nodesById.get(edge.from), to = nodesById.get(edge.to);
|
|
534
|
+
const out = [];
|
|
535
|
+
if (!from || !to) return out;
|
|
536
|
+
const sem = RELATION_SEMANTICS[edge.relation];
|
|
537
|
+
if (!sem || sem.structural) return out;
|
|
538
|
+
if (sem.boundary === 'identity') out.push({ type: 'identity', detail: `${from.name} -> ${to.name}` });
|
|
539
|
+
if (from.trustZone !== to.trustZone && (from.trustZone !== 'unknown' || to.trustZone !== 'unknown')) {
|
|
540
|
+
out.push({ type: 'network', detail: `${from.trustZone} -> ${to.trustZone}` });
|
|
541
|
+
}
|
|
542
|
+
if (from.tenant !== null && to.tenant !== null && from.tenant !== to.tenant) out.push({ type: 'tenant', detail: `${from.tenant} -> ${to.tenant}` });
|
|
543
|
+
if (edge.crossRepo) out.push({ type: 'repository', detail: `${edge.crossRepo.callerRepository} -> ${edge.crossRepo.calleeRepository}` });
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Shortest path (fewest edges) from one node to another over the edges `accept`
|
|
549
|
+
* allows, as the list of edges, or null. Breadth first with a visited set, so a
|
|
550
|
+
* cycle cannot loop it, and a hop limit, so a large graph cannot make it
|
|
551
|
+
* unbounded. `out` is the `out` map of `indexGraph`.
|
|
552
|
+
*/
|
|
553
|
+
export function findPath(out, fromId, toId, { accept = () => true, maxHops = 8 } = {}) {
|
|
554
|
+
const queue = [{ id: fromId, path: [] }];
|
|
555
|
+
const seen = new Set([fromId]);
|
|
556
|
+
while (queue.length) {
|
|
557
|
+
const { id, path } = queue.shift();
|
|
558
|
+
if (id === toId && path.length) return path;
|
|
559
|
+
if (path.length >= maxHops) continue;
|
|
560
|
+
for (const e of out.get(id) ?? []) {
|
|
561
|
+
if (seen.has(e.to) || !accept(e)) continue;
|
|
562
|
+
seen.add(e.to);
|
|
563
|
+
queue.push({ id: e.to, path: [...path, e] });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return null;
|
|
567
|
+
}
|