@clear-capabilities/agentic-security-scanner 0.162.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 +26 -0
- package/bin/agentic-security.js +87 -0
- 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 +1 -1
- 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 +1 -1
- package/dist/9560.index.js +2318 -23
- package/dist/957.index.js +1 -1
- package/dist/9999.index.js +43 -7
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -6
- 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 +9 -0
- package/src/discovery/index.js +15 -1
- package/src/discovery/lenses.js +18 -0
- package/src/engine.js +80 -15
- 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 +5 -2
- 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 +13 -1
- package/src/posture/CLAUDE.md +332 -1
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- 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/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 +172 -1
- package/src/posture/oracles/conformance.js +3 -2
- package/src/posture/oracles/oracle.js +23 -3
- package/src/posture/oracles/registry.js +1 -1
- 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 +1 -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/patch-negative.js +16 -4
- package/src/posture/verification/patch-promotion.js +10 -2
- package/src/report/index.js +51 -1
- package/src/sandbox/CLAUDE.md +10 -0
- package/src/sandbox/backend-userspace.js +91 -13
- package/dist/5613.index.js +0 -500
- package/dist/6239.index.js +0 -469
|
@@ -14,6 +14,7 @@ import * as fs from 'node:fs';
|
|
|
14
14
|
import * as path from 'node:path';
|
|
15
15
|
import * as yaml from '../util/yaml.js';
|
|
16
16
|
import { statePath } from '../posture/state-dir.js';
|
|
17
|
+
import { portfolioAssuranceEnabled, HEADLINE, BLOCKED_HEADLINE } from '../posture/portfolio/wording.js';
|
|
17
18
|
|
|
18
19
|
function _configPath(scanRoot) {
|
|
19
20
|
return statePath(scanRoot, 'integrations.yml');
|
|
@@ -36,12 +37,14 @@ export function buildSlackDigest(findings, summary, options = {}) {
|
|
|
36
37
|
const project = options.project || 'project';
|
|
37
38
|
const status = (summary.critical || 0) === 0 && (summary.high || 0) === 0
|
|
38
39
|
? '✅ safe to deploy' : '❌ not safe to deploy';
|
|
40
|
+
const statusLine = portfolioAssuranceEnabled({ option: options.portfolioAssurance })
|
|
41
|
+
? (status.startsWith('✅') ? `✅ ${HEADLINE}` : `❌ ${BLOCKED_HEADLINE}`) + ' (completed supported checks only)' : status;
|
|
39
42
|
const top = (findings || []).slice(0, 3);
|
|
40
43
|
|
|
41
44
|
const lines = [
|
|
42
45
|
`*🛡 agentic-security daily — ${new Date().toISOString().slice(0,10)}*`,
|
|
43
46
|
`Project: \`${project}\``,
|
|
44
|
-
`Status: ${
|
|
47
|
+
`Status: ${statusLine}`,
|
|
45
48
|
'',
|
|
46
49
|
`*Findings:* ${summary.critical || 0} critical · ${summary.high || 0} high · ${summary.medium || 0} medium`,
|
|
47
50
|
];
|
|
@@ -66,7 +69,7 @@ export function buildDiscordDigest(findings, summary, options = {}) {
|
|
|
66
69
|
embeds: [{
|
|
67
70
|
title: `🛡 agentic-security — ${project}`,
|
|
68
71
|
color: safe ? 0x2ecc71 : 0xe74c3c,
|
|
69
|
-
description: safe ? '✅ safe to deploy' : '❌ not safe to deploy',
|
|
72
|
+
description: portfolioAssuranceEnabled({ option: options.portfolioAssurance }) ? (safe ? `✅ ${HEADLINE} (completed supported checks only)` : `❌ ${BLOCKED_HEADLINE}`) : (safe ? '✅ safe to deploy' : '❌ not safe to deploy'),
|
|
70
73
|
fields: [
|
|
71
74
|
{ name: 'Critical', value: String(summary.critical || 0), inline: true },
|
|
72
75
|
{ name: 'High', value: String(summary.high || 0), inline: true },
|
package/src/ir/CLAUDE.md
CHANGED
|
@@ -34,6 +34,20 @@ consumed by `scanner/src/dataflow/` for taint analysis.
|
|
|
34
34
|
| Rust | `parser-rust.js` | Hand-rolled, the `parser-go.js`/`parser-cs.js` pattern (preprocess: comments blanked, `::` paths flattened to dots, turbofish/`?`/`.await` stripped; then statement-split + recursive CFG build). **SARD 80% F1 push, first-class from day one — no prior IR existed for Rust at all** (only the opt-in, WASM-based `tree-sitter-loader.js` long-tail path below, which nothing in the taint engine consumes). Models: `fn` items (free/impl/trait, qualifiers, generics, `impl Type` methods named `Type.method`), `let`/destructuring/compound-assign, method chains (args accumulated innermost-first, so `Command::new("sh").arg("-c").arg(x)` has the literal program name at arg 0 regardless of the chain's terminal method), macros (`format!`/`write!`/etc. lower to a template with inline `{ident}` and positional args; `vec!` to an array; every other macro keeps its own `name!` callee so `sqlx::query!` — the compile-checked, parameterized macro — never collides with the `query` sink); `if`/`if let`/`while`/`while let`/`loop`/`for`/`match` (pattern bindings assigned from the scrutinee) including expression-position control flow (`let x = match … {}` yields a `union` of the branch tails); trailing closures inlined with their parameter bound to the receiver; extractor-typed parameters (`Query(q): Query<P>`) and route-attributed handlers (`#[get("/<x>")]`) emit `fn.paramAnnotations`. **`.unwrap()`/`.expect(msg)` are popped from the chain, not just taint-transparent** — leaving them in shifts the terminal segment away from the real sink name (`sqlx::query(&sql).fetch_all(pool).await.unwrap()` would otherwise end in `.unwrap`, never `.fetch_all`, and no catalog entry could ever match it), the same "terminal segment shift" defect class documented for Go/Java/Kotlin above; a macro-only statement whose sole continuation is a popped `.unwrap()` (`write!(w, "…", x).unwrap();`) still counts as a call, tracked via a separate `_baseIsCall` flag so it doesn't fall through to "no call happened here" once the passthrough is gone. Three self-scan-flagged regexes (`(?:#\[[^\]]*\]\s*)+`, the attribute-skip pattern, appearing at three call sites) match the "optional/quantified group flanked by `\s*`" ReDoS heuristic shape but are linear by construction — each iteration is bounded by the mandatory literal `#[`…`]`, no shared character class exists for the engine to backtrack across — confirmed by direct timing (50 000 back-to-back attributes, 200 000 non-matching whitespace chars: both single-digit milliseconds), not merely asserted; see the comment at `_parseParams` in `parser-rust.js`. Guarded by `test/parser-rust.test.js` (CFG shape) and `test/rust-taint-flow.test.js` (end-to-end taint flow + precision cases). **Measured `bench/layer-recall` impact: 0 → 5/5 (100%) whole-corpus, 0 → 3/3 (100%) deep-tier** on first measurement — the corpus is small and every entry is annotation-sourced (axum/actix/rocket extractor parameters route directly into `paramAnnotations`), so 100% reflects the corpus's own composition, not general Rust ecosystem coverage; see `docs/METRICS.md`'s own disclosure. |
|
|
35
35
|
| Long-tail (solidity/go/swift/dart) | `tree-sitter-loader.js` | **Optional** `web-tree-sitter` + `tree-sitter-wasms` (ABI-pinned 0.20.8 ↔ 0.1.13), lazy + degrades when absent. Powers `sast/tree-sitter-sinks.js` (opt-in via `AGENTIC_SECURITY_TREE_SITTER=1`). Marked `--external` in the build so the committed bundle never embeds WASM. Rust's own tree-sitter grammar remains loadable here too (unaffected by `parser-rust.js` existing), but the hand-rolled parser above is what feeds the taint engine — this path is now redundant for Rust specifically, kept only because removing a language from `tree-sitter-sinks.js`'s opt-in surface is out of scope for this change. |
|
|
36
36
|
|
|
37
|
+
## Implicit handler parameters and Go line attribution (QA-006)
|
|
38
|
+
|
|
39
|
+
`implicit-handler-params.js` is the pure decision "is this parameter request-bound without any annotation". `parser-cs.js` emits a
|
|
40
|
+
`paramAnnotations` entry with decorator `ImplicitMvcActionParam` for a string-shaped parameter of a public, non-static, non-`[NonAction]` method of a
|
|
41
|
+
controller class (derived from Controller/ControllerBase/ApiController, or named `*Controller`) unless it carries `[FromServices]` or an explicit
|
|
42
|
+
`[From*]`; `parser-java.js` emits `ImplicitSpringMappedParam` for a plain `String` parameter of a method annotated `@GetMapping`/`@PostMapping`/
|
|
43
|
+
`@RequestMapping`/... The catalog turns those two names into sources; the IR states the fact and the catalog decides what is a source. Nothing in the
|
|
44
|
+
module reads a path, label or environment variable.
|
|
45
|
+
|
|
46
|
+
`parser-go.js` line attribution: `FUNC_RE`'s leading `\s*` swallows blank lines, so the function used to be placed at the first newline of the run above
|
|
47
|
+
it, and `_splitStatements` dropped blank and comment lines so every later statement was pulled upward by their count. Statement lines are now start-offset
|
|
48
|
+
based (`_splitStatements(...).lineOffsets`, `_buildCfg` takes the line the body BEGINS on), a `//` comment no longer swallows the newline that ends its
|
|
49
|
+
line, and nested if/for/closure bodies take their base from the real brace position. Tests: `test/evaluation/engine-mechanisms.test.js`.
|
|
50
|
+
|
|
37
51
|
## Python parser — dual-path with auto fallback
|
|
38
52
|
|
|
39
53
|
The Python pipeline has two implementations. The dispatcher in `index.js`
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Implicit request-bound handler parameters (QA-006, mechanism "framework entry points").
|
|
2
|
+
//
|
|
3
|
+
// A web framework binds the parameters of a request-handler method from the
|
|
4
|
+
// request WITHOUT any annotation on the parameter itself:
|
|
5
|
+
// - ASP.NET MVC / Core: a public action method of a controller binds `string next`
|
|
6
|
+
// from the route, query string or form unless it carries `[FromServices]`.
|
|
7
|
+
// - Spring MVC: a method carrying `@GetMapping` / `@RequestMapping` / ... binds a plain
|
|
8
|
+
// `String q` parameter from the request parameters (`@RequestParam` is optional).
|
|
9
|
+
//
|
|
10
|
+
// The taint engine's annotation channel (`fn.paramAnnotations`, matched by the catalog's
|
|
11
|
+
// `type: 'annotation'` sources) only ever saw the EXPLICIT forms (`[FromQuery]`,
|
|
12
|
+
// `@RequestParam`), so a handler written in the equally common implicit form had no
|
|
13
|
+
// source at all and the flow was invisible to the taint layer. This module is the pure
|
|
14
|
+
// decision of "is this parameter implicitly request-bound", shared by the C# and Java
|
|
15
|
+
// frontends; each frontend turns a positive answer into a `paramAnnotations` entry whose
|
|
16
|
+
// decorator is one of the two names below, which the catalog binds to a source.
|
|
17
|
+
//
|
|
18
|
+
// Scope, stated so nothing is over-read:
|
|
19
|
+
// - ONLY string-shaped parameters. A numeric or Guid parameter cannot carry an injection
|
|
20
|
+
// payload, and a complex model parameter's field-level taint is out of scope here.
|
|
21
|
+
// - ONLY inside a class the framework would treat as a handler (a Controller-derived
|
|
22
|
+
// class, or any class whose name ends in `Controller`; for Spring, the MAPPING
|
|
23
|
+
// annotation on the method is what makes it a handler, not the class).
|
|
24
|
+
// - A DI-injected parameter is not request-bound: `[FromServices]` opts a parameter out.
|
|
25
|
+
// - Nothing here looks at a file name, path, comment or label.
|
|
26
|
+
|
|
27
|
+
export const IMPLICIT_MVC_PARAM = 'ImplicitMvcActionParam';
|
|
28
|
+
export const IMPLICIT_SPRING_PARAM = 'ImplicitSpringMappedParam';
|
|
29
|
+
|
|
30
|
+
const CS_CONTROLLER_BASE = /^(?:Controller|ControllerBase|ApiController|AsyncController)$/;
|
|
31
|
+
const CS_STRINGISH = /^(?:(?:System\.)?[Ss]tring\??(?:\[\])?|(?:List|IList|IEnumerable|ICollection|IReadOnlyList)<\s*(?:System\.)?[Ss]tring\??\s*>)$/;
|
|
32
|
+
const CS_DI_OPT_OUT = new Set(['FromServices', 'FromKeyedServices']);
|
|
33
|
+
|
|
34
|
+
/** True when a C# class record (`{ name, bases }`) is one the framework treats as a controller. */
|
|
35
|
+
function isCsControllerClass(range) {
|
|
36
|
+
if (!range) return false;
|
|
37
|
+
if (/Controller$/.test(range.name || '')) return true;
|
|
38
|
+
return (range.bases || []).some((b) => CS_CONTROLLER_BASE.test(b));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Should a C# parameter be treated as implicitly request-bound?
|
|
43
|
+
* @param {object} o
|
|
44
|
+
* @param {string} o.modifiers the method's modifier text (the declaration prefix), e.g. 'public async'
|
|
45
|
+
* @param {string} o.rawType the parameter's declared type text, e.g. 'string', 'List<string>'
|
|
46
|
+
* @param {string[]} o.decorators the parameter's attribute names
|
|
47
|
+
* @param {boolean} o.nonAction the method carries [NonAction]
|
|
48
|
+
* @param {object} o.classRange the enclosing class record
|
|
49
|
+
*/
|
|
50
|
+
export function isImplicitCsActionParam({ modifiers = '', rawType = '', decorators = [], nonAction = false, classRange = null }) {
|
|
51
|
+
if (nonAction) return false;
|
|
52
|
+
if (!isCsControllerClass(classRange)) return false;
|
|
53
|
+
if (!/\bpublic\b/.test(modifiers) || /\bstatic\b/.test(modifiers)) return false;
|
|
54
|
+
if (decorators.some((d) => CS_DI_OPT_OUT.has(d))) return false;
|
|
55
|
+
// An explicit binding attribute is handled by the explicit annotation sources.
|
|
56
|
+
if (decorators.some((d) => /^From/.test(d))) return false;
|
|
57
|
+
return CS_STRINGISH.test(String(rawType).trim());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SPRING_MAPPING = /^(?:RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)$/;
|
|
61
|
+
const SPRING_DI_OR_BINDING = /^(?:RequestParam|PathVariable|RequestBody|RequestHeader|CookieValue|ModelAttribute|RequestAttribute|SessionAttribute|RequestPart|Value|Autowired)$/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Should a Java parameter be treated as implicitly request-bound?
|
|
65
|
+
* @param {string[]} o.methodAnnotations annotation simple names on the method
|
|
66
|
+
* @param {string} o.paramType the parameter's simple type name ('String')
|
|
67
|
+
* @param {string[]} o.decorators annotation names already on the parameter
|
|
68
|
+
*/
|
|
69
|
+
export function isImplicitSpringMappedParam({ methodAnnotations = [], paramType = '', decorators = [] }) {
|
|
70
|
+
if (!methodAnnotations.some((a) => SPRING_MAPPING.test(a))) return false;
|
|
71
|
+
if (paramType !== 'String') return false;
|
|
72
|
+
if (decorators.some((d) => SPRING_DI_OR_BINDING.test(d))) return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
package/src/ir/parser-cs.js
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
// This is a v1. Promoted to a Roslyn-backed CST parser (analogous to
|
|
46
46
|
// parser-py-cst.js) once we have a dotnet capability probe.
|
|
47
47
|
|
|
48
|
+
import { IMPLICIT_MVC_PARAM, isImplicitCsActionParam } from './implicit-handler-params.js';
|
|
48
49
|
import * as crypto from 'node:crypto';
|
|
49
50
|
import { callSitesFromCfg } from './call-sites.js';
|
|
50
51
|
import { matchBalancedCall } from './balanced-call.js';
|
|
@@ -270,6 +271,16 @@ function _extractClassFields(code, range) {
|
|
|
270
271
|
// Innermost class whose range contains `pos` (a method declaration's match
|
|
271
272
|
// offset), or null when the method sits outside any class (top-level
|
|
272
273
|
// statements — legal but rare).
|
|
274
|
+
function _enclosingClassRange(ranges, pos) {
|
|
275
|
+
let best = null;
|
|
276
|
+
for (const r of ranges) {
|
|
277
|
+
if (pos >= r.start && pos < r.end) {
|
|
278
|
+
if (!best || (r.end - r.start) < (best.end - best.start)) best = r;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return best;
|
|
282
|
+
}
|
|
283
|
+
|
|
273
284
|
function _enclosingClassName(ranges, pos) {
|
|
274
285
|
let best = null;
|
|
275
286
|
for (const r of ranges) {
|
|
@@ -1537,7 +1548,12 @@ export function parseCSharpFile(file, code) {
|
|
|
1537
1548
|
while ((m = METHOD_RE.exec(code)) !== null) {
|
|
1538
1549
|
const name = m[2];
|
|
1539
1550
|
const className = _enclosingClassName(classRanges, m.index);
|
|
1551
|
+
const classRange = _enclosingClassRange(classRanges, m.index);
|
|
1540
1552
|
const paramsText = m[3] || '';
|
|
1553
|
+
// [NonAction] sits in the attribute region just before the declaration.
|
|
1554
|
+
// Only the attribute region of THIS member counts: cut at the previous statement or block end.
|
|
1555
|
+
const attrRegion = code.slice(Math.max(0, m.index - 300), m.index + 1);
|
|
1556
|
+
const nonAction = /\bNonAction\b/.test(attrRegion.slice(Math.max(attrRegion.lastIndexOf('}'), attrRegion.lastIndexOf(';')) + 1));
|
|
1541
1557
|
const paramAnnotations = [];
|
|
1542
1558
|
const paramTypes = {};
|
|
1543
1559
|
// `keptIdx` tracks the parameter's position in the FILTERED array — the
|
|
@@ -1616,6 +1632,10 @@ export function parseCSharpFile(file, code) {
|
|
|
1616
1632
|
paramAnnotations.push({ index: keptIdx, name: paramName, decorator });
|
|
1617
1633
|
}
|
|
1618
1634
|
if (paramType) paramTypes[paramName] = paramType;
|
|
1635
|
+
// QA-006: a controller action's plain string parameter is bound from the request with no attribute at all.
|
|
1636
|
+
if (isImplicitCsActionParam({ modifiers: m[0], rawType: nameTokens.filter(w => !/^(?:ref|out|params|in|this)$/.test(w)).join(' '), decorators, nonAction, classRange })) {
|
|
1637
|
+
paramAnnotations.push({ index: keptIdx, name: paramName, decorator: IMPLICIT_MVC_PARAM });
|
|
1638
|
+
}
|
|
1619
1639
|
keptIdx++;
|
|
1620
1640
|
}
|
|
1621
1641
|
return paramName;
|
package/src/ir/parser-go.js
CHANGED
|
@@ -34,6 +34,10 @@ const FUNC_RE = new RegExp(
|
|
|
34
34
|
|
|
35
35
|
function _splitStatements(body) {
|
|
36
36
|
const out = [];
|
|
37
|
+
// Index (in `body`) of each statement's first character, so a caller can recover its real line. Blank lines and comment-only
|
|
38
|
+
// lines yield no statement, so counting statements undercounts lines wherever a function body has either.
|
|
39
|
+
const starts = [];
|
|
40
|
+
let bufStart = 0;
|
|
37
41
|
let buf = '';
|
|
38
42
|
let depth = 0;
|
|
39
43
|
let inStr = null;
|
|
@@ -57,19 +61,24 @@ function _splitStatements(body) {
|
|
|
57
61
|
if (c === '`') { inRaw = true; buf += c; continue; }
|
|
58
62
|
if (c === '/' && body[i + 1] === '/') {
|
|
59
63
|
while (i < body.length && body[i] !== '\n') i++;
|
|
64
|
+
// Leave the newline for the loop to see: swallowing it meant a comment line never ended its statement, so the next line was
|
|
65
|
+
// glued to whatever came before the comment and its line number was counted from the wrong place.
|
|
66
|
+
i--;
|
|
60
67
|
continue;
|
|
61
68
|
}
|
|
62
69
|
if (c === '{' || c === '(' || c === '[') depth++;
|
|
63
70
|
if (c === '}' || c === ')' || c === ']') depth--;
|
|
64
71
|
if ((c === '\n' || c === ';') && depth === 0) {
|
|
65
72
|
const t = buf.trim();
|
|
66
|
-
if (t) out.push(t);
|
|
73
|
+
if (t) { out.push(t); starts.push(bufStart + (buf.length - buf.trimStart().length)); }
|
|
67
74
|
buf = '';
|
|
75
|
+
bufStart = i + 1;
|
|
68
76
|
continue;
|
|
69
77
|
}
|
|
70
78
|
buf += c;
|
|
71
79
|
}
|
|
72
|
-
if (buf.trim()) out.push(buf.trim());
|
|
80
|
+
if (buf.trim()) { out.push(buf.trim()); starts.push(bufStart + (buf.length - buf.trimStart().length)); }
|
|
81
|
+
out.lineOffsets = starts.map((idx) => _countNewlinesUpTo(body, idx));
|
|
73
82
|
return out;
|
|
74
83
|
}
|
|
75
84
|
|
|
@@ -399,17 +408,21 @@ function _link(nodes, src, dst) {
|
|
|
399
408
|
if (!nodes[dst].pred.includes(src)) nodes[dst].pred.push(src);
|
|
400
409
|
}
|
|
401
410
|
|
|
411
|
+
// `startLine` is the line on which `bodyText` BEGINS (the line of its opening brace); each statement's line is that plus the
|
|
412
|
+
// newlines before the statement, so blank lines and comments inside the body no longer pull later statements upward.
|
|
402
413
|
function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
403
414
|
const stmts = _splitStatements(bodyText);
|
|
404
415
|
let prev = prevId;
|
|
405
416
|
let line = startLine;
|
|
406
|
-
for (
|
|
417
|
+
for (let si = 0; si < stmts.length; si++) {
|
|
418
|
+
const stmt = stmts[si];
|
|
419
|
+
line = startLine + (stmts.lineOffsets ? stmts.lineOffsets[si] : 0);
|
|
407
420
|
const s = stmt.trim();
|
|
408
421
|
if (!s || s.startsWith('//')) { line++; continue; }
|
|
409
422
|
|
|
410
423
|
// if statement with brace body
|
|
411
|
-
const ifMatch = s.match(/^if\s+([\s\S]+?)\s*\{([\s\S]*)\}(?:\s*else\s*\{([\s\S]*)\})?\s*$/
|
|
412
|
-
s.match(/^if\s+([\s\S]+?)\s*\{([\s\S]*)\}\s*$/
|
|
424
|
+
const ifMatch = s.match(/^if\s+([\s\S]+?)\s*\{([\s\S]*)\}(?:\s*else\s*\{([\s\S]*)\})?\s*$/sd) ||
|
|
425
|
+
s.match(/^if\s+([\s\S]+?)\s*\{([\s\S]*)\}\s*$/sd);
|
|
413
426
|
if (ifMatch) {
|
|
414
427
|
const condText = ifMatch[1].replace(/;[^;]*$/, '').trim();
|
|
415
428
|
const thenBody = ifMatch[2];
|
|
@@ -417,10 +430,10 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
|
417
430
|
const ifNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(condText), line });
|
|
418
431
|
_link(nodes, prev, ifNode);
|
|
419
432
|
const join = _addNode(nodes, { kind: 'noop', line });
|
|
420
|
-
const thenTail = _buildCfg(thenBody, nodes, ifNode, line +
|
|
433
|
+
const thenTail = _buildCfg(thenBody, nodes, ifNode, line + _countNewlinesUpTo(s, ifMatch.indices[2][0]));
|
|
421
434
|
_link(nodes, thenTail, join);
|
|
422
435
|
if (elseBody) {
|
|
423
|
-
const elseTail = _buildCfg(elseBody, nodes, ifNode, line +
|
|
436
|
+
const elseTail = _buildCfg(elseBody, nodes, ifNode, line + _countNewlinesUpTo(s, ifMatch.indices[3][0]));
|
|
424
437
|
_link(nodes, elseTail, join);
|
|
425
438
|
} else {
|
|
426
439
|
_link(nodes, ifNode, join);
|
|
@@ -431,7 +444,7 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
|
431
444
|
}
|
|
432
445
|
|
|
433
446
|
// for loop with brace body
|
|
434
|
-
const forMatch = s.match(/^for\s+([\s\S]*?)\s*\{([\s\S]*)\}\s*$/
|
|
447
|
+
const forMatch = s.match(/^for\s+([\s\S]*?)\s*\{([\s\S]*)\}\s*$/sd);
|
|
435
448
|
if (forMatch) {
|
|
436
449
|
const header = _addNode(nodes, { kind: 'loop-header', line });
|
|
437
450
|
_link(nodes, prev, header);
|
|
@@ -446,7 +459,7 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
|
446
459
|
_link(nodes, header, assignId);
|
|
447
460
|
bodyPrev = assignId;
|
|
448
461
|
}
|
|
449
|
-
const bodyTail = _buildCfg(loopBody, nodes, bodyPrev, line +
|
|
462
|
+
const bodyTail = _buildCfg(loopBody, nodes, bodyPrev, line + _countNewlinesUpTo(s, forMatch.indices[2][0]));
|
|
450
463
|
_link(nodes, bodyTail, header);
|
|
451
464
|
const join = _addNode(nodes, { kind: 'noop', line });
|
|
452
465
|
_link(nodes, header, join);
|
|
@@ -476,8 +489,9 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
|
476
489
|
const callId = _addNode(nodes, { kind: 'call', line, callee: closureCall.callee, args: outerArgs });
|
|
477
490
|
_link(nodes, prev, callId);
|
|
478
491
|
const closureOffset = s.indexOf(closure.closureText);
|
|
479
|
-
const
|
|
480
|
-
|
|
492
|
+
const closureBodyIdx = closureOffset >= 0 ? s.indexOf(closure.body, closureOffset) : -1;
|
|
493
|
+
const bodyStartLine = closureBodyIdx >= 0
|
|
494
|
+
? line + _countNewlinesUpTo(s, closureBodyIdx)
|
|
481
495
|
: line;
|
|
482
496
|
prev = _buildCfg(closure.body, nodes, callId, bodyStartLine);
|
|
483
497
|
line += (s.match(/\n/g) || []).length + 1;
|
|
@@ -517,11 +531,14 @@ export function parseGoFile(file, code) {
|
|
|
517
531
|
if (braceIdx < 0) continue;
|
|
518
532
|
const extracted = _extractBody(code, braceIdx);
|
|
519
533
|
if (!extracted) continue;
|
|
520
|
-
|
|
534
|
+
// FUNC_RE's leading boundary is a newline and its `\s*` then swallows every blank line before `func`, so m.index can sit
|
|
535
|
+
// several lines ABOVE the declaration (a blank line before a function is the gofmt norm). Every line below, the function's
|
|
536
|
+
// own and each statement's, is counted from here, so the keyword's offset is what must be measured.
|
|
537
|
+
const startLine = _lineAt(code, m.index + Math.max(0, m[0].indexOf('func')));
|
|
521
538
|
const nodes = {};
|
|
522
539
|
const entry = _addNode(nodes, { kind: 'entry', line: startLine });
|
|
523
540
|
const exit = _addNode(nodes, { kind: 'exit', line: startLine });
|
|
524
|
-
const tail = _buildCfg(extracted.body, nodes, entry,
|
|
541
|
+
const tail = _buildCfg(extracted.body, nodes, entry, _lineAt(code, braceIdx + 1));
|
|
525
542
|
_link(nodes, tail, exit);
|
|
526
543
|
const cfg = { entry, exit, nodes };
|
|
527
544
|
functions.push({
|
package/src/ir/parser-java.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { blankComments } from '../sast/_comment-strip.js';
|
|
25
25
|
import { callSitesFromCfg } from './call-sites.js';
|
|
26
|
+
import { IMPLICIT_SPRING_PARAM, isImplicitSpringMappedParam } from './implicit-handler-params.js';
|
|
26
27
|
|
|
27
28
|
let _nodeIdSeq = 0;
|
|
28
29
|
function nextNodeId() { return 'jn' + (++_nodeIdSeq); }
|
|
@@ -1082,6 +1083,11 @@ export async function parseJavaFile(file, raw) {
|
|
|
1082
1083
|
|| md.children?.methodDeclarator?.[0]?.children?.formalParameterList?.[0];
|
|
1083
1084
|
const paramAnnotations = [];
|
|
1084
1085
|
const paramTypes = {};
|
|
1086
|
+
// QA-006: annotation simple names on the METHOD itself (`@GetMapping`), read from its modifiers.
|
|
1087
|
+
const methodAnnotations = (md.children?.methodModifier || []).map((mm) => {
|
|
1088
|
+
const ids = mm.children?.annotation?.[0]?.children?.typeName?.[0]?.children?.Identifier;
|
|
1089
|
+
return ids?.length ? ids[ids.length - 1]?.image : null;
|
|
1090
|
+
}).filter(Boolean);
|
|
1085
1091
|
const params = (fpl?.children?.formalParameter || []).map((fp, idx) => {
|
|
1086
1092
|
// Regular parameters nest under `variableParaRegularParameter`.
|
|
1087
1093
|
// Varargs (`String... args`) instead use a distinct
|
|
@@ -1119,6 +1125,7 @@ export async function parseJavaFile(file, raw) {
|
|
|
1119
1125
|
// the first, or a stacked annotation is silently dropped (the
|
|
1120
1126
|
// same class of bug Task 3's C# regex needed a fix round for).
|
|
1121
1127
|
const variableModifiers = vp?.children?.variableModifier || [];
|
|
1128
|
+
const ownDecorators = [];
|
|
1122
1129
|
for (const vm of variableModifiers) {
|
|
1123
1130
|
const ann = vm.children?.annotation?.[0];
|
|
1124
1131
|
// `typeName.children.Identifier` is an array of ALL
|
|
@@ -1133,9 +1140,13 @@ export async function parseJavaFile(file, raw) {
|
|
|
1133
1140
|
const identifiers = ann?.children?.typeName?.[0]?.children?.Identifier;
|
|
1134
1141
|
const decoratorName = identifiers?.length ? identifiers[identifiers.length - 1]?.image : undefined;
|
|
1135
1142
|
if (decoratorName && paramName) {
|
|
1143
|
+
ownDecorators.push(decoratorName);
|
|
1136
1144
|
paramAnnotations.push({ index: idx, name: paramName, decorator: decoratorName });
|
|
1137
1145
|
}
|
|
1138
1146
|
}
|
|
1147
|
+
if (paramName && isImplicitSpringMappedParam({ methodAnnotations, paramType, decorators: ownDecorators })) {
|
|
1148
|
+
paramAnnotations.push({ index: idx, name: paramName, decorator: IMPLICIT_SPRING_PARAM });
|
|
1149
|
+
}
|
|
1139
1150
|
return paramName;
|
|
1140
1151
|
}).filter(Boolean);
|
|
1141
1152
|
const body = md.children?.methodBody?.[0]?.children?.block?.[0];
|
|
@@ -148,6 +148,15 @@ const un=net.connect(sock);un.on('connect',()=>{r.daemonSocket='CONNECTED';un.de
|
|
|
148
148
|
setTimeout(()=>{r.timeout=true;console.log(JSON.stringify(r));process.exit(0)},8000);
|
|
149
149
|
`;
|
|
150
150
|
|
|
151
|
+
/** server.listen that settles: it resolves when listening and rejects on the server's error (a bare callback never fires on failure). */
|
|
152
|
+
function listenOrReject(server, ...args) {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const onError = (e) => reject(e);
|
|
155
|
+
server.once('error', onError);
|
|
156
|
+
server.listen(...args, () => { server.off('error', onError); resolve(); });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
151
160
|
/**
|
|
152
161
|
* Prove the isolation of a backend with real attempts, independent of any evaluator flag. The result is
|
|
153
162
|
* `verified` only if the one permitted read worked, the scratch write worked and every other attempt was refused.
|
|
@@ -160,12 +169,31 @@ export async function probeSandbox(sb, opts = {}) {
|
|
|
160
169
|
for (const d of [allowed, scratch, outside]) mkdirSync(d);
|
|
161
170
|
writeFileSync(join(allowed, 'a.txt'), 'allowed'); writeFileSync(join(outside, 'secret.txt'), 'secret');
|
|
162
171
|
const script = join(base, 'probe.cjs'); writeFileSync(script, PROBE_SOURCE);
|
|
163
|
-
|
|
172
|
+
let sockPath = join(base, 'daemon.sock');
|
|
173
|
+
let sockDir = null;
|
|
164
174
|
let connections = 0;
|
|
165
175
|
const tcp = netServer((s) => { connections++; s.destroy(); });
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
176
|
+
let uds = netServer((s) => { connections++; s.destroy(); });
|
|
177
|
+
try {
|
|
178
|
+
await listenOrReject(tcp, 0, '127.0.0.1');
|
|
179
|
+
try {
|
|
180
|
+
await listenOrReject(uds, sockPath);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
// A unix socket path is limited to about 104 bytes, and a temporary directory can easily be longer than that (macOS, CI runners).
|
|
183
|
+
// Without an error handler the listen call never completed and the whole evaluation hung. Retry in a short directory; the probe
|
|
184
|
+
// proves that the sandboxed process is refused, which does not depend on where the socket lives.
|
|
185
|
+
if (!['EINVAL', 'ENAMETOOLONG'].includes(e && e.code) || !existsSync('/tmp')) throw e;
|
|
186
|
+
uds.close();
|
|
187
|
+
uds = netServer((s) => { connections++; s.destroy(); });
|
|
188
|
+
sockDir = mkdtempSync('/tmp/nep-');
|
|
189
|
+
sockPath = join(sockDir, 'd.sock');
|
|
190
|
+
await listenOrReject(uds, sockPath);
|
|
191
|
+
}
|
|
192
|
+
} catch (e) {
|
|
193
|
+
tcp.close(); uds.close(); rmSync(base, { recursive: true, force: true });
|
|
194
|
+
if (sockDir) rmSync(sockDir, { recursive: true, force: true });
|
|
195
|
+
return { verified: false, backend: sb.backend, reason: `the probe could not create its listeners (${(e && e.code) || 'error'}): isolation is not verified`, attempts: {} };
|
|
196
|
+
}
|
|
169
197
|
const port = tcp.address().port;
|
|
170
198
|
try {
|
|
171
199
|
const exe = opts.nodePath || process.execPath;
|
|
@@ -186,7 +214,7 @@ export async function probeSandbox(sb, opts = {}) {
|
|
|
186
214
|
if (attempts.credentialEnv && attempts.credentialEnv !== 'absent') insufficient.push('a credential environment variable was visible');
|
|
187
215
|
if (attempts.timeout) insufficient.push('the probe timed out');
|
|
188
216
|
return { verified: insufficient.length === 0 && connections === 0, backend: sb.backend, reason: insufficient.length ? insufficient.join('; ') : (connections ? 'the probe listeners saw a connection' : null), attempts, listenerConnections: connections, insufficient };
|
|
189
|
-
} finally { tcp.close(); uds.close(); rmSync(base, { recursive: true, force: true }); }
|
|
217
|
+
} finally { tcp.close(); uds.close(); rmSync(base, { recursive: true, force: true }); if (sockDir) rmSync(sockDir, { recursive: true, force: true }); }
|
|
190
218
|
}
|
|
191
219
|
|
|
192
220
|
// ── 3. evaluator feature detection ───────────────────────────────────────────
|
package/src/lineage/CLAUDE.md
CHANGED
|
@@ -1182,6 +1182,37 @@ established operator-config trust model this whole package uses
|
|
|
1182
1182
|
everywhere else (a hand-edited config file is trusted as declared, the
|
|
1183
1183
|
way `recipient-profiles.json`/`privacy-policy.json` already are).
|
|
1184
1184
|
|
|
1185
|
+
## Deployment boundary graph (differentiation PRD, X-301 to X-308)
|
|
1186
|
+
|
|
1187
|
+
`deployment/` is a SEPARATE graph from `DataFlowGraph v1`: it answers "what can reach what, as deployed" (repositories,
|
|
1188
|
+
services, routes, identities, tenants, resources, environments), not "where does a data element flow". It shares no mutable
|
|
1189
|
+
state with the taint or vulnerability engines. It imports only pure helpers (the assurance schema kit, canonical
|
|
1190
|
+
serialization and content hashing from `posture/assurance/`) and three assurance enums (provenance, path state,
|
|
1191
|
+
observation completeness), so the verification layer and this graph speak one vocabulary. It is gated by the
|
|
1192
|
+
`deployment-boundaries` feature (off by default); only the two file-reading entry points are gated, everything else is a
|
|
1193
|
+
pure function of text. Suite: `npm run test:deployment`, with fixtures under `test/deployment/fixtures/`.
|
|
1194
|
+
|
|
1195
|
+
| Module | Responsibility |
|
|
1196
|
+
|---|---|
|
|
1197
|
+
| `deployment/boundary-graph.js` | X-301. The versioned, closed-world contract. Seven node kinds, a relation table giving each relation its legal endpoint kinds, the trust boundary it crosses and its meaning (`RELATION_SEMANTICS`), and `validateBoundaryGraph` (ids are recomputed, a tampered digest or id is refused). **Three provenance types are separate**: `static-config`, `runtime-observation`, `inferred` (part of an edge's id, so a configured and an observed edge between the same nodes are two edges). A path is `possible`, `blocked`, `unresolved` or `runtime-supported`; `blocked` requires an explicit deny and a deny is always blocked; absence never blocks. The edge completeness enum has no `complete` value, so no producer can assert complete traffic coverage. Node ids hash (kind, name, environment, tenant), so two environments or tenants are two nodes; the owning repository is NOT in the id, so a reference and its declaration coincide and two repositories claiming one identity collide detectably. `buildBoundaryGraph` normalizes, merges identical declarations, records conflicts as gaps (conflicting allow becomes `unresolved`, deny kept) and never throws; `exportBoundaryGraph`/`importBoundaryGraph` round-trip byte-identically; `findPath` is a bounded, cycle-safe shortest path (shared with the correlation and cross-repository code, and the base for X-305). |
|
|
1198
|
+
| `deployment/adapter-kit.js` | Shared adapter context. Every static edge carries `{file, digest, parser, parserVersion}` and the revision; every declared node carries `declaredBy` file references; an unresolved reference becomes an unresolved node plus a typed gap. YAML is read with the scanner's safe loader (unknown tags refused), more than 100 aliases is refused, JSON with `JSON.parse`; nothing is evaluated. |
|
|
1199
|
+
| `deployment/adapter-kubernetes.js`, `adapter-compose.js`, `adapter-cloud.js` | X-302 adapters: Kubernetes (workloads, Service, Ingress, Gateway API HTTPRoute, ServiceAccount, Role/ClusterRole bindings, NetworkPolicy), compose files, Terraform plan JSON (`terraform show -json`, with `configuration` references used to join resources and `after_unknown` honoured) and standalone IAM policy documents. Secrets, ConfigMaps, environment values, env files and images are never read. A selector that matches nothing, a missing ServiceAccount/Role/Service, a value known only after apply, `NotAction`/`NotResource`, a YAML merge key and unknown resource types are typed gaps, never edges. A `<<` merge key is reported, not expanded. |
|
|
1200
|
+
| `deployment/ingest.js` | `ingestDeploymentConfig` (pure: text in, graph out; an adapter that throws is contained to that file), `ingestDeploymentFiles` (reads named files under one root, refusing traversal, absolute paths, symlinks and oversize files; feature gated), `detectFormat` (HCL, proxy configuration and TOML/XML are typed unsupported syntax) and `invalidateChangedSources` (drops the relations a changed or removed file supported, using the recorded digests; a node only that file declared stops being resolved or is dropped). |
|
|
1201
|
+
| `deployment/trace-correlation.js` | X-303. `importTraceLines` keeps an allowlist of topology metadata and removes every other field WITHOUT storing it, even its name (only counts, split credential-shaped, payload-shaped and other); strips query strings; turns opaque path segments into `:id`; rejects a record whose kept value is not identifier-shaped; refuses other-environment records instead of relabelling them; merges duplicates; and is bounded (bytes, line length, lines, distinct observations with oldest-first eviction reported). `correlateObservations` matches with numbered rules R1 to R4 (exact service pair, route chain, identity, naming convention); R4 yields an `inferred` edge, R1 to R3 a `runtime-observation` edge, and static edges are never edited. `coverage.trafficCoverage` is always `not-established`; observed, sampled, stale and missing are separate lists; a static edge with no observation stays `possible`. `applyCorrelation` folds the result into a new validated graph. It does NOT persist anything: callers store `exportObservationSet` output and read it back with `parseObservationSet` (closed world, bounded, digest-checked). This sits beside, not on top of, the existing data-flow `runtime-observation.js` records, whose graph-id oriented contract is unchanged; only `EVENT_COUNT_BANDS` is shared. |
|
|
1202
|
+
| `deployment/cross-repo.js` | X-304. `resolveCrossRepoBoundaries` takes graph exports the operator supplies, declared dependencies (each binding an exact 40/64 hex revision for both ends) and an access scope; it has no network or file access (a test pins its import list). A link resolves only when both repositories are in scope and supplied, each graph validates and names the repository it was supplied as, the graph revision equals the bound revision, the declared service exists resolved in its repository and in no other supplied repository, and a declared entry route reaches it. Every failure is a typed gap (`access-denied`, `repository-unavailable`, `version-mismatch`, `revision-unknown`, `identity-collision`, `unresolved-identity`, `graph-integrity`, `malformed-input`) and the dependency is kept as an unresolved edge to an unresolved callee. A resolved link carries evidence (declaration file and digest, both graph digests, the declaring files on each side, the route chain) and transitions (entry route, caller identities, callee identity and its grants, boundary crossings). It extends the idea of `cross-repo-link.js` to this graph; that module and `federation-loader.js` are unchanged. |
|
|
1203
|
+
| `deployment/attack-paths.js` | X-305. `computeAttackPaths` walks bounded, cycle-safe simple paths from entry points (resolved public routes and services) to targets (resources an identity is granted, or in the privileged zone) over `routes-to`, `calls`, `depends-on`, `network-allows`, `assumes` and `grants`. A hop is `blocked` only when an explicit deny edge sits between its endpoints; a path is `blocked` if any hop is, `unresolved` if a hop or endpoint is unresolved or a tenant transition makes it CONDITIONAL (a hop whose endpoints carry different tenants, listed as a `tenant-binding` condition), `runtime-supported` only if every network hop was observed (identity hops stay configuration), else `possible`. Each path lists supporting edges (with source file, digest, revision), assumptions, blockers, environments, boundary crossings and a coverage block (observed, sampled, stale, unobserved hops; `trafficCoverage` is always `not-established`; staleness is judged only when the caller passes `now`). Path ids hash the edge ids along the path, not the state, so they survive reordering and state changes. `bindFindingToService` ties a finding to a service ONLY through an explicit path-prefix binding (longest prefix wins; a tie, an absent service or no binding is a typed non-bound status and nothing is guessed). `analyzeFindingBoundaries` builds the per-finding `boundary-context` (exposure paths to the bound service, impact paths onward, verification assessments, uncovered boundaries, warnings). **Exploitability is never derived from reachability**: `assessVerification` accepts a record only if it validates, its outcome is `confirmed`, its confirmation level RECOMPUTED from evidence is `runtime-confirmed`, and its oracle is the one the finding's non-taint class names (an unsupported class has none); the label is `exploitable-confirmed` only then, and a blocked or unresolved path keeps that fact in an `oracle-confirmed-path-*` label. `exposureReach` is the path-free reachability the drift diff compares. |
|
|
1204
|
+
| `deployment/drift.js` | X-306. `diffBoundaryGraphs` compares two validated graphs of one repository at stable ids: new exposure (newly reachable from an entry, by reachability not by edge), changed privilege (new or changed grant, new identity assumption), tenant weakening (a dropped tenant binding, found by (kind, name, environment) because the tenant is part of the node id, or a new cross-tenant edge) and removed controls (a removed deny) are `material`; other relation changes are reported but not material. It also diffs the SOURCES and names the edges each moved file supported. `planRescan` maps changes to hypotheses through the bound service: a hypothesis is affected when its service is a seed of a change or can reach or be reached from one (union of both revisions, bounded closure); everything else is `skipped` with a reason and NOT rescanned. A hypothesis that cannot be tied to a service cannot be proven independent, so it makes the plan `incomplete` (only when something changed), as does a graph gap that stands for a missing dependency (`DEPENDENCY_GAP_CODES`). `incrementalAnnotate` recomputes only the planned hypotheses; `compareScoped` checks the result against a complete recomputation on `scopedSignature` (which leaves out graph-wide metadata) and never reports an incomplete result as equivalent. `invalidatePathsForSources` reuses `invalidateChangedSources` and drops exactly the paths whose supporting edges or node declarations cite a changed file. |
|
|
1205
|
+
| `deployment/projection.js` | X-307. The ONE projection of a boundary context, modelled on `posture/verification/projection.js`: `projectBoundaryContext` (closed-world validation first: an unknown field, a credential-shaped or payload-shaped string, a control character or an over-long string refuses the whole context), `filterBoundaryView` (service, repository, environment, tenant; keeps scope, uncovered boundaries and every earlier warning and adds a warning saying how many paths it hid), `boundaryFields` (the additive report fields; the context itself is echoed only when it validated), `boundaryCoverage` (run accounting) and `attachBoundaryContext`, the ONLY producer, gated by the `deployment-boundaries` feature (off returns the same findings array untouched). The text states what was and was not established, never says safe, protected or unreachable (a lint-style test sweeps every state), and a context with no path says "not shown to exist". Consumed by `report/index.js` (`normalizeFindings` adds `boundaryContext` and `boundaryView` only when a finding carries a context, a top-level `boundaryCoverage`, a Markdown and a terminal block) and by MCP `explain_finding`. Previous output schemas are pinned in `test/fixtures/boundary-compat/` (captured before the wiring). |
|
|
1206
|
+
| `deployment/boundaries-run.js` | X-308 wiring. The one operator entry point: `agentic-security boundaries --from <dir> [--findings <last-scan.json>] [--environment] [--repository] [--revision] [--root] [--out] [--json] [--now]`. `runBoundaries` (gated by `deployment-boundaries`, off by default; off returns a typed `disabled` BEFORE any file is opened) lists configuration files under the directory (bounded walk, symlinks never followed or listed), reads the reserved inputs `service-bindings.json`, `identities.json` and `traces.jsonl`/`traces.ndjson` (never ingested as configuration), ingests through `ingestDeploymentFiles`, correlates sanitized traces, reads findings from a scan result (a sibling `.sig` that exists must verify, else exit 1; an absent one is read and labelled unsigned) or from memory, and attaches contexts with `attachBoundaryContext`. Local files only, no scan, no execution; the only write is `--out`, refused when the target is a symbolic link. Exit codes: 0 report, 1 failed, 2 usage, 3 feature off, blocked or unsupported. `renderBoundariesText` reuses each context's own projected text. Suite: `test/deployment/boundaries-cli.test.js`. |
|
|
1207
|
+
|
|
1208
|
+
**Disclosed limits.** Kubernetes `matchExpressions`, namespace-only selectors and ExternalName targets are not evaluated
|
|
1209
|
+
(reported as gaps). Terraform modules other than the root are joined only through the plan's flat `resource_changes`.
|
|
1210
|
+
IAM conditions are not interpreted (a conditioned statement is recorded at low confidence with `conditioned: true`).
|
|
1211
|
+
The digest of an ingested file is over the UTF-8 text that was parsed. Observation persistence is not wired. The presentation (X-307) is consumed by the report and MCP `explain_finding` only when a finding carries a
|
|
1212
|
+
`boundaryContext`; nothing in the default scan produces one. `attachBoundaryContext` is a feature-gated helper; its one operator entry point is
|
|
1213
|
+
`agentic-security boundaries` (`boundaries-run.js`, explicit, read-only, off by default), so default output is unchanged. The boundary graph attribute grammar admits credential-shaped values such as `password=x` (it only
|
|
1214
|
+
restricts characters); the projection refuses them, but the graph contract itself does not.
|
|
1215
|
+
|
|
1185
1216
|
## Conventions
|
|
1186
1217
|
|
|
1187
1218
|
- Every enum here is a single source of truth for its concept. If you add
|