@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.
- package/CHANGELOG.md +34 -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 +30 -0
- package/src/dataflow/catalog.js +42 -1
- package/src/dataflow/engine.js +67 -2
- 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
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
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// adapter-cloud.js: Terraform plan JSON and IAM policy documents into
|
|
2
|
+
// deployment boundary nodes and edges (X-302).
|
|
3
|
+
//
|
|
4
|
+
// Input is a plan that someone already produced (`terraform show -json`); this
|
|
5
|
+
// adapter never runs terraform, never evaluates HCL and never contacts a
|
|
6
|
+
// provider. Values the plan itself marks as unknown after apply are not
|
|
7
|
+
// guessed: the relation becomes an unresolved node plus a gap. Policy
|
|
8
|
+
// documents are read with JSON.parse; `NotAction` / `NotResource` statements
|
|
9
|
+
// and conditions it cannot interpret are reported, not approximated.
|
|
10
|
+
|
|
11
|
+
import { adapterContext, isObject, asArray, str } from './adapter-kit.js';
|
|
12
|
+
|
|
13
|
+
export const TERRAFORM_PARSER = 'terraform-plan-json';
|
|
14
|
+
export const TERRAFORM_PARSER_VERSION = '1';
|
|
15
|
+
export const IAM_PARSER = 'iam-policy-json';
|
|
16
|
+
export const IAM_PARSER_VERSION = '1';
|
|
17
|
+
|
|
18
|
+
const MAX_STATEMENTS = 200;
|
|
19
|
+
const MAX_RESOURCES_PER_STATEMENT = 50;
|
|
20
|
+
|
|
21
|
+
function toList(v) { return typeof v === 'string' ? [v] : Array.isArray(v) ? v.filter(x => typeof x === 'string') : []; }
|
|
22
|
+
|
|
23
|
+
/** Emit grants edges for every statement of a policy document. Returns the number of statements read. */
|
|
24
|
+
function applyPolicy(kit, identity, policy, subject) {
|
|
25
|
+
const statements = Array.isArray(policy?.Statement) ? policy.Statement : isObject(policy?.Statement) ? [policy.Statement] : null;
|
|
26
|
+
if (!statements) { kit.gap('malformed-input', subject, 'policy document has no Statement'); return 0; }
|
|
27
|
+
let read = 0;
|
|
28
|
+
for (const st of statements.slice(0, MAX_STATEMENTS)) {
|
|
29
|
+
if (!isObject(st)) { kit.gap('malformed-input', subject, 'a policy statement that is not an object is skipped'); continue; }
|
|
30
|
+
if (st.NotAction !== undefined || st.NotResource !== undefined || st.NotPrincipal !== undefined) {
|
|
31
|
+
kit.gap('unsupported-syntax', subject, 'NotAction, NotResource and NotPrincipal statements are not interpreted; the statement is skipped');
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const effect = st.Effect === 'Allow' ? 'allow' : st.Effect === 'Deny' ? 'deny' : null;
|
|
35
|
+
if (!effect) { kit.gap('malformed-input', subject, `statement Effect must be Allow or Deny, got ${JSON.stringify(st.Effect)}`); continue; }
|
|
36
|
+
const actions = toList(st.Action).sort();
|
|
37
|
+
const resources = toList(st.Resource);
|
|
38
|
+
if (actions.length === 0 || resources.length === 0) { kit.gap('malformed-input', subject, 'a statement without Action and Resource is skipped'); continue; }
|
|
39
|
+
const conditioned = isObject(st.Condition) && Object.keys(st.Condition).length > 0;
|
|
40
|
+
read += 1;
|
|
41
|
+
if (resources.length > MAX_RESOURCES_PER_STATEMENT) kit.gap('limit-exceeded', subject, `a statement lists ${resources.length} resources; only the first ${MAX_RESOURCES_PER_STATEMENT} are read`);
|
|
42
|
+
for (const r of resources.slice(0, MAX_RESOURCES_PER_STATEMENT)) {
|
|
43
|
+
const target = kit.node('resource', `aws/${r}`, { trustZone: 'internal', attrs: r === '*' ? { wildcard: true } : {} });
|
|
44
|
+
const attrs = { actions: actions.join(',').slice(0, 250) };
|
|
45
|
+
if (conditioned) attrs.conditioned = true;
|
|
46
|
+
kit.edge('grants', identity, target, {
|
|
47
|
+
effect, confidence: conditioned ? 'low' : 'high', discriminator: `${actions.join(',')}${conditioned ? ';cond' : ''}`.slice(0, 250), attrs,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (statements.length > MAX_STATEMENTS) kit.gap('limit-exceeded', subject, `policy has ${statements.length} statements; only the first ${MAX_STATEMENTS} are read`);
|
|
52
|
+
return read;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseIamPolicy(text, fileCtx) {
|
|
56
|
+
const kit = adapterContext({ ...fileCtx, parser: IAM_PARSER, parserVersion: IAM_PARSER_VERSION });
|
|
57
|
+
let doc;
|
|
58
|
+
try { doc = JSON.parse(text); } catch (e) {
|
|
59
|
+
kit.gap('malformed-input', fileCtx.file, `not valid JSON (${String(e.message).split('\n')[0]})`);
|
|
60
|
+
return kit.result();
|
|
61
|
+
}
|
|
62
|
+
const identityName = str(fileCtx.identity);
|
|
63
|
+
let identity;
|
|
64
|
+
if (identityName) identity = kit.node('identity', `iam/${identityName}`, { trustZone: 'internal' });
|
|
65
|
+
else {
|
|
66
|
+
identity = kit.unresolved('identity', `iam/policy:${fileCtx.file}`, 'the policy document is not attached to a named identity', { trustZone: 'unknown' });
|
|
67
|
+
kit.gap('unresolved-identity', fileCtx.file, 'the policy document is not attached to a named identity; supply the identity it belongs to');
|
|
68
|
+
}
|
|
69
|
+
applyPolicy(kit, identity, doc, fileCtx.file);
|
|
70
|
+
return kit.result();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function roleNameFromArn(arn) {
|
|
74
|
+
const m = /^arn:[^:]*:iam::[^:]*:role\/(?:.*\/)?([^/]+)$/.exec(arn ?? '');
|
|
75
|
+
return m ? m[1] : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseTerraformPlan(text, fileCtx) {
|
|
79
|
+
const kit = adapterContext({ ...fileCtx, parser: TERRAFORM_PARSER, parserVersion: TERRAFORM_PARSER_VERSION });
|
|
80
|
+
let plan;
|
|
81
|
+
try { plan = JSON.parse(text); } catch (e) {
|
|
82
|
+
kit.gap('malformed-input', fileCtx.file, `not valid JSON (${String(e.message).split('\n')[0]})`);
|
|
83
|
+
return kit.result();
|
|
84
|
+
}
|
|
85
|
+
if (!isObject(plan) || !str(plan.format_version) || !Array.isArray(plan.resource_changes)) {
|
|
86
|
+
kit.gap('unsupported-format', fileCtx.file, 'not a terraform plan in JSON form (needs format_version and resource_changes)');
|
|
87
|
+
return kit.result();
|
|
88
|
+
}
|
|
89
|
+
const resources = new Map();
|
|
90
|
+
for (const rc of plan.resource_changes) {
|
|
91
|
+
if (!isObject(rc) || !str(rc.address) || !isObject(rc.change)) continue;
|
|
92
|
+
const actions = asArray(rc.change.actions);
|
|
93
|
+
if (actions.includes('delete') && !actions.includes('create')) continue;
|
|
94
|
+
resources.set(rc.address, { type: rc.type, name: rc.name, after: isObject(rc.change.after) ? rc.change.after : {}, unknown: isObject(rc.change.after_unknown) ? rc.change.after_unknown : {} });
|
|
95
|
+
}
|
|
96
|
+
const config = new Map();
|
|
97
|
+
for (const r of asArray(plan.configuration?.root_module?.resources)) {
|
|
98
|
+
if (isObject(r) && str(r.address)) config.set(r.address, isObject(r.expressions) ? r.expressions : {});
|
|
99
|
+
}
|
|
100
|
+
const refTarget = (address, field, wantType) => {
|
|
101
|
+
const refs = asArray(config.get(address)?.[field]?.references);
|
|
102
|
+
for (const ref of refs) {
|
|
103
|
+
const m = /^([a-z0-9_]+\.[A-Za-z0-9_-]+)(?:\.|$)/.exec(ref);
|
|
104
|
+
if (m && resources.get(m[1])?.type === wantType) return m[1];
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const identityNodes = new Map();
|
|
110
|
+
const roleNode = (address) => {
|
|
111
|
+
if (identityNodes.has(address)) return identityNodes.get(address);
|
|
112
|
+
const r = resources.get(address);
|
|
113
|
+
const name = str(r?.after?.name);
|
|
114
|
+
let node;
|
|
115
|
+
if (name && r.unknown.name !== true) node = kit.node('identity', `iam-role/${name}`, { trustZone: 'internal' });
|
|
116
|
+
else {
|
|
117
|
+
node = kit.unresolved('identity', `iam-role/${address}`, 'the role name is only known after apply', { trustZone: 'unknown' });
|
|
118
|
+
kit.gap('unresolved-identity', address, 'the role name is only known after apply');
|
|
119
|
+
}
|
|
120
|
+
identityNodes.set(address, node);
|
|
121
|
+
return node;
|
|
122
|
+
};
|
|
123
|
+
const roleByArn = (arn, subject) => {
|
|
124
|
+
const n = roleNameFromArn(arn);
|
|
125
|
+
if (!n) { kit.gap('unresolved-identity', subject, 'the role reference is not a literal role ARN and has no resolvable reference'); return kit.unresolved('identity', `iam-role/${subject}`, 'unresolved role reference', { trustZone: 'unknown' }); }
|
|
126
|
+
return kit.unresolved('identity', `iam-role/${n}`, 'the role is not defined in this plan', { trustZone: 'unknown' });
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const handled = new Set(['aws_iam_role', 'aws_iam_policy', 'aws_iam_role_policy', 'aws_iam_role_policy_attachment', 'aws_lambda_function', 'aws_s3_bucket', 'aws_security_group']);
|
|
130
|
+
const unsupportedTypes = new Set();
|
|
131
|
+
const policies = new Map();
|
|
132
|
+
for (const [address, r] of resources) {
|
|
133
|
+
if (r.type === 'aws_iam_policy' || r.type === 'aws_iam_role_policy') {
|
|
134
|
+
let doc = null;
|
|
135
|
+
if (typeof r.after.policy === 'string' && r.unknown.policy !== true) { try { doc = JSON.parse(r.after.policy); } catch { doc = null; } }
|
|
136
|
+
policies.set(address, doc);
|
|
137
|
+
}
|
|
138
|
+
if (!handled.has(r.type)) unsupportedTypes.add(r.type);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const [address, r] of resources) {
|
|
142
|
+
switch (r.type) {
|
|
143
|
+
case 'aws_iam_role': roleNode(address); break;
|
|
144
|
+
case 'aws_iam_role_policy': {
|
|
145
|
+
const roleAddr = refTarget(address, 'role', 'aws_iam_role');
|
|
146
|
+
const ident = roleAddr ? roleNode(roleAddr) : (str(r.after.role) && r.unknown.role !== true ? kit.node('identity', `iam-role/${r.after.role}`, { trustZone: 'internal' }) : kit.unresolved('identity', `iam-role/${address}`, 'the role is only known after apply', { trustZone: 'unknown' }));
|
|
147
|
+
const doc = policies.get(address);
|
|
148
|
+
if (!doc) kit.gap('unresolved-identity', address, 'the policy document is only known after apply or is not valid JSON; its grants are unknown');
|
|
149
|
+
else applyPolicy(kit, ident, doc, address);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case 'aws_iam_role_policy_attachment': {
|
|
153
|
+
const roleAddr = refTarget(address, 'role', 'aws_iam_role');
|
|
154
|
+
const polAddr = refTarget(address, 'policy_arn', 'aws_iam_policy');
|
|
155
|
+
const ident = roleAddr ? roleNode(roleAddr) : (str(r.after.role) && r.unknown.role !== true ? kit.node('identity', `iam-role/${r.after.role}`, { trustZone: 'internal' }) : kit.unresolved('identity', `iam-role/${address}`, 'the role is only known after apply', { trustZone: 'unknown' }));
|
|
156
|
+
if (!polAddr) {
|
|
157
|
+
kit.gap('unresolved-identity', address, `attached policy ${typeof r.after.policy_arn === 'string' ? r.after.policy_arn : '(known after apply)'} is not defined in this plan, so what it grants is unknown`);
|
|
158
|
+
const target = kit.unresolved('resource', `aws/policy:${typeof r.after.policy_arn === 'string' ? r.after.policy_arn : address}`, 'the attached policy is not defined in this plan', { trustZone: 'unknown' });
|
|
159
|
+
kit.edge('grants', ident, target, { effect: 'allow', confidence: 'low', discriminator: 'attached-policy' });
|
|
160
|
+
} else if (!policies.get(polAddr)) {
|
|
161
|
+
kit.gap('unresolved-identity', polAddr, 'the policy document is only known after apply or is not valid JSON; its grants are unknown');
|
|
162
|
+
} else applyPolicy(kit, ident, policies.get(polAddr), address);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case 'aws_lambda_function': {
|
|
166
|
+
const name = str(r.after.function_name);
|
|
167
|
+
const node = name && r.unknown.function_name !== true
|
|
168
|
+
? kit.node('service', `lambda/${name}`, { trustZone: 'internal' })
|
|
169
|
+
: kit.unresolved('service', `lambda/${address}`, 'the function name is only known after apply', { trustZone: 'unknown' });
|
|
170
|
+
const roleAddr = refTarget(address, 'role', 'aws_iam_role');
|
|
171
|
+
const ident = roleAddr ? roleNode(roleAddr) : (typeof r.after.role === 'string' ? roleByArn(r.after.role, address) : roleByArn(null, address));
|
|
172
|
+
kit.edge('assumes', node, ident, { confidence: roleAddr ? 'high' : 'low' });
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case 'aws_s3_bucket': {
|
|
176
|
+
const b = str(r.after.bucket);
|
|
177
|
+
if (b && r.unknown.bucket !== true) kit.node('resource', `s3/${b}`, { trustZone: r.after.acl === 'public-read' || r.after.acl === 'public-read-write' ? 'public' : 'internal', attrs: { acl: typeof r.after.acl === 'string' ? r.after.acl : 'unspecified' } });
|
|
178
|
+
else kit.unresolved('resource', `s3/${address}`, 'the bucket name is only known after apply', { trustZone: 'unknown' });
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case 'aws_security_group': {
|
|
182
|
+
const name = str(r.after.name) ?? address;
|
|
183
|
+
const sg = kit.node('resource', `sg/${name}`, { trustZone: 'internal' });
|
|
184
|
+
for (const rule of asArray(r.after.ingress)) {
|
|
185
|
+
const cidrs = asArray(rule?.cidr_blocks).filter(c => typeof c === 'string');
|
|
186
|
+
const ports = `${rule?.protocol ?? '-1'}:${rule?.from_port ?? 0}-${rule?.to_port ?? 0}`;
|
|
187
|
+
for (const cidr of cidrs) {
|
|
188
|
+
const src = kit.node('route', `cidr/${cidr}`, { trustZone: cidr === '0.0.0.0/0' ? 'public' : 'edge', attrs: { cidr } });
|
|
189
|
+
kit.edge('network-allows', src, sg, { confidence: 'high', discriminator: ports, attrs: { ports } });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
default: break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (unsupportedTypes.size) {
|
|
198
|
+
const list = [...unsupportedTypes].sort();
|
|
199
|
+
kit.gap('unsupported-format', fileCtx.file, `resource types not interpreted by this adapter: ${list.slice(0, 10).join(', ')}${list.length > 10 ? `, and ${list.length - 10} more` : ''}`);
|
|
200
|
+
}
|
|
201
|
+
return kit.result();
|
|
202
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// adapter-compose.js: container composition files into deployment boundary
|
|
2
|
+
// nodes and edges (X-302).
|
|
3
|
+
//
|
|
4
|
+
// A service is a node. A published port is an entry route into it, public
|
|
5
|
+
// unless it is bound to loopback. `depends_on` and `links` are declared
|
|
6
|
+
// dependencies: they are recorded as `depends-on`, never as `calls`, because a
|
|
7
|
+
// dependency is a startup ordering or a reachability hint and says nothing
|
|
8
|
+
// about a call actually being made. Environment variables, env files, secrets
|
|
9
|
+
// and build arguments are never read, so a credential in a compose file cannot
|
|
10
|
+
// reach the graph.
|
|
11
|
+
|
|
12
|
+
import { adapterContext, parseYamlDocuments, isObject, asArray, str } from './adapter-kit.js';
|
|
13
|
+
|
|
14
|
+
export const COMPOSE_PARSER = 'compose-file';
|
|
15
|
+
export const COMPOSE_PARSER_VERSION = '1';
|
|
16
|
+
|
|
17
|
+
function parsePort(p) {
|
|
18
|
+
if (typeof p === 'number') return { published: String(p), target: String(p), hostIp: null };
|
|
19
|
+
if (typeof p === 'string') {
|
|
20
|
+
let t = p.trim().replace(/\/(tcp|udp|sctp)$/i, '');
|
|
21
|
+
let hostIp = null;
|
|
22
|
+
const v6 = /^\[([^\]]+)\]:(.*)$/.exec(t);
|
|
23
|
+
if (v6) { hostIp = `[${v6[1]}]`; t = v6[2]; }
|
|
24
|
+
const parts = t.split(':');
|
|
25
|
+
const port = /^\d+(-\d+)?$/;
|
|
26
|
+
if (parts.some(x => !port.test(x) && !(parts.length === 3 && x === parts[0] && /^[0-9.]+$/.test(x)))) return null;
|
|
27
|
+
if (parts.length === 1) return { published: null, target: parts[0], hostIp };
|
|
28
|
+
if (parts.length === 2) return { published: parts[0], target: parts[1], hostIp };
|
|
29
|
+
if (parts.length === 3 && !hostIp) return { published: parts[1], target: parts[2], hostIp: parts[0] };
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (isObject(p)) {
|
|
33
|
+
const target = p.target != null ? String(p.target) : null;
|
|
34
|
+
if (!target) return null;
|
|
35
|
+
return { published: p.published != null ? String(p.published) : null, target, hostIp: str(p.host_ip) };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseCompose(text, fileCtx) {
|
|
41
|
+
const kit = adapterContext({ ...fileCtx, parser: COMPOSE_PARSER, parserVersion: COMPOSE_PARSER_VERSION });
|
|
42
|
+
const parsed = parseYamlDocuments(text, fileCtx.file);
|
|
43
|
+
if (parsed.gap) { kit.gaps.push(parsed.gap); return kit.result(); }
|
|
44
|
+
const doc = parsed.docs[0];
|
|
45
|
+
if (!isObject(doc) || !isObject(doc.services)) {
|
|
46
|
+
kit.gap('unsupported-format', fileCtx.file, 'no top-level services mapping; not a compose file');
|
|
47
|
+
return kit.result();
|
|
48
|
+
}
|
|
49
|
+
const declared = new Set(Object.keys(doc.services));
|
|
50
|
+
const nodes = new Map();
|
|
51
|
+
const nodeFor = (name) => nodes.get(name);
|
|
52
|
+
const networks = isObject(doc.networks) ? doc.networks : {};
|
|
53
|
+
|
|
54
|
+
for (const [name, svc] of Object.entries(doc.services)) {
|
|
55
|
+
if (!isObject(svc)) { kit.gap('malformed-input', name, 'a service that is not a mapping is skipped'); continue; }
|
|
56
|
+
if (Object.hasOwn(svc, '<<')) kit.gap('unsupported-syntax', name, "YAML merge key '<<' is not expanded; fields inherited through it are not read");
|
|
57
|
+
const tenant = isObject(svc.labels) && typeof svc.labels.tenant === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(svc.labels.tenant) ? svc.labels.tenant : null;
|
|
58
|
+
const attrs = {};
|
|
59
|
+
const nets = (Array.isArray(svc.networks) ? svc.networks : isObject(svc.networks) ? Object.keys(svc.networks) : []).filter(n => typeof n === 'string').sort();
|
|
60
|
+
if (nets.length) attrs.networks = nets.join(',');
|
|
61
|
+
if (nets.length && nets.every(n => isObject(networks[n]) && networks[n].internal === true)) attrs.networkInternal = true;
|
|
62
|
+
if (svc.network_mode === 'host') attrs.networkMode = 'host';
|
|
63
|
+
nodes.set(name, kit.node('service', name, { tenant, trustZone: 'internal', attrs }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const [name, svc] of Object.entries(doc.services)) {
|
|
67
|
+
const node = nodeFor(name);
|
|
68
|
+
if (!node) continue;
|
|
69
|
+
for (const raw of asArray(svc.ports)) {
|
|
70
|
+
const port = parsePort(raw);
|
|
71
|
+
if (!port) { kit.gap('malformed-input', name, 'a port mapping that cannot be read is skipped'); continue; }
|
|
72
|
+
if (port.published === null) continue; // container port only, not published to the host
|
|
73
|
+
const loop = port.hostIp !== null && (/^127\./.test(port.hostIp) || port.hostIp === '[::1]');
|
|
74
|
+
const route = kit.node('route', `port/${name}:${port.published}`, { tenant: node.tenant, trustZone: loop ? 'internal' : 'public', attrs: { published: port.published, target: port.target, bind: port.hostIp ?? 'all-interfaces' } });
|
|
75
|
+
kit.edge('routes-to', route, node, { confidence: 'high' });
|
|
76
|
+
}
|
|
77
|
+
const deps = Array.isArray(svc.depends_on) ? svc.depends_on : isObject(svc.depends_on) ? Object.keys(svc.depends_on) : [];
|
|
78
|
+
const links = [...asArray(svc.links), ...asArray(svc.external_links)].map(l => (typeof l === 'string' ? l.split(':')[0] : null));
|
|
79
|
+
for (const d of [...deps, ...links]) {
|
|
80
|
+
if (typeof d !== 'string' || !d) continue;
|
|
81
|
+
if (declared.has(d) && nodeFor(d)) kit.edge('depends-on', node, nodeFor(d), { confidence: 'medium' });
|
|
82
|
+
else {
|
|
83
|
+
const miss = kit.unresolved('service', d, 'the dependency is not defined in this compose file', { trustZone: 'unknown' });
|
|
84
|
+
kit.edge('depends-on', node, miss, { confidence: 'low' });
|
|
85
|
+
kit.gap('unresolved-identity', name, `depends on '${d}', which this compose file does not define`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return kit.result();
|
|
90
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// adapter-kit.js: shared plumbing for the deployment configuration adapters
|
|
2
|
+
// (X-302). Every adapter is a pure function from text to `{ nodes, edges, gaps }`
|
|
3
|
+
// built through one context, so that three things hold for every adapter
|
|
4
|
+
// without each one remembering them:
|
|
5
|
+
//
|
|
6
|
+
// - every edge a static adapter emits is `static-config` provenance and
|
|
7
|
+
// carries the exact file reference, digest, parser name and parser version
|
|
8
|
+
// it came from (X-302.AC03);
|
|
9
|
+
// - environment, tenant and owning repository are stamped uniformly, so two
|
|
10
|
+
// environments never share a node;
|
|
11
|
+
// - a reference that cannot be resolved becomes an unresolved node plus a gap,
|
|
12
|
+
// never a guessed edge to a resolved one.
|
|
13
|
+
//
|
|
14
|
+
// Nothing here evaluates configuration. YAML is read with the scanner's safe
|
|
15
|
+
// loader (unknown tags are refused), JSON with JSON.parse, and policy strings
|
|
16
|
+
// are JSON.parse'd, never evaluated.
|
|
17
|
+
|
|
18
|
+
import { load as loadYaml } from '../../util/yaml.js';
|
|
19
|
+
import { nodeIdOf } from './boundary-graph.js';
|
|
20
|
+
|
|
21
|
+
const ANCHOR_LIMIT = 100;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse one YAML document, or several separated by `---`. Never throws.
|
|
25
|
+
* Returns `{ docs }` or `{ gap }` where the gap is a typed unsupported or
|
|
26
|
+
* malformed result. YAML merge keys are not expanded by the loader, so a
|
|
27
|
+
* document that uses `<<` is reported by the adapter that needs the key.
|
|
28
|
+
*/
|
|
29
|
+
export function parseYamlDocuments(text, file) {
|
|
30
|
+
const aliasCount = (text.match(/(^|[\s\[{,:-])\*[A-Za-z0-9_-]+/g) || []).length;
|
|
31
|
+
if (aliasCount > ANCHOR_LIMIT) {
|
|
32
|
+
return { gap: { code: 'unsupported-syntax', subject: file, file, message: `YAML uses ${aliasCount} aliases, over the limit of ${ANCHOR_LIMIT}; the file is not read` } };
|
|
33
|
+
}
|
|
34
|
+
const chunks = text.split(/^---[ \t]*$/m);
|
|
35
|
+
const docs = [];
|
|
36
|
+
for (const chunk of chunks) {
|
|
37
|
+
let doc;
|
|
38
|
+
try { doc = loadYaml(chunk); } catch (e) {
|
|
39
|
+
return { gap: { code: 'malformed-input', subject: file, file, message: `not valid YAML (${String(e.message).split('\n')[0]})` } };
|
|
40
|
+
}
|
|
41
|
+
if (doc !== undefined && doc !== null) docs.push(doc);
|
|
42
|
+
}
|
|
43
|
+
return { docs };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
|
|
47
|
+
export function asArray(v) { return Array.isArray(v) ? v : []; }
|
|
48
|
+
export function str(v) { return typeof v === 'string' && v.length > 0 ? v : null; }
|
|
49
|
+
|
|
50
|
+
/** True when every entry of `selector` is present with the same value in `labels`. An empty selector selects nothing. */
|
|
51
|
+
export function selectorMatches(selector, labels) {
|
|
52
|
+
if (!isObject(selector) || !isObject(labels)) return false;
|
|
53
|
+
const entries = Object.entries(selector);
|
|
54
|
+
if (entries.length === 0) return false;
|
|
55
|
+
return entries.every(([k, v]) => Object.hasOwn(labels, k) && String(labels[k]) === String(v));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} p
|
|
60
|
+
* @param {string} p.file path as supplied, relative to the ingest root
|
|
61
|
+
* @param {string} p.digest sha256 of the text that was parsed
|
|
62
|
+
* @param {string} p.environment deployment environment these files describe
|
|
63
|
+
* @param {string|null} p.repository
|
|
64
|
+
* @param {string|null} p.revision exact commit, when known
|
|
65
|
+
* @param {string} p.parser
|
|
66
|
+
* @param {string} p.parserVersion
|
|
67
|
+
*/
|
|
68
|
+
export function adapterContext({ file, digest, environment, repository, revision, parser, parserVersion }) {
|
|
69
|
+
const nodes = [];
|
|
70
|
+
const edges = [];
|
|
71
|
+
const gaps = [];
|
|
72
|
+
const ref = { file, digest };
|
|
73
|
+
const source = { file, digest, parser, parserVersion };
|
|
74
|
+
|
|
75
|
+
const kit = {
|
|
76
|
+
nodes, edges, gaps, environment, repository, file,
|
|
77
|
+
|
|
78
|
+
/** Declare a node this file defines. Adds the structural environment, repository and tenant edges. */
|
|
79
|
+
node(kind, name, o = {}) {
|
|
80
|
+
const tenant = o.tenant ?? null;
|
|
81
|
+
const n = {
|
|
82
|
+
kind, name, environment, tenant, repository: o.resolved === false ? null : repository,
|
|
83
|
+
trustZone: o.trustZone ?? 'unknown', resolved: o.resolved !== false, unresolvedReason: o.unresolvedReason ?? null,
|
|
84
|
+
attrs: o.attrs ?? {}, declaredBy: o.resolved === false ? [] : [ref],
|
|
85
|
+
};
|
|
86
|
+
nodes.push(n);
|
|
87
|
+
if (o.resolved !== false) {
|
|
88
|
+
const env = { kind: 'environment', name: environment, environment, tenant: null, repository: null, trustZone: 'unknown', resolved: true, attrs: {}, declaredBy: [ref] };
|
|
89
|
+
nodes.push(env);
|
|
90
|
+
kit.structural('deployed-in', n, env);
|
|
91
|
+
if (repository) {
|
|
92
|
+
const repo = { kind: 'repository', name: repository, environment: null, tenant: null, repository, trustZone: 'unknown', resolved: true, attrs: {}, declaredBy: [ref] };
|
|
93
|
+
nodes.push(repo);
|
|
94
|
+
kit.structural('defined-in', n, repo);
|
|
95
|
+
}
|
|
96
|
+
if (tenant) {
|
|
97
|
+
const t = { kind: 'tenant', name: tenant, environment, tenant, repository: null, trustZone: 'unknown', resolved: true, attrs: {}, declaredBy: [ref] };
|
|
98
|
+
nodes.push(t);
|
|
99
|
+
kit.structural('scoped-to', n, t);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return n;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/** A reference this file makes to something it does not define and cannot resolve. */
|
|
106
|
+
unresolved(kind, name, reason, o = {}) {
|
|
107
|
+
return kit.node(kind, name, { ...o, resolved: false, unresolvedReason: reason });
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
structural(relation, from, to) {
|
|
111
|
+
edges.push(kit.baseEdge(relation, from, to, { effect: 'none', confidence: 'high' }));
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
baseEdge(relation, from, to, o = {}) {
|
|
115
|
+
return {
|
|
116
|
+
relation, from: idOf(from), to: idOf(to), environment: from.environment ?? to.environment ?? null, tenant: o.tenant ?? null,
|
|
117
|
+
effect: o.effect ?? 'none', provenance: 'static-config', confidence: o.confidence ?? 'medium',
|
|
118
|
+
pathState: o.effect === 'deny' ? 'blocked' : (o.pathState ?? 'possible'),
|
|
119
|
+
observationInterval: null, completeness: null, sourceRevision: revision ?? null, source,
|
|
120
|
+
discriminator: o.discriminator ?? null, crossRepo: null, attrs: o.attrs ?? {},
|
|
121
|
+
_fromNode: from, _toNode: to,
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/** Add a relation between two nodes from this file. */
|
|
126
|
+
edge(relation, from, to, o = {}) {
|
|
127
|
+
edges.push(kit.baseEdge(relation, from, to, o));
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
gap(code, subject, message) { gaps.push({ code, subject, file, message }); },
|
|
131
|
+
|
|
132
|
+
result() {
|
|
133
|
+
// Drop the private endpoint references now that the ids are computed.
|
|
134
|
+
return { nodes, edges: edges.map(({ _fromNode, _toNode, ...e }) => e), gaps };
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
return kit;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function idOf(node) {
|
|
141
|
+
return nodeIdOf({ kind: node.kind, name: node.name, environment: node.kind === 'environment' ? node.name : (node.environment ?? null), tenant: node.kind === 'tenant' ? node.name : (node.tenant ?? null) });
|
|
142
|
+
}
|