@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.1

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.
Files changed (153) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +1 -1
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +30 -0
  44. package/src/dataflow/catalog.js +42 -1
  45. package/src/dataflow/engine.js +67 -2
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -0,0 +1,221 @@
1
+ // ingest.js: turn customer-supplied deployment files into a boundary graph
2
+ // (X-302), and invalidate it when a source file changes.
3
+ //
4
+ // What this module will and will not do:
5
+ // - It reads only files the caller names, under one root, as bounded UTF-8
6
+ // text. It follows no symlink out of the root, opens no socket, spawns no
7
+ // process, reads no credential and calls no cloud API. The pure entry point
8
+ // (`ingestDeploymentConfig`) takes text and cannot touch the file system at
9
+ // all; the file entry point (`ingestDeploymentFiles`) is gated by the
10
+ // `deployment-boundaries` feature so it is inert unless an operator turns
11
+ // it on.
12
+ // - It does not execute configuration. Terraform HCL, nginx style
13
+ // configuration and other languages are reported as typed unsupported
14
+ // syntax, with a hint where a supported equivalent exists, and contribute
15
+ // no edges.
16
+ // - An adapter that throws on hostile input is contained: the file becomes a
17
+ // typed gap and the other files are still ingested.
18
+ //
19
+ // Every edge an adapter emits carries the file, the sha256 of the exact text
20
+ // parsed, the parser name and the parser version. `invalidateChangedSources`
21
+ // uses those digests to drop what a changed or removed file used to support,
22
+ // so a stale relation does not outlive its evidence.
23
+
24
+ import * as fs from 'node:fs';
25
+ import * as path from 'node:path';
26
+ import { digestOfBytes } from '../../posture/assurance/identity.js';
27
+ import { featureStatus, limitValues, typed } from '../../posture/assurance/config.js';
28
+ import { readFileBounded } from '../../posture/assurance/bounded-io.js';
29
+ import { buildBoundaryGraph } from './boundary-graph.js';
30
+ import { parseKubernetes, KUBERNETES_PARSER, KUBERNETES_PARSER_VERSION } from './adapter-kubernetes.js';
31
+ import { parseCompose, COMPOSE_PARSER, COMPOSE_PARSER_VERSION } from './adapter-compose.js';
32
+ import { parseTerraformPlan, parseIamPolicy, TERRAFORM_PARSER, TERRAFORM_PARSER_VERSION, IAM_PARSER, IAM_PARSER_VERSION } from './adapter-cloud.js';
33
+
34
+ export const DEPLOYMENT_FEATURE = 'deployment-boundaries';
35
+ export const MAX_INGEST_FILES = 200;
36
+
37
+ const ADAPTERS = Object.freeze({
38
+ kubernetes: { parse: parseKubernetes, parser: KUBERNETES_PARSER, version: KUBERNETES_PARSER_VERSION },
39
+ compose: { parse: parseCompose, parser: COMPOSE_PARSER, version: COMPOSE_PARSER_VERSION },
40
+ 'terraform-plan': { parse: parseTerraformPlan, parser: TERRAFORM_PARSER, version: TERRAFORM_PARSER_VERSION },
41
+ 'iam-policy': { parse: parseIamPolicy, parser: IAM_PARSER, version: IAM_PARSER_VERSION },
42
+ });
43
+ export const ADAPTER_NAMES = Object.freeze(Object.keys(ADAPTERS));
44
+
45
+ const UNSUPPORTED_SYNTAX = {
46
+ '.tf': 'Terraform HCL is not interpreted (it would need evaluation); supply the JSON from `terraform show -json <plan>`',
47
+ '.hcl': 'HCL is not interpreted; supply a supported JSON or YAML form',
48
+ '.tfvars': 'Terraform variable files carry values, not topology, and are not read',
49
+ '.conf': 'proxy and server configuration syntax is not supported by any adapter',
50
+ '.toml': 'TOML configuration is not supported by any adapter',
51
+ '.xml': 'XML configuration is not supported by any adapter',
52
+ };
53
+
54
+ /** Decide which adapter reads a file, or why none does. Pure. */
55
+ export function detectFormat(file, text) {
56
+ const ext = path.extname(file).toLowerCase();
57
+ if (UNSUPPORTED_SYNTAX[ext]) return { adapter: null, code: 'unsupported-syntax', reason: UNSUPPORTED_SYNTAX[ext] };
58
+ if (ext === '.json') {
59
+ let doc;
60
+ try { doc = JSON.parse(text); } catch (e) { return { adapter: null, code: 'malformed-input', reason: `not valid JSON (${String(e.message).split('\n')[0]})` }; }
61
+ if (doc && typeof doc === 'object' && !Array.isArray(doc)) {
62
+ if (typeof doc.format_version === 'string' && Array.isArray(doc.resource_changes)) return { adapter: 'terraform-plan' };
63
+ if (Object.hasOwn(doc, 'Statement')) return { adapter: 'iam-policy' };
64
+ }
65
+ return { adapter: null, code: 'unsupported-format', reason: 'JSON that is neither a terraform plan nor an IAM policy document' };
66
+ }
67
+ if (ext === '.yaml' || ext === '.yml') {
68
+ if (/^services:\s*(#.*)?$/m.test(text)) return { adapter: 'compose' };
69
+ if (/^apiVersion:\s*\S/m.test(text) && /^kind:\s*\S/m.test(text)) return { adapter: 'kubernetes' };
70
+ return { adapter: null, code: 'unsupported-format', reason: 'YAML that is neither a compose file nor a Kubernetes manifest' };
71
+ }
72
+ return { adapter: null, code: 'unsupported-format', reason: `no adapter reads '${ext || 'files without an extension'}'` };
73
+ }
74
+
75
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
76
+ const REV = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
77
+
78
+ /**
79
+ * Ingest already-read files. Pure: no file system, no network, no clock.
80
+ *
81
+ * @param {object} p
82
+ * @param {Array<{path: string, text: string}>} p.files
83
+ * @param {string} p.environment the deployment environment these files describe
84
+ * @param {string|null} [p.repository]
85
+ * @param {string|null} [p.revision] exact commit these files were read at, when known
86
+ * @param {Record<string,string>} [p.identities] file path -> identity a policy document belongs to
87
+ * @returns {{status: 'ok'|'invalid-input'|'invalid-graph', graph: object|null, files: object[], errors: object[]}}
88
+ */
89
+ export function ingestDeploymentConfig({ files, environment, repository = null, revision = null, identities = {} } = {}) {
90
+ if (typeof environment !== 'string' || !NAME.test(environment)) return { status: 'invalid-input', graph: null, files: [], errors: [{ code: 'BAD_TYPE', path: 'environment', message: 'environment must be a name such as prod or staging' }] };
91
+ if (repository !== null && (typeof repository !== 'string' || !NAME.test(repository))) return { status: 'invalid-input', graph: null, files: [], errors: [{ code: 'BAD_TYPE', path: 'repository', message: 'repository must be null or a name' }] };
92
+ if (revision !== null && !(typeof revision === 'string' && REV.test(revision))) return { status: 'invalid-input', graph: null, files: [], errors: [{ code: 'BAD_TYPE', path: 'revision', message: 'revision must be null or an exact 40/64 hex commit' }] };
93
+ if (!Array.isArray(files)) return { status: 'invalid-input', graph: null, files: [], errors: [{ code: 'BAD_TYPE', path: 'files', message: 'files must be an array' }] };
94
+
95
+ const nodes = [], edges = [], gaps = [], sources = [], report = [];
96
+ const sorted = [...files].filter(f => f && typeof f.path === 'string' && typeof f.text === 'string').sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
97
+ if (files.length > MAX_INGEST_FILES) gaps.push({ code: 'limit-exceeded', subject: 'files', file: null, message: `${files.length} files supplied; only the first ${MAX_INGEST_FILES} are read` });
98
+ for (const f of sorted.slice(0, MAX_INGEST_FILES)) {
99
+ const bytes = Buffer.from(f.text, 'utf8');
100
+ const digest = digestOfBytes(bytes);
101
+ const fmt = detectFormat(f.path, f.text);
102
+ if (!fmt.adapter) {
103
+ gaps.push({ code: fmt.code, subject: f.path, file: f.path, message: fmt.reason });
104
+ report.push({ file: f.path, adapter: null, status: fmt.code });
105
+ continue;
106
+ }
107
+ const a = ADAPTERS[fmt.adapter];
108
+ let out;
109
+ try {
110
+ out = a.parse(f.text, { file: f.path, digest, environment, repository, revision, identity: identities[f.path] ?? null });
111
+ } catch (e) {
112
+ gaps.push({ code: 'malformed-input', subject: f.path, file: f.path, message: `the ${fmt.adapter} adapter could not read this file (${String(e?.message ?? e).split('\n')[0]})` });
113
+ report.push({ file: f.path, adapter: fmt.adapter, status: 'malformed-input' });
114
+ continue;
115
+ }
116
+ nodes.push(...out.nodes); edges.push(...out.edges); gaps.push(...out.gaps);
117
+ sources.push({ file: f.path, digest, parser: a.parser, parserVersion: a.version, bytes: bytes.length });
118
+ report.push({ file: f.path, adapter: fmt.adapter, status: out.nodes.length || out.edges.length ? 'ingested' : (out.gaps[0]?.code ?? 'empty') });
119
+ }
120
+ const built = buildBoundaryGraph({ repository, revision, nodes, edges, gaps, sources });
121
+ if (!built.ok) return { status: 'invalid-graph', graph: null, files: report, errors: built.errors };
122
+ return { status: 'ok', graph: built.graph, files: report, errors: [] };
123
+ }
124
+
125
+ // ------------------------------------------------------------ file entry point
126
+
127
+ /** Resolve `rel` under `root`, refusing absolute paths, traversal and symlinks. Returns the real path or null. */
128
+ export function resolveUnderRoot(root, rel) {
129
+ if (typeof rel !== 'string' || rel === '' || path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return null;
130
+ let realRoot;
131
+ try { realRoot = fs.realpathSync(root); } catch { return null; }
132
+ const full = path.join(realRoot, rel);
133
+ let st;
134
+ try { st = fs.lstatSync(full); } catch { return null; }
135
+ if (st.isSymbolicLink() || !st.isFile()) return null;
136
+ let real;
137
+ try { real = fs.realpathSync(full); } catch { return null; }
138
+ return real === full && (real === realRoot || real.startsWith(realRoot + path.sep)) ? real : null;
139
+ }
140
+
141
+ /**
142
+ * Read named files under `root` and ingest them. Gated by the
143
+ * `deployment-boundaries` feature (off by default; the environment, an explicit
144
+ * option or the operator's config turn it on). Returns a typed result and never
145
+ * throws.
146
+ *
147
+ * @param {object} p
148
+ * @param {object} p.config resolveAssuranceConfig() result
149
+ * @param {string} p.root
150
+ * @param {string[]} p.paths relative paths under root
151
+ */
152
+ export function ingestDeploymentFiles({ config, root, paths, environment, repository = null, revision = null, identities = {} } = {}) {
153
+ const gate = featureStatus(config, DEPLOYMENT_FEATURE);
154
+ if (gate.status !== 'ok') return gate;
155
+ if (!Array.isArray(paths)) return typed('blocked', 'invalid-config', 'paths must be an array', { feature: DEPLOYMENT_FEATURE });
156
+ const maxBytes = limitValues(config).maxFileBytes;
157
+ const files = [];
158
+ const readGaps = [];
159
+ for (const rel of paths.slice(0, MAX_INGEST_FILES)) {
160
+ const real = resolveUnderRoot(root, rel);
161
+ if (!real) { readGaps.push({ code: 'malformed-input', subject: String(rel), file: null, message: 'path is outside the root, is a symlink, or is not a regular file; it is not read' }); continue; }
162
+ const r = readFileBounded(real, maxBytes);
163
+ if (r.status !== 'ok') { readGaps.push({ code: r.code === 'limit-exceeded' ? 'limit-exceeded' : 'malformed-input', subject: String(rel), file: String(rel), message: r.reason }); continue; }
164
+ files.push({ path: rel.split(path.sep).join('/'), text: r.text });
165
+ }
166
+ const out = ingestDeploymentConfig({ files, environment, repository, revision, identities });
167
+ if (out.status !== 'ok') return typed('blocked', 'invalid-config', out.errors.map(e => e.message).join('; ') || 'invalid ingest input', { feature: DEPLOYMENT_FEATURE, ...out });
168
+ if (readGaps.length) {
169
+ // Re-build with the read gaps included so the graph tells the whole story.
170
+ const again = buildBoundaryGraph({ ...out.graph, repository, revision, gaps: [...out.graph.gaps, ...readGaps] });
171
+ if (again.ok) out.graph = again.graph;
172
+ }
173
+ return typed('ok', null, 'ingested', { feature: DEPLOYMENT_FEATURE, ...out });
174
+ }
175
+
176
+ // ------------------------------------------------------------ invalidation
177
+
178
+ /**
179
+ * Drop what changed or removed source files used to support (X-302.AC03).
180
+ *
181
+ * `currentDigests` maps file -> the sha256 of the file as it is NOW (a missing
182
+ * entry means the file is gone). Static edges sourced from a file whose digest
183
+ * differs are removed; a node that only a stale file declared stops being
184
+ * resolved; nodes nothing references any more are dropped; a gap records each
185
+ * invalidated file. Runtime and inferred edges are untouched because they do
186
+ * not claim a file as evidence. Returns a rebuilt, validated graph.
187
+ */
188
+ export function invalidateChangedSources(graph, currentDigests) {
189
+ const cur = currentDigests instanceof Map ? currentDigests : new Map(Object.entries(currentDigests ?? {}));
190
+ const stale = new Map(); // file -> 'changed' | 'missing'
191
+ for (const s of graph.sources) {
192
+ const now = cur.get(s.file);
193
+ if (now === undefined) stale.set(s.file, 'missing');
194
+ else if (now !== s.digest) stale.set(s.file, 'changed');
195
+ }
196
+ if (stale.size === 0) return { ok: true, graph, invalidatedEdgeIds: [], staleFiles: [], errors: [] };
197
+
198
+ const isStale = (ref) => ref && stale.has(ref.file) && cur.get(ref.file) !== ref.digest;
199
+ const keptEdges = [], invalidated = [];
200
+ for (const e of graph.edges) {
201
+ if (e.source && isStale(e.source)) invalidated.push(e.id); else keptEdges.push(e);
202
+ }
203
+ const referenced = new Set();
204
+ for (const e of keptEdges) { referenced.add(e.from); referenced.add(e.to); }
205
+ const nodes = [];
206
+ for (const n of graph.nodes) {
207
+ const declaredBy = n.declaredBy.filter(d => !isStale(d));
208
+ const lostDeclaration = declaredBy.length < n.declaredBy.length;
209
+ if (declaredBy.length === 0 && !referenced.has(n.id)) continue; // nothing supports it any more
210
+ if (lostDeclaration && declaredBy.length === 0) nodes.push({ ...n, declaredBy, resolved: false, unresolvedReason: 'the file that declared it changed or was removed', repository: null });
211
+ else nodes.push({ ...n, declaredBy });
212
+ }
213
+ const gaps = [...graph.gaps];
214
+ for (const [file, why] of [...stale].sort()) {
215
+ gaps.push({ code: why === 'missing' ? 'source-missing' : 'source-changed', subject: file, file, message: `${file} ${why === 'missing' ? 'is no longer present' : 'has changed since it was ingested'}; the relations it supported were dropped and must be re-ingested` });
216
+ }
217
+ const sources = graph.sources.filter(s => !stale.has(s.file));
218
+ const built = buildBoundaryGraph({ repository: graph.repository, revision: graph.revision, nodes, edges: keptEdges, gaps, sources });
219
+ if (!built.ok) return { ok: false, graph: null, invalidatedEdgeIds: invalidated, staleFiles: [...stale.keys()].sort(), errors: built.errors };
220
+ return { ok: true, graph: built.graph, invalidatedEdgeIds: invalidated.sort(), staleFiles: [...stale.keys()].sort(), errors: [] };
221
+ }
@@ -0,0 +1,244 @@
1
+ // projection.js: the one place a boundary context becomes something a person or another tool reads (X-307).
2
+ //
3
+ // It follows the verification projection (`posture/verification/projection.js`): the JSON report, the Markdown and terminal
4
+ // reports and the MCP finding explanation all call this module, so none of them formats its own boundary text and the
5
+ // surfaces cannot disagree. Rules it keeps:
6
+ // - It is a VIEW. It reads a context produced by `analyzeFindingBoundaries` and invents nothing; a context that fails
7
+ // validation yields `{ ok: false, errors }`, never a guessed state.
8
+ // - Closed world. A context with a field this module does not know, or a string shaped like a credential or carrying control
9
+ // characters, is refused rather than shown. Imported secrets and raw trace payloads cannot reach a report through here.
10
+ // - Ids are shared. The view carries the context id, the hypothesis id, path ids, edge ids and verification record ids
11
+ // unchanged, so a boundary record can be joined to a verification record by id.
12
+ // - Filtering never hides the caveats. A filter narrows the paths shown; the scope, the uncovered boundaries and every
13
+ // warning stay, and a warning says how many paths the filter hid.
14
+ // - The text states what was established and what was not. It never calls a partial result safe or protected, and it does
15
+ // not call a finding unreachable: no path found is "not shown to exist".
16
+ // - Deterministic: no clock, no randomness, fixed key order.
17
+ //
18
+ // The presentation is additive and off by default. `attachBoundaryContext` is the only producer and it is gated by the
19
+ // `deployment-boundaries` feature; with the feature off no finding carries a context, so no output changes.
20
+
21
+ import { featureStatus } from '../../posture/assurance/config.js';
22
+ import { analyzeFindingBoundaries, BOUNDARY_CONTEXT_SCHEMA } from './attack-paths.js';
23
+
24
+ export const VIEW_SCHEMA = 'agentic-security/boundary-view';
25
+ export const VIEW_VERSION = '1.0.0';
26
+ export const COVERAGE_SCHEMA = 'agentic-security/boundary-coverage';
27
+ export const BOUNDARY_FEATURE = 'deployment-boundaries';
28
+
29
+ const CONTEXT_KEYS = ['schema', 'schemaVersion', 'id', 'hypothesisId', 'finding', 'binding', 'graph', 'exposure', 'paths', 'truncated', 'verification', 'uncovered', 'warnings'];
30
+ const FILTER_KEYS = ['service', 'repository', 'environment', 'tenant'];
31
+ const MAX_STRING = 600;
32
+
33
+ const STATE_TEXT = Object.freeze({
34
+ possible: 'POSSIBLE (configured; not exercised)',
35
+ blocked: 'BLOCKED (an explicit deny in configuration; the deny was not tested)',
36
+ unresolved: 'UNRESOLVED (depends on something the configuration does not establish)',
37
+ 'runtime-supported': 'OBSERVED (seen in the supplied traces; traffic coverage is not established)',
38
+ });
39
+ const EXPOSURE_TEXT = Object.freeze({
40
+ 'runtime-supported': 'OBSERVED', possible: 'POSSIBLE', unresolved: 'UNRESOLVED', blocked: 'BLOCKED', 'none-found': 'NO PATH FOUND', 'not-assessed': 'NOT ASSESSED',
41
+ });
42
+ const EXPLOIT_TEXT = Object.freeze({
43
+ 'not-established': 'not established',
44
+ 'exploitable-confirmed': 'confirmed by a trusted oracle in its declared scenario',
45
+ 'oracle-confirmed-path-blocked': 'oracle-confirmed effect, but the path is blocked by configuration',
46
+ 'oracle-confirmed-path-unresolved': 'oracle-confirmed effect, but deployment reachability is unresolved',
47
+ });
48
+
49
+ // Credential and payload shapes that must never appear in a context.
50
+ const SENSITIVE = [
51
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
52
+ /\bAKIA[0-9A-Z]{16}\b/,
53
+ /\b(?:password|passwd|secret|api[_-]?key|token|authorization)\s*[:=]\s*\S+/i,
54
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/i,
55
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./,
56
+ ];
57
+ // eslint-disable-next-line no-control-regex
58
+ const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
59
+
60
+ function scanStrings(value, path, errors, depth = 0) {
61
+ if (depth > 12) { errors.push({ code: 'RULE_VIOLATION', path, message: 'context nests deeper than the contract allows' }); return; }
62
+ if (typeof value === 'string') {
63
+ if (value.length > MAX_STRING) errors.push({ code: 'RULE_VIOLATION', path, message: `a string is over ${MAX_STRING} characters` });
64
+ else if (CONTROL.test(value)) errors.push({ code: 'RULE_VIOLATION', path, message: 'a string carries control characters' });
65
+ else if (SENSITIVE.some((re) => re.test(value))) errors.push({ code: 'RULE_VIOLATION', path, message: 'a string is shaped like a credential or a raw payload and is refused' });
66
+ } else if (Array.isArray(value)) value.forEach((v, i) => scanStrings(v, `${path}[${i}]`, errors, depth + 1));
67
+ else if (value && typeof value === 'object') for (const k of Object.keys(value)) scanStrings(value[k], `${path}.${k}`, errors, depth + 1);
68
+ }
69
+
70
+ /** Closed-world check of a context. Never throws. */
71
+ export function validateBoundaryContext(ctx) {
72
+ const errors = [];
73
+ if (!ctx || typeof ctx !== 'object' || Array.isArray(ctx)) return { ok: false, errors: [{ code: 'BAD_TYPE', path: '', message: 'a boundary context must be an object' }] };
74
+ if (ctx.schema !== BOUNDARY_CONTEXT_SCHEMA) errors.push({ code: 'BAD_SCHEMA', path: 'schema', message: `schema must be '${BOUNDARY_CONTEXT_SCHEMA}'` });
75
+ for (const k of Object.keys(ctx)) if (!CONTEXT_KEYS.includes(k)) errors.push({ code: 'UNKNOWN_FIELD', path: k, message: `field '${k}' is not part of a boundary context` });
76
+ for (const k of CONTEXT_KEYS) if (!(k in ctx)) errors.push({ code: 'MISSING_FIELD', path: k, message: `field '${k}' is required` });
77
+ if (errors.length) return { ok: false, errors };
78
+ if (typeof ctx.id !== 'string' || !ctx.id) errors.push({ code: 'BAD_TYPE', path: 'id', message: 'id must be a non-empty string' });
79
+ if (!Array.isArray(ctx.paths)) errors.push({ code: 'BAD_TYPE', path: 'paths', message: 'paths must be an array' });
80
+ if (!Array.isArray(ctx.warnings) || !Array.isArray(ctx.uncovered) || !Array.isArray(ctx.verification)) errors.push({ code: 'BAD_TYPE', path: '', message: 'warnings, uncovered and verification must be arrays' });
81
+ if (errors.length) return { ok: false, errors };
82
+ scanStrings(ctx, '', errors);
83
+ return { ok: errors.length === 0, errors };
84
+ }
85
+
86
+ // ------------------------------------------------------------ view
87
+
88
+ function pathView(p) {
89
+ const services = [...new Set(p.hops.flatMap((h) => [h.from, h.to]).concat(p.hops.length ? [] : [p.entry]).filter((n) => n.kind === 'service').map((n) => n.name))].sort();
90
+ const repositories = [...new Set(p.hops.flatMap((h) => [h.from.repository, h.to.repository]).concat([p.entry.repository, p.target.repository]).filter(Boolean))].sort();
91
+ const unobserved = new Set(p.coverage.unobservedHops);
92
+ const uncovered = p.crossings.filter((c) => c.type === 'identity' || unobserved.has(c.hop)).map((c) => ({ type: c.type, detail: c.detail, hop: c.hop }));
93
+ return {
94
+ id: p.id, kind: p.kind, state: p.state, conditional: p.conditional,
95
+ environments: p.environments, tenants: p.tenants, services, repositories, provenance: p.provenance,
96
+ entry: p.entry, target: p.target,
97
+ hops: p.hops.map((h) => ({
98
+ index: h.index, from: h.from, to: h.to, relations: h.relations, state: h.state,
99
+ edges: h.edges.map((e) => ({
100
+ id: e.id, relation: e.relation, provenance: e.provenance, effect: e.effect, confidence: e.confidence, pathState: e.pathState,
101
+ sourceRevision: e.sourceRevision, source: e.source, observationInterval: e.observationInterval, completeness: e.completeness,
102
+ })),
103
+ })),
104
+ supportingEdgeIds: p.supportingEdgeIds, conditions: p.conditions, blockers: p.blockers, assumptions: p.assumptions,
105
+ crossings: p.crossings, uncoveredBoundaries: uncovered,
106
+ freshness: {
107
+ observedFrom: p.coverage.observedFrom, observedUntil: p.coverage.observedUntil, staleHops: p.coverage.staleHops,
108
+ sampledHops: p.coverage.sampledHops, staleEvaluated: p.coverage.staleEvaluated,
109
+ statement: p.coverage.observedHops.length
110
+ ? `${p.coverage.observedHops.length} network hop(s) were observed${p.coverage.observedUntil ? ` up to ${p.coverage.observedUntil}` : ''}${p.coverage.staleEvaluated ? '' : '; staleness was not evaluated (no clock supplied)'}; the rest are configuration only`
111
+ : 'no network hop was observed; this path is configuration only',
112
+ },
113
+ coverage: p.coverage, exploitability: p.exploitability,
114
+ };
115
+ }
116
+
117
+ function textLines(view) {
118
+ const L = [];
119
+ L.push(`Deployment boundaries: exposure ${EXPOSURE_TEXT[view.exposure.state] ?? view.exposure.state}. ${view.exposure.statement}`);
120
+ L.push(view.binding.service
121
+ ? ` Service: ${view.binding.service.name} (environment ${view.binding.service.environment ?? 'none'}, tenant ${view.binding.service.tenant ?? 'none'})`
122
+ : ` Service: not bound (${view.binding.reason})`);
123
+ L.push(` Graph: repository ${view.graph.repository ?? 'unknown'}, revision ${view.graph.revision ? view.graph.revision.slice(0, 12) : 'unknown'}, digest ${view.graph.digest.slice(0, 12)}`);
124
+ const f = view.filter;
125
+ const filterText = f ? ` (filter ${FILTER_KEYS.filter((k) => f[k]).map((k) => `${k}=${f[k]}`).join(', ') || 'none'}; ${view.scope.hiddenPathCount} hidden)` : '';
126
+ L.push(` Paths: ${view.paths.length} shown of ${view.scope.totalPathCount}${filterText}`);
127
+ for (const p of view.paths) {
128
+ const chain = p.hops.length ? [p.hops[0].from.name, ...p.hops.map((h) => h.to.name)].join(' -> ') : p.entry.name;
129
+ L.push(` ${p.id} [${p.kind}] ${STATE_TEXT[p.state]}: ${chain}`);
130
+ L.push(` environment: ${p.environments.join(', ') || 'none'}; tenants: ${p.tenants.join(', ') || 'none'}; provenance: ${p.provenance.join(', ') || 'none'}`);
131
+ const files = [...new Set(p.hops.flatMap((h) => h.edges.map((e) => (e.source ? `${e.source.file}@${e.source.digest.slice(0, 8)}` : null))).filter(Boolean))].sort();
132
+ L.push(` supporting edges: ${p.supportingEdgeIds.length}${files.length ? ` from ${files.join(', ')}` : ''}`);
133
+ L.push(` freshness: ${p.freshness.statement}`);
134
+ for (const c of p.conditions) L.push(` condition: ${c.detail}`);
135
+ for (const b of p.blockers) L.push(` blocker: ${b.detail}`);
136
+ if (p.uncoveredBoundaries.length) L.push(` not covered by observation: ${p.uncoveredBoundaries.map((u) => `${u.type} (${u.detail})`).join('; ')}`);
137
+ L.push(` exploitability: ${EXPLOIT_TEXT[p.exploitability.label] ?? p.exploitability.label}. ${p.exploitability.reason}`);
138
+ }
139
+ if (view.verification.length) L.push(` Verification records: ${view.verification.map((v) => `${v.recordId} (${v.applicable ? 'applicable' : 'not applicable'})`).join(', ')}`);
140
+ if (view.uncovered.length) {
141
+ L.push(' Not established:');
142
+ for (const u of view.uncovered) L.push(` ${u.type}: ${u.detail}`);
143
+ }
144
+ L.push(' Caveats:');
145
+ for (const w of view.warnings) L.push(` ${w}`);
146
+ return L;
147
+ }
148
+
149
+ /**
150
+ * Project a boundary context.
151
+ * @returns {{ ok: boolean, view: object|null, errors: object[] }} never throws
152
+ */
153
+ export function projectBoundaryContext(ctx) {
154
+ try {
155
+ const v = validateBoundaryContext(ctx);
156
+ if (!v.ok) return { ok: false, view: null, errors: v.errors };
157
+ const paths = ctx.paths.map(pathView);
158
+ const view = {
159
+ schema: VIEW_SCHEMA, schemaVersion: VIEW_VERSION,
160
+ contextId: ctx.id, hypothesisId: ctx.hypothesisId, finding: ctx.finding, binding: ctx.binding,
161
+ graph: ctx.graph, exposure: ctx.exposure, paths,
162
+ verification: ctx.verification.map((r) => ({ recordId: r.recordId, outcome: r.outcome, applicable: r.applicable })),
163
+ verificationRecordIds: ctx.verification.map((r) => r.recordId).filter(Boolean),
164
+ uncovered: ctx.uncovered,
165
+ warnings: [...ctx.warnings],
166
+ scope: {
167
+ totalPathCount: paths.length, hiddenPathCount: 0, truncated: ctx.truncated,
168
+ repositories: ctx.graph.repositories, environments: ctx.graph.environments, tenants: ctx.graph.tenants,
169
+ services: [...new Set(paths.flatMap((p) => p.services))].sort(),
170
+ },
171
+ filter: null,
172
+ };
173
+ view.text = textLines(view);
174
+ return { ok: true, view, errors: [] };
175
+ } catch (e) {
176
+ return { ok: false, view: null, errors: [{ code: 'RULE_VIOLATION', path: '', message: `boundary projection failed: ${String(e?.message || e).slice(0, 160)}` }] };
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Narrow a view by service, repository, environment and tenant (all given criteria must match). The scope, the uncovered
182
+ * boundaries and every warning are kept, and a warning says how many paths were hidden. A filter that matches nothing says so
183
+ * and does not claim that nothing exists.
184
+ */
185
+ export function filterBoundaryView(view, filter = {}) {
186
+ const f = {};
187
+ for (const k of FILTER_KEYS) f[k] = typeof filter[k] === 'string' && filter[k] ? filter[k] : null;
188
+ const match = (p) => (!f.service || p.services.includes(f.service))
189
+ && (!f.repository || p.repositories.includes(f.repository))
190
+ && (!f.environment || p.environments.includes(f.environment))
191
+ && (!f.tenant || p.tenants.includes(f.tenant));
192
+ const total = view.scope.totalPathCount;
193
+ const shown = view.paths.filter(match);
194
+ const hidden = total - shown.length;
195
+ const warnings = [...view.warnings.filter((w) => !w.startsWith('Filter: '))];
196
+ if (hidden > 0) warnings.push(`Filter: ${hidden} of ${total} path(s) are hidden by the filter; hidden paths are not shown to be absent.`);
197
+ if (!shown.length && total > 0) warnings.push('Filter: no path matches the filter; that says nothing about paths outside it.');
198
+ const out = { ...view, paths: shown, warnings, scope: { ...view.scope, hiddenPathCount: hidden }, filter: f };
199
+ out.text = textLines(out);
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * The additive report fields for a finding that carries a context. Mirrors `verificationFields`, with one difference: the
205
+ * context itself is returned only when it validated, so a refused context (a credential-shaped string, a made-up field) is not
206
+ * echoed into a report by the very surface that refused to show it.
207
+ */
208
+ export function boundaryFields(ctx, o = {}) {
209
+ if (!ctx || typeof ctx !== 'object') return {};
210
+ const p = projectBoundaryContext(ctx);
211
+ if (!p.ok) return { boundaryView: null, boundaryViewErrors: p.errors };
212
+ return { boundaryContext: ctx, boundaryView: o.filter ? filterBoundaryView(p.view, o.filter) : p.view };
213
+ }
214
+
215
+ /** Run-level accounting for the findings that carry a context. */
216
+ export function boundaryCoverage(contexts) {
217
+ const list = (Array.isArray(contexts) ? contexts : []).filter((c) => c && typeof c === 'object');
218
+ const valid = list.filter((c) => validateBoundaryContext(c).ok);
219
+ const bound = valid.filter((c) => c.binding.status === 'bound').length;
220
+ const byExposure = {};
221
+ for (const c of valid) byExposure[c.exposure.state] = (byExposure[c.exposure.state] || 0) + 1;
222
+ const invalid = list.length - valid.length;
223
+ const why = Object.entries(byExposure).sort(([a], [b]) => a.localeCompare(b)).map(([k, n]) => `${k}: ${n}`).join(', ');
224
+ const line = `Deployment boundaries: ${list.length} finding(s) with context; ${bound} bound to a service, ${valid.length - bound} not bound${invalid ? `, ${invalid} invalid` : ''}${why ? ` (${why})` : ''}; traffic coverage is not established`;
225
+ return { schema: COVERAGE_SCHEMA, schemaVersion: VIEW_VERSION, total: list.length, bound, unbound: valid.length - bound, invalid, byExposure, lines: [line] };
226
+ }
227
+
228
+ /**
229
+ * Attach a boundary context to each finding. This is the only producer and it is feature gated: with the feature off the
230
+ * same array is returned untouched, so scan and report output do not change.
231
+ *
232
+ * @returns {{ status: string, code?: string, reason?: string, findings: object[], errors: object[] }}
233
+ */
234
+ export function attachBoundaryContext({ config, findings, graph, bindings = [], records = [], now } = {}) {
235
+ const gate = featureStatus(config, BOUNDARY_FEATURE);
236
+ if (gate.status !== 'ok') return { ...gate, findings, errors: [] };
237
+ const out = [], errors = [];
238
+ for (const f of Array.isArray(findings) ? findings : []) {
239
+ const r = analyzeFindingBoundaries({ graph, finding: f, bindings, records, now });
240
+ if (!r.ok) { errors.push(...r.errors); out.push(f); continue; }
241
+ out.push({ ...f, boundaryContext: r.context });
242
+ }
243
+ return { status: 'ok', code: null, reason: gate.reason, findings: out, errors };
244
+ }