@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 +390 -0
- package/bin/agentic-security.js +3813 -83
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +13 -4
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/2271.index.js +165 -0
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +14 -6
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +14 -6
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +67 -3
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +26 -8
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +580 -187
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/src/util/git-hardening.js +128 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
//
|
|
2
|
+
// coverage.js — Sub-project E, increment 4 (E4).
|
|
3
|
+
//
|
|
4
|
+
// Implements DESIGN_GRAPH_BUILDER.md §9.4/§10 exactly: finishes the coverage
|
|
5
|
+
// ledger E1/E3 shipped only as a sketch (`graph-builder.js`'s own inline
|
|
6
|
+
// `graph.coverage = {...}` block — still present, still the DEFAULT when a
|
|
7
|
+
// caller uses `buildDataFlowGraph` directly; this module supersedes it only
|
|
8
|
+
// for a caller that opts in via `buildGraphWithCoverage`), and closes FR-203
|
|
9
|
+
// (a recognized sink whose destination expression could not be statically
|
|
10
|
+
// resolved — `fetch(url)` with a computed `url`; an SDK client built from
|
|
11
|
+
// config).
|
|
12
|
+
//
|
|
13
|
+
// §9.4 item 3 ("ship §5's enumerator union as a real module function") is
|
|
14
|
+
// ALREADY DONE — `graph-builder.js` exports `degradedTerminals(store)` as a
|
|
15
|
+
// real, tested function (E3). This module reads its result via `built.degraded`,
|
|
16
|
+
// it does not reimplement it.
|
|
17
|
+
//
|
|
18
|
+
// §9.4 item 5's hook-vs-post-processing-pass decision: an `opts` hook on
|
|
19
|
+
// `buildDataFlowGraph` (`resolveSiteDecision`, shipped in Task 1), not a
|
|
20
|
+
// post-processing pass over the built graph — see graph-builder.js's own
|
|
21
|
+
// header for why a post-processing pass was rejected (FR-203 changes a
|
|
22
|
+
// node's identity discriminator, so "adjust after minting" means re-minting).
|
|
23
|
+
//
|
|
24
|
+
// Reuse boundary: imports ONLY `reclassifySink` from `./sink-registry.js`,
|
|
25
|
+
// `DEFAULTS` from `./path-query.js`, and `buildDataFlowGraph` from
|
|
26
|
+
// `./graph-builder.js` — all three already-shipped `src/lineage/` modules.
|
|
27
|
+
// Never `dataflow/engine.js`, never `dataflow/summaries.js`.
|
|
28
|
+
//
|
|
29
|
+
// Disclosure: FR-203's own `external-api` headline example ("fetch(url)
|
|
30
|
+
// with a computed url") is currently unreachable on this project's real
|
|
31
|
+
// fixture through the privacy-catalog guard below — `vulnerable-js`'s only
|
|
32
|
+
// `external-api` sites are `privacy-js-axios-post`, and
|
|
33
|
+
// `reclassifyPrivacySink` has no `opts.destinationUnresolved` parameter for
|
|
34
|
+
// `resolveSiteDecision` to invoke (a disclosed, deliberate asymmetry — see
|
|
35
|
+
// `sink-registry.js`'s own header). Correct and deliberate; recorded here
|
|
36
|
+
// so the gap is visible rather than silently true of the only real fixture
|
|
37
|
+
// in this tree today. FR-203 DOES fire on `vulnerable-js` via a different
|
|
38
|
+
// category — `file`/`js-fs-readFile` (a general, CWE-keyed catalog entry) —
|
|
39
|
+
// so `unresolvedDestinations` is genuinely nonzero on that fixture; only
|
|
40
|
+
// the `external-api`/`fetch`-shaped case is unreachable there.
|
|
41
|
+
//
|
|
42
|
+
// Disclosure: the RECEIVER signal (a non-plain-ident callee object) fires
|
|
43
|
+
// on any fixed-but-nontrivial property chain, not just a genuinely dynamic
|
|
44
|
+
// expression — `this.db.query(q)` and `ctx.services.db.query(q)` both fire,
|
|
45
|
+
// even though both are perfectly static, readable paths; only a bare
|
|
46
|
+
// single-identifier receiver (`pool.query(q)`) reads as "resolved". This is
|
|
47
|
+
// the same shape MUST-FIX 1 closed on the arg0 side, one axis over: the
|
|
48
|
+
// plain-ident/member discriminator approximates static resolvability, it
|
|
49
|
+
// doesn't decide it. Defensible (this module resolves nothing, so
|
|
50
|
+
// "unresolved" is an honest label for anything it can't itself read back
|
|
51
|
+
// out as a flat name) but will inflate `unresolvedDestinations` on any
|
|
52
|
+
// repository using an ORM/repository pattern with `this.`/nested-service
|
|
53
|
+
// receivers. Not narrowed in this increment — narrowing it to genuinely
|
|
54
|
+
// computed receivers (a `call` or computed-member object) is the natural
|
|
55
|
+
// follow-up if this proves noisy in practice.
|
|
56
|
+
|
|
57
|
+
import { reclassifySink } from './sink-registry.js';
|
|
58
|
+
import { DEFAULTS as PATH_QUERY_DEFAULTS } from './path-query.js';
|
|
59
|
+
import { buildDataFlowGraph } from './graph-builder.js';
|
|
60
|
+
// Milestone 2, Sub-project A, increment 1: `resolveDestination` composes
|
|
61
|
+
// with `resolveSiteDecision` below the same way it's imported here — a real
|
|
62
|
+
// ES module cycle (resolve-destination.js imports THREE things back from
|
|
63
|
+
// this file). See that module's own header for why this is safe. Kept as
|
|
64
|
+
// its own import line, not folded into the block above, so the cycle is
|
|
65
|
+
// visible to a reader at a glance.
|
|
66
|
+
import { resolveDestination } from './resolve-destination.js';
|
|
67
|
+
// Milestone 2, Sub-project B, increment 2: `resolveTransitProtectionForSite`
|
|
68
|
+
// composes with `opts.resolveTransitProtection` the same way `resolveDestination`
|
|
69
|
+
// composes above — see `buildGraphWithCoverage`'s own wiring below. No ES
|
|
70
|
+
// module cycle here (unlike `resolve-destination.js`) — `transit-protection.js`
|
|
71
|
+
// never imports anything back from this file.
|
|
72
|
+
import { resolveTransitProtectionForSite } from './transit-protection.js';
|
|
73
|
+
// Deliverable #10 (DFG-020, graph-derived DPIA/RoPA migration): reused
|
|
74
|
+
// UNMODIFIED, per the plan's own Global Constraints — this module never
|
|
75
|
+
// re-implements governance-field lookup, only composes the default hook
|
|
76
|
+
// `graph-builder.js`'s `opts.resolveGovernanceRefs` expects.
|
|
77
|
+
import { governanceRecordFor, GOVERNANCE_FIELDS } from '../dataflow/privacy-governance.js';
|
|
78
|
+
// Milestone 4, FR-506 (Third-Party and Cross-Border Intelligence):
|
|
79
|
+
// `buildRecipientProfile` composes with `opts.buildRecipientProfile` the
|
|
80
|
+
// same way `resolveDestination`/`resolveTransitProtectionForSite` compose
|
|
81
|
+
// above — see `buildGraphWithCoverage`'s own wiring below. No ES module
|
|
82
|
+
// cycle here — `recipient-registry.js` never imports anything back from
|
|
83
|
+
// this file.
|
|
84
|
+
import { buildRecipientProfile } from './recipient-registry.js';
|
|
85
|
+
// Milestone 5, language coverage-tier disclosure: `coverageTierForLanguage`
|
|
86
|
+
// composes into `buildCoverageLedger`'s `languages` computation below —
|
|
87
|
+
// pure, static data, no cycle risk (mirrors how every other sibling import
|
|
88
|
+
// above composes into this file's own hooks/computations).
|
|
89
|
+
import { coverageTierForLanguage } from './language-coverage-tiers.js';
|
|
90
|
+
// M5 deliverable #7 (FR-505/AC-29, Runtime-Corroborated Digital Twin, "7b"):
|
|
91
|
+
// `correlateObservations` composes into `opts.correlateObservations` the
|
|
92
|
+
// same way every other sibling default hook in this file composes — see
|
|
93
|
+
// `buildGraphWithCoverage`'s own wiring below. No ES module cycle here —
|
|
94
|
+
// `observation-correlation.js` never imports anything back from this file.
|
|
95
|
+
import { correlateObservations } from './observation-correlation.js';
|
|
96
|
+
|
|
97
|
+
// =========================================================================
|
|
98
|
+
// FR-203 — the destination-unresolved heuristic.
|
|
99
|
+
//
|
|
100
|
+
// No catalog entry carries per-call-site destination information
|
|
101
|
+
// (DESIGN_REGISTRIES.md §7.5), so this heuristic works from the two shapes
|
|
102
|
+
// a real call site actually offers: the CALLEE (is the receiver a plain
|
|
103
|
+
// bound identifier, or a computed expression — "an SDK client built from
|
|
104
|
+
// config"?) and the first ARGUMENT (is it a literal, or a computed
|
|
105
|
+
// expression — "fetch(url) with a computed url"?). Either one firing is
|
|
106
|
+
// enough; the two are deliberately not required to agree, since a call
|
|
107
|
+
// site can be unresolvable via only one of the two shapes.
|
|
108
|
+
// =========================================================================
|
|
109
|
+
|
|
110
|
+
function isPlainIdent(e) {
|
|
111
|
+
return Boolean(e) && typeof e === 'object' && e.kind === 'ident' && typeof e.name === 'string';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Renders an IR expression node as a short, human-readable string for a
|
|
115
|
+
* FR-203 `reason` — never throws, never returns an empty string. */
|
|
116
|
+
export function renderExpr(e, depth = 0) {
|
|
117
|
+
if (depth > 4 || e == null) return '<computed expression>';
|
|
118
|
+
if (typeof e === 'string') return e;
|
|
119
|
+
if (typeof e !== 'object') return '<computed expression>';
|
|
120
|
+
if (e.kind === 'ident' && typeof e.name === 'string') return e.name;
|
|
121
|
+
if (e.kind === 'literal') return JSON.stringify(e.value);
|
|
122
|
+
if (e.kind === 'member' && typeof e.prop === 'string') return `${renderExpr(e.object, depth + 1)}.${e.prop}`;
|
|
123
|
+
return '<computed expression>';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* §9.4 item 2's heuristic. `site` is one entry from `enumerateSinkSites`'s
|
|
128
|
+
* `sites[]` (post Task 1: carries `.calleeExpr` and `.args`). Returns
|
|
129
|
+
* `null` when nothing here looks unresolvable — never a guess in the
|
|
130
|
+
* unresolved direction. The result names WHICH signal fired (`via`):
|
|
131
|
+
* `'receiver'` (a computed callee receiver — "an SDK client built from
|
|
132
|
+
* config") or `'arg0'` (a non-literal first argument — "fetch(url) with a
|
|
133
|
+
* computed url"). `resolveSiteDecision` uses `via` to apply the narrower
|
|
134
|
+
* category gate the arg0 signal needs (see `FR203_ARG0_DESTINATION_CATEGORIES`
|
|
135
|
+
* below) — this function itself stays category-blind, since it has no
|
|
136
|
+
* access to the site's decision/category and is also exercised directly,
|
|
137
|
+
* without a category, by this module's own unit tests.
|
|
138
|
+
*/
|
|
139
|
+
export function detectUnresolvedDestination(site) {
|
|
140
|
+
const callee = site.calleeExpr;
|
|
141
|
+
if (callee && typeof callee === 'object' && callee.kind === 'member' && typeof callee.prop === 'string' && !isPlainIdent(callee.object)) {
|
|
142
|
+
return { blockingExpression: `${renderExpr(callee.object)}.${callee.prop}`, via: 'receiver' };
|
|
143
|
+
}
|
|
144
|
+
const arg0 = (site.args ?? [])[0];
|
|
145
|
+
if (arg0 && typeof arg0 === 'object' && arg0.kind !== 'literal') {
|
|
146
|
+
return { blockingExpression: renderExpr(arg0), via: 'arg0' };
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Categories whose node kind genuinely has a "destination" concept — a
|
|
152
|
+
// target system or location an argument/receiver could name. Excludes
|
|
153
|
+
// 'sink' (http-response/declared — the destination IS the call itself,
|
|
154
|
+
// always fixed) and 'log' (same reasoning). Mirrors sink-registry.js's own
|
|
155
|
+
// CATEGORY_NODE_KIND vocabulary; not re-derived, just filtered against.
|
|
156
|
+
// This is the RECEIVER signal's own eligibility set — the receiver is the
|
|
157
|
+
// destination handle in every one of these (an SDK client, a DB cursor, a
|
|
158
|
+
// queue channel), so the receiver check applies to all three.
|
|
159
|
+
//
|
|
160
|
+
// 'queue' is currently unreachable in practice: no `sink-registry.js`
|
|
161
|
+
// `CWE_MAP` row maps to `queue` (only `PRIVACY_CATEGORY_MAP`'s `queues` row
|
|
162
|
+
// does, and privacy-catalog sites are excluded above by the `vuln.cwe ===
|
|
163
|
+
// undefined` guard) — kept for forward-compatibility with a future
|
|
164
|
+
// CWE_MAP row, not a bug.
|
|
165
|
+
const FR203_ELIGIBLE_KINDS = Object.freeze(['external', 'store', 'queue']);
|
|
166
|
+
|
|
167
|
+
// The ARGUMENT signal's own, NARROWER eligibility set (MUST-FIX 1). Unlike
|
|
168
|
+
// the receiver, the first argument only actually NAMES the destination for
|
|
169
|
+
// these three categories (`fetch(url)`, `fs.writeFile(path, data)`,
|
|
170
|
+
// `s3.putObject(key, body)`) — everywhere else in `FR203_ELIGIBLE_KINDS`
|
|
171
|
+
// (overwhelmingly `database`/`client-storage`), the first argument is the
|
|
172
|
+
// PAYLOAD being sent TO an already-resolved destination named by the
|
|
173
|
+
// receiver (`cursor.execute(sql)`, `document.write(html)`), so treating a
|
|
174
|
+
// non-literal payload as "destination unresolved" is a false positive —
|
|
175
|
+
// measured live: 54 of 86 FR-203-eligible catalog entries (63%) are
|
|
176
|
+
// `database`(48)/`client-storage`(6), where the payload argument is
|
|
177
|
+
// non-literal by construction, so the un-gated arg0 signal fired
|
|
178
|
+
// unconditionally there and carried no information.
|
|
179
|
+
// `object-storage` is currently unreachable via `CWE_MAP` today (like
|
|
180
|
+
// `queue` above) — kept for forward-compatibility with a future row that
|
|
181
|
+
// maps to it, not a bug.
|
|
182
|
+
export const FR203_ARG0_DESTINATION_CATEGORIES = Object.freeze(['external-api', 'file', 'object-storage']);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The exact shape `buildDataFlowGraph`'s `opts.resolveSiteDecision` hook
|
|
186
|
+
* expects: `(site) -> decision | undefined`. Composes with §4.3's
|
|
187
|
+
* multi-candidate ambiguity resolution rather than silently discarding it
|
|
188
|
+
* — see the Global Constraints note in this plan and the comment below.
|
|
189
|
+
*/
|
|
190
|
+
export function resolveSiteDecision(site) {
|
|
191
|
+
// Privacy-catalog entries are identified by carrying their own `category`
|
|
192
|
+
// field — the literal field `reclassifyPrivacySink` keys on, and one no
|
|
193
|
+
// general CATALOG sink entry ever has (sink-registry.js's own `D1/8b`
|
|
194
|
+
// pins this for the general side; independently re-confirmed live: 0 of
|
|
195
|
+
// 194 general sink entries carry `category`). `reclassifySink`'s `opts`
|
|
196
|
+
// parameter is specified only for the general (CWE-keyed) catalog
|
|
197
|
+
// (sink-registry.js's own disclosed asymmetry) — never applied here.
|
|
198
|
+
//
|
|
199
|
+
// CORRECTED (hotfix, 2026-08-31): the original guard checked
|
|
200
|
+
// `site.entry?.vuln?.cwe === undefined`, on the assumption that
|
|
201
|
+
// privacy-catalog entries carry no `vuln.cwe`. Measured, live: ALL 18 of
|
|
202
|
+
// 18 PRIVACY_SINK_CATALOG entries carry `vuln.cwe: 'CWE-359'` — the
|
|
203
|
+
// guard's premise was false for every single entry, so it excluded
|
|
204
|
+
// nothing. Since `'CWE-359'` has no `CWE_MAP` row, every privacy-catalog
|
|
205
|
+
// site that also triggered FR-203's heuristic silently fell through
|
|
206
|
+
// `reclassifySink`'s "unreachable from live data" fallback branch,
|
|
207
|
+
// corrupting a real store/external/queue-kind node into
|
|
208
|
+
// process/null/unsupported. Found and root-caused during Sub-project F's
|
|
209
|
+
// own corpus-authoring review; see
|
|
210
|
+
// docs/superpowers/plans/2026-08-31-lineage-coverage-privacy-catalog-fr203-hotfix.md.
|
|
211
|
+
if (typeof site.entry?.category === 'string') return undefined;
|
|
212
|
+
// Defensive: `resolveSiteDecision` is exported as a hook contract (passed
|
|
213
|
+
// straight into `buildDataFlowGraph`'s `opts.resolveSiteDecision`), so it
|
|
214
|
+
// should be as defensive against a malformed site as
|
|
215
|
+
// `detectUnresolvedDestination`/`renderExpr` already are — a site with no
|
|
216
|
+
// `.decision` at all must not throw here.
|
|
217
|
+
if (!site.decision) return undefined;
|
|
218
|
+
// A null-category (unsupported/process) decision has no category to
|
|
219
|
+
// retain — reclassifySink's own guard already refuses this combination;
|
|
220
|
+
// checking it here too avoids computing a heuristic result that would
|
|
221
|
+
// just be thrown away.
|
|
222
|
+
if (site.decision.category === null) return undefined;
|
|
223
|
+
if (!FR203_ELIGIBLE_KINDS.includes(site.decision.kind)) return undefined;
|
|
224
|
+
|
|
225
|
+
const unresolved = detectUnresolvedDestination(site);
|
|
226
|
+
if (!unresolved) return undefined;
|
|
227
|
+
// MUST-FIX 1: the arg0 signal only actually names a destination for the
|
|
228
|
+
// narrower FR203_ARG0_DESTINATION_CATEGORIES set — see that constant's
|
|
229
|
+
// own comment. The receiver signal has no such extra gate (it's eligible
|
|
230
|
+
// for everything FR203_ELIGIBLE_KINDS already allowed above).
|
|
231
|
+
if (unresolved.via === 'arg0' && !FR203_ARG0_DESTINATION_CATEGORIES.includes(site.decision.category)) return undefined;
|
|
232
|
+
|
|
233
|
+
const fr203 = reclassifySink(site.entry, {
|
|
234
|
+
destinationUnresolved: true,
|
|
235
|
+
blockingExpression: unresolved.blockingExpression,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// §4.3's plurality resolution already demoted `site.decision.coverageStatus`
|
|
239
|
+
// to 'partial' and appended an "AMBIGUOUS..." reason, entirely at the SITE
|
|
240
|
+
// level — `site.entry` is only the winning candidate's raw entry, so a
|
|
241
|
+
// bare `reclassifySink(site.entry, opts)` call knows nothing about that
|
|
242
|
+
// demotion and would silently produce `fr203.coverageStatus` from
|
|
243
|
+
// CWE_MAP fresh, discarding it. Carry the site-level adjustment forward
|
|
244
|
+
// when it happened; otherwise `fr203`'s own values already agree with
|
|
245
|
+
// `site.decision`'s (nothing to carry).
|
|
246
|
+
const ambiguityAdjusted = site.ambiguity?.resolvedBy === 'plurality';
|
|
247
|
+
return {
|
|
248
|
+
...fr203,
|
|
249
|
+
coverageStatus: ambiguityAdjusted ? site.decision.coverageStatus : fr203.coverageStatus,
|
|
250
|
+
reason: ambiguityAdjusted ? `${fr203.reason} (site: ${site.decision.reason})` : fr203.reason,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// =========================================================================
|
|
255
|
+
// The coverage ledger (§10).
|
|
256
|
+
// =========================================================================
|
|
257
|
+
|
|
258
|
+
// Worst-wins precedence when a category's sites/seeds carry more than one
|
|
259
|
+
// coverageStatus — mirrors protection.js's aggregateVerdicts() and
|
|
260
|
+
// flow-grade.js's _PRECEDENCE risk-precedence-reduction convention, the
|
|
261
|
+
// established pattern in this package for "one summary value from several
|
|
262
|
+
// individually-graded inputs, worst wins".
|
|
263
|
+
const STATUS_PRECEDENCE = Object.freeze(['unsupported', 'candidate', 'partial', 'modeled']);
|
|
264
|
+
function worstStatus(a, b) {
|
|
265
|
+
const ia = STATUS_PRECEDENCE.indexOf(a);
|
|
266
|
+
const ib = STATUS_PRECEDENCE.indexOf(b);
|
|
267
|
+
if (ia === -1) return b;
|
|
268
|
+
if (ib === -1) return a;
|
|
269
|
+
return ia <= ib ? a : b;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function byCategorySorted(build) {
|
|
273
|
+
const out = {};
|
|
274
|
+
for (const key of [...build.keys()].sort()) out[key] = build.get(key);
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** §10's `sources.byCategory` — from `built.seeds` (already category- and
|
|
279
|
+
* coverageStatus-tagged by source-registry.js's `reclassifySource`, per
|
|
280
|
+
* `source-seeding.js`'s own seed shape). Null-category seeds are excluded
|
|
281
|
+
* — the source registry's own tests already prove every source category
|
|
282
|
+
* is non-null (source-registry.js has no `unsupported` tier, D2's own
|
|
283
|
+
* measured 84/14/82/0 split), so this is a defensive exclusion, not a
|
|
284
|
+
* documented real case. */
|
|
285
|
+
function sourcesByCategory(seeds) {
|
|
286
|
+
const m = new Map();
|
|
287
|
+
for (const s of seeds) {
|
|
288
|
+
if (s.category == null) continue;
|
|
289
|
+
if (!m.has(s.category)) m.set(s.category, { sites: 0, coverageStatus: s.coverageStatus });
|
|
290
|
+
const e = m.get(s.category);
|
|
291
|
+
e.sites += 1;
|
|
292
|
+
e.coverageStatus = worstStatus(e.coverageStatus, s.coverageStatus);
|
|
293
|
+
}
|
|
294
|
+
return byCategorySorted(m);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** §10's `sinks.byCategory` — from `built.sites` (post any
|
|
298
|
+
* `opts.resolveSiteDecision` override, and post Task 1's `site.connected`
|
|
299
|
+
* stamp). Null-category (process/unsupported) sites are excluded — their
|
|
300
|
+
* existence is already guaranteed visible via AC-11's coarse half (every
|
|
301
|
+
* discovered sink becomes a node, `kind: 'process'`), so a per-category
|
|
302
|
+
* breakdown for a category that by definition doesn't exist would be
|
|
303
|
+
* misleading, not informative. */
|
|
304
|
+
function sinksByCategory(sites) {
|
|
305
|
+
const m = new Map();
|
|
306
|
+
for (const s of sites) {
|
|
307
|
+
if (s.decision.category == null) continue;
|
|
308
|
+
if (!m.has(s.decision.category)) m.set(s.decision.category, { sites: 0, connected: 0, coverageStatus: s.decision.coverageStatus });
|
|
309
|
+
const e = m.get(s.decision.category);
|
|
310
|
+
e.sites += 1;
|
|
311
|
+
if (s.connected) e.connected += 1;
|
|
312
|
+
e.coverageStatus = worstStatus(e.coverageStatus, s.decision.coverageStatus);
|
|
313
|
+
}
|
|
314
|
+
return byCategorySorted(m);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Language dispatch — mirrors ir/index.js's own extension-based dispatch
|
|
318
|
+
// (never imported directly: that module has no exported "which language is
|
|
319
|
+
// this file" function, only its own internal parse dispatch, and importing
|
|
320
|
+
// ir/index.js here would cross a reuse boundary no other src/lineage/
|
|
321
|
+
// module crosses). A small, stable, independently-testable duplicate.
|
|
322
|
+
const LANGUAGE_EXT_PATTERNS = Object.freeze([
|
|
323
|
+
[/\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/i, 'js'],
|
|
324
|
+
[/\.py$/i, 'python'],
|
|
325
|
+
[/\.java$/i, 'java'],
|
|
326
|
+
[/\.cs$/i, 'csharp'],
|
|
327
|
+
[/\.kt$/i, 'kotlin'],
|
|
328
|
+
[/\.go$/i, 'go'],
|
|
329
|
+
[/\.(?:php|phtml)$/i, 'php'],
|
|
330
|
+
[/\.rb$/i, 'ruby'],
|
|
331
|
+
[/\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$/i, 'cpp'],
|
|
332
|
+
// Milestone 5, language coverage-tier disclosure: these 4 have ZERO
|
|
333
|
+
// lineage/taint wiring (tree-sitter-pattern-only, sast/tree-sitter-sinks.js
|
|
334
|
+
// only) — added here purely so a coverage-ledger `languages[]` entry can
|
|
335
|
+
// honestly attribute their files to a real language bucket, distinct from
|
|
336
|
+
// a genuinely unrecognized extension, rather than silently folding them
|
|
337
|
+
// into the shared 'unknown' fallback below.
|
|
338
|
+
[/\.rs$/i, 'rust'],
|
|
339
|
+
[/\.sol$/i, 'solidity'],
|
|
340
|
+
[/\.swift$/i, 'swift'],
|
|
341
|
+
[/\.dart$/i, 'dart'],
|
|
342
|
+
]);
|
|
343
|
+
function languageForFile(file) {
|
|
344
|
+
for (const [re, lang] of LANGUAGE_EXT_PATTERNS) if (re.test(file)) return lang;
|
|
345
|
+
return 'unknown';
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* §10's finished coverage-ledger contract. `built` is `buildDataFlowGraph`'s
|
|
350
|
+
* own return value (`{graph, store, hops, seeds, unseedable, sites,
|
|
351
|
+
* nonStatementSites, degraded, stats, decisionsByNodeId}`) — this function
|
|
352
|
+
* reads it, never rebuilds any of it.
|
|
353
|
+
*
|
|
354
|
+
* @param {object} built
|
|
355
|
+
* @param {object} [opts]
|
|
356
|
+
* @param {Record<string, object>} [opts.perFile] the same `{file: irRecord}`
|
|
357
|
+
* map `runScan`'s `_sharedIR.perFile` holds — used ONLY to count
|
|
358
|
+
* successfully-analyzed files per language. Optional: a caller with no
|
|
359
|
+
* file list (e.g. a unit test building a callGraph by hand) gets
|
|
360
|
+
* `languages: []`, honestly empty, never fabricated.
|
|
361
|
+
* @param {Array<{file: string, language?: string, message?: string}>} [opts.parseFailures]
|
|
362
|
+
* per-file parse-failure records. A `callGraph`-only builder cannot see
|
|
363
|
+
* these itself (a parse failure never reaches `callGraph` at all —
|
|
364
|
+
* DESIGN_GRAPH_BUILDER.md §9.4 item 5b's own note); a caller with the
|
|
365
|
+
* real file list (E5/`runScan`) supplies them. Optional, defaults to `[]`.
|
|
366
|
+
* NOTE for that future caller: `ir/index.js`'s own parse-failure tracking
|
|
367
|
+
* (`irParseFailures().byLanguage`) keys by raw extension (`'py'`, `'ts'`,
|
|
368
|
+
* `'java'`), not this module's normalized `languageForFile` vocabulary
|
|
369
|
+
* (`'python'`, `'js'`) — feed `irParseFailures()`'s entries through
|
|
370
|
+
* `languageForFile(file)` (or supply an explicit `language`) rather than
|
|
371
|
+
* its raw extension key, or `languages[]` will report split/duplicated
|
|
372
|
+
* buckets for the same language.
|
|
373
|
+
* @param {object} [opts.budget] the same budget object passed to
|
|
374
|
+
* `buildDataFlowGraph`'s own `opts.budget` — used only to report which
|
|
375
|
+
* values were ACTUALLY in effect (merged over path-query.js's DEFAULTS,
|
|
376
|
+
* the same way `reconstructPaths` itself merges them).
|
|
377
|
+
*/
|
|
378
|
+
export function buildCoverageLedger(built, opts = {}) {
|
|
379
|
+
const perFile = opts.perFile ?? {};
|
|
380
|
+
const parseFailures = (opts.parseFailures ?? []).map((f) => ({
|
|
381
|
+
file: f.file, language: f.language ?? languageForFile(f.file), message: f.message ?? null,
|
|
382
|
+
}));
|
|
383
|
+
|
|
384
|
+
const filesAnalyzedByLang = new Map();
|
|
385
|
+
for (const file of Object.keys(perFile)) {
|
|
386
|
+
const lang = languageForFile(file);
|
|
387
|
+
filesAnalyzedByLang.set(lang, (filesAnalyzedByLang.get(lang) ?? 0) + 1);
|
|
388
|
+
}
|
|
389
|
+
const filesFailedByLang = new Map();
|
|
390
|
+
for (const f of parseFailures) filesFailedByLang.set(f.language, (filesFailedByLang.get(f.language) ?? 0) + 1);
|
|
391
|
+
const allLangs = new Set([...filesAnalyzedByLang.keys(), ...filesFailedByLang.keys()]);
|
|
392
|
+
const languages = [...allLangs].sort().map((language) => {
|
|
393
|
+
const filesAnalyzed = filesAnalyzedByLang.get(language) ?? 0;
|
|
394
|
+
const base = { language, filesExpected: filesAnalyzed + (filesFailedByLang.get(language) ?? 0), filesAnalyzed };
|
|
395
|
+
// Milestone 5, language coverage-tier disclosure: coverageTierForLanguage
|
|
396
|
+
// never fabricates — a language with no curated entry (including the
|
|
397
|
+
// shared 'unknown' bucket for anything languageForFile can't recognize)
|
|
398
|
+
// gets tier: 'unknown' and no recall/measuredAt/source fields at all,
|
|
399
|
+
// never a guessed or zeroed-out number.
|
|
400
|
+
const tierEntry = coverageTierForLanguage(language);
|
|
401
|
+
if (!tierEntry) return { ...base, tier: 'unknown' };
|
|
402
|
+
const { tier, irTaintRecallPct, measuredAt, source } = tierEntry;
|
|
403
|
+
return irTaintRecallPct == null ? { ...base, tier } : { ...base, tier, irTaintRecallPct, measuredAt, source };
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const unresolvedDestinations = built.sites.filter((s) => s.decision.kind === 'unresolved').length;
|
|
407
|
+
const storeStats = built.store.stats();
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
languages, parseFailures,
|
|
411
|
+
destinationResolutionStatus: 'not-attempted', // FR-202 is Milestone 2 — unchanged from E3's sketch
|
|
412
|
+
pathBudgetTruncation: built.stats.truncatedQueries > 0,
|
|
413
|
+
|
|
414
|
+
sources: {
|
|
415
|
+
matched: built.seeds.length,
|
|
416
|
+
unseedable: built.unseedable.length,
|
|
417
|
+
dataElements: built.graph.dataElements.length,
|
|
418
|
+
byCategory: sourcesByCategory(built.seeds),
|
|
419
|
+
},
|
|
420
|
+
sinks: {
|
|
421
|
+
callStatementSites: built.sites.length,
|
|
422
|
+
connected: built.stats.connectedSinkSites,
|
|
423
|
+
disconnected: built.sites.length - built.stats.connectedSinkSites,
|
|
424
|
+
nonStatementSitesNotEnumerable: built.nonStatementSites.length,
|
|
425
|
+
// §10's own rule ("a node dropped and a node with no path must be
|
|
426
|
+
// distinguishable") applies to the reconciliation gap too: sites
|
|
427
|
+
// with a null category (kind:'process', AC-11's coarse half) are
|
|
428
|
+
// deliberately excluded from `byCategory` (see `sinksByCategory`'s
|
|
429
|
+
// own doc comment) but must not just vanish from the totals a reader
|
|
430
|
+
// sums — this count is the named residual, so
|
|
431
|
+
// `unsupportedSites + Σ byCategory[*].sites === callStatementSites`
|
|
432
|
+
// always holds.
|
|
433
|
+
unsupportedSites: built.sites.filter((s) => s.decision.category == null).length,
|
|
434
|
+
byCategory: sinksByCategory(built.sites),
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
degradedTerminals: built.degraded.length,
|
|
438
|
+
unresolvedDestinations,
|
|
439
|
+
|
|
440
|
+
paths: { enumerated: built.stats.pathsEnumerated, projected: built.stats.pathsProjected, truncatedQueries: built.stats.truncatedQueries },
|
|
441
|
+
budgets: { ...PATH_QUERY_DEFAULTS, ...(opts.budget ?? {}) },
|
|
442
|
+
|
|
443
|
+
provenance: { hops: built.hops.length, pnodes: storeStats.nodes, pedges: storeStats.edges },
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Convenience entry point: `buildDataFlowGraph` with FR-203 closed by
|
|
449
|
+
* default and the finished coverage ledger in place of E3's sketch.
|
|
450
|
+
* Returns the same shape `buildDataFlowGraph` returns — `built.graph.coverage`
|
|
451
|
+
* is the only field this function changes.
|
|
452
|
+
*
|
|
453
|
+
* @param {object} [opts.transitEvidenceByFile] Milestone 2, Sub-project B,
|
|
454
|
+
* increment 2: a pre-computed `Map<file, findings[]>` — the SAME `Map`
|
|
455
|
+
* `scanTransitEvidence` returns, computed exactly once by the caller
|
|
456
|
+
* (`index.js`'s `buildLineageGraph`), never raw `fileContents`. This
|
|
457
|
+
* function never calls `scanTransitEvidence`/`scanCryptoProtocol` itself
|
|
458
|
+
* — see `index.js`'s own header for why the computation must happen
|
|
459
|
+
* exactly once, in exactly one place.
|
|
460
|
+
* @param {object} [opts.privacySinkPolicy] Milestone 2, Sub-project G,
|
|
461
|
+
* increment 1: a pre-loaded `dataflow/privacy-sink-policy.js` policy
|
|
462
|
+
* object (`{allow: [...]}`), computed exactly once by the caller
|
|
463
|
+
* (`index.js`'s `buildLineageGraph`, mirroring `transitEvidenceByFile`'s
|
|
464
|
+
* own single-computation discipline). This function never reads the
|
|
465
|
+
* filesystem itself — it is spread straight through to
|
|
466
|
+
* `buildDataFlowGraph` via `...opts` below, unchanged, since this
|
|
467
|
+
* convenience wrapper has no default of its own to compose it with (only
|
|
468
|
+
* `resolveSiteDecision`/`resolveDestination`/`resolveTransitProtection`
|
|
469
|
+
* need one). `undefined` (never coerced to `{allow: []}` anywhere in this
|
|
470
|
+
* file) means "no policy evaluation was attempted" — see
|
|
471
|
+
* `graph-builder.js`'s own `policyLoaded` gate for why that distinction
|
|
472
|
+
* is load-bearing.
|
|
473
|
+
* @param {string} [opts.environment] optional deployment-environment
|
|
474
|
+
* override for policy evaluation's environment-scoped rules — also
|
|
475
|
+
* spread straight through, unchanged.
|
|
476
|
+
* @param {object} [opts.recipientConfig] Milestone 4, FR-506: a pre-loaded
|
|
477
|
+
* `recipient-registry.js`'s `loadRecipientConfig` return shape
|
|
478
|
+
* (`{recipients: {...}}`), computed exactly once by the caller
|
|
479
|
+
* (`index.js`'s `buildLineageGraph`, mirroring `transitEvidenceByFile`'s/
|
|
480
|
+
* `privacySinkPolicy`'s own single-computation discipline). This function
|
|
481
|
+
* never reads the filesystem itself — it only closes over this value for
|
|
482
|
+
* `opts.buildRecipientProfile`'s own default hook, below.
|
|
483
|
+
* @param {Array<object>} [opts.runtimeObservations] M5 deliverable #7
|
|
484
|
+
* (FR-505/AC-29): a PRE-LOADED `RuntimeObservation[]` array — never a
|
|
485
|
+
* path, the read happens once, upstream, in `index.js`'s
|
|
486
|
+
* `buildLineageGraph` (mirroring `opts.recipientConfig`'s own wording).
|
|
487
|
+
* `undefined` means "no observation store was consulted" (`index.js`'s
|
|
488
|
+
* own `existsSync` gate against `.agentic-security/runtime-observations/`
|
|
489
|
+
* never found the directory) and installs NO default
|
|
490
|
+
* `opts.correlateObservations` hook at all, so `graph.runtimeCorroboration`
|
|
491
|
+
* stays genuinely absent — the `not_evaluated` state. `[]` means "a store
|
|
492
|
+
* was consulted and is genuinely empty" and DOES install the default
|
|
493
|
+
* hook, producing a real correlation result whose every flow reads
|
|
494
|
+
* `not_observed_in_window` — the `opts.privacySinkPolicy`
|
|
495
|
+
* `undefined`-vs-`[]` precedent (`index.js:129-150`) applied to
|
|
496
|
+
* observations.
|
|
497
|
+
* @param {string} [opts.observationWindowStart] optional ISO-8601 lower
|
|
498
|
+
* bound for the correlation window, threaded verbatim into
|
|
499
|
+
* `correlateObservations`'s own `opts.windowStart`.
|
|
500
|
+
* @param {string} [opts.observationWindowEnd] optional ISO-8601 upper
|
|
501
|
+
* bound for the correlation window, threaded verbatim into
|
|
502
|
+
* `correlateObservations`'s own `opts.windowEnd`.
|
|
503
|
+
* @param {Array<object>} [opts.crossRepoLinkRecords] M5 deliverable #8
|
|
504
|
+
* (FR-304 "declared" half): a PRE-LOADED `CrossRepoLink[]` array —
|
|
505
|
+
* never a path, the read happens once, upstream, in `index.js`'s
|
|
506
|
+
* `buildLineageGraph` (mirroring `opts.recipientConfig`'s own
|
|
507
|
+
* wording). `undefined` means "no cross-repo-links.json was
|
|
508
|
+
* consulted" (`index.js`'s own `existsSync` gate never found the
|
|
509
|
+
* file) and installs NO default `opts.crossRepoLinks` hook at all —
|
|
510
|
+
* `graph.crossRepoLinks` still reads `[]` either way (the same
|
|
511
|
+
* visible OUTCOME as "consulted and genuinely empty"), unlike
|
|
512
|
+
* `opts.runtimeObservations`'s own `undefined`-vs-`[]` distinction,
|
|
513
|
+
* because a CrossRepoLink array carries no `not_evaluated` state to
|
|
514
|
+
* preserve the way runtime corroboration does (see `graph-builder.js`'s
|
|
515
|
+
* own comment on this hook for the full reasoning). The default hook,
|
|
516
|
+
* when installed, DROPS any record whose `local.nodeId` is not present
|
|
517
|
+
* in the graph's own current `nodes[]` (a stale declaration from
|
|
518
|
+
* before a rescan), reporting each dropped id via `console.error`
|
|
519
|
+
* rather than silently keeping it.
|
|
520
|
+
*/
|
|
521
|
+
export function buildGraphWithCoverage(callGraph, opts = {}) {
|
|
522
|
+
// NITPICK 4: compose with a caller-supplied `opts.resolveSiteDecision`
|
|
523
|
+
// rather than silently clobbering it — a caller's own hook always wins,
|
|
524
|
+
// matching `buildDataFlowGraph`'s own "the hook, when present, replaces
|
|
525
|
+
// `site.decision`" contract rather than this convenience wrapper quietly
|
|
526
|
+
// overriding that caller's choice.
|
|
527
|
+
// Milestone 2, Sub-project A, increment 1: identical composition pattern
|
|
528
|
+
// for `opts.resolveDestination` — a caller-supplied hook always wins.
|
|
529
|
+
// Milestone 2, Sub-project B, increment 2: identical composition pattern
|
|
530
|
+
// for `opts.resolveTransitProtection` — a caller-supplied hook always
|
|
531
|
+
// wins over this module's own default, which closes over
|
|
532
|
+
// `opts.transitEvidenceByFile` (honestly empty, via the `?? new Map()`
|
|
533
|
+
// fallback, when the caller supplies neither — never re-derived from
|
|
534
|
+
// `opts.fileContents`, which this function never reads).
|
|
535
|
+
const built = buildDataFlowGraph(callGraph, {
|
|
536
|
+
...opts,
|
|
537
|
+
resolveSiteDecision: opts.resolveSiteDecision ?? resolveSiteDecision,
|
|
538
|
+
resolveDestination: opts.resolveDestination ?? resolveDestination,
|
|
539
|
+
resolveTransitProtection: opts.resolveTransitProtection
|
|
540
|
+
?? ((site) => resolveTransitProtectionForSite(site, opts.transitEvidenceByFile ?? new Map())),
|
|
541
|
+
// Milestone 4, FR-506: identical composition pattern — a caller-supplied
|
|
542
|
+
// hook always wins over this module's own default, which closes over
|
|
543
|
+
// `opts.recipientConfig` (honestly `undefined`, via `buildRecipientProfile`'s
|
|
544
|
+
// own `opts.recipientConfig` fallback, when the caller supplies none —
|
|
545
|
+
// never re-derived from the filesystem here).
|
|
546
|
+
buildRecipientProfile: opts.buildRecipientProfile
|
|
547
|
+
?? ((site, graph) => buildRecipientProfile(site, graph, { recipientConfig: opts.recipientConfig })),
|
|
548
|
+
// Deliverable #10 (DFG-020): opts.resolveGovernanceRefs, composed the
|
|
549
|
+
// same way every sibling hook is — a caller-supplied hook always wins.
|
|
550
|
+
// The default closes over opts.privacyGovernanceConfig, a PRE-LOADED
|
|
551
|
+
// config object (mirroring opts.transitEvidenceByFile's own
|
|
552
|
+
// single-computation-per-buildLineageGraph-call discipline — never
|
|
553
|
+
// read the filesystem itself here).
|
|
554
|
+
resolveGovernanceRefs: opts.resolveGovernanceRefs
|
|
555
|
+
?? ((dataClasses) => {
|
|
556
|
+
// Task-1 review finding (non-blocking, fixed): a flow whose
|
|
557
|
+
// dataElement classified into NO named class (dataClasses: [])
|
|
558
|
+
// used to fall straight through this loop and return {} — no
|
|
559
|
+
// per-field MANUAL_REQUIRED at all. Privacy View's own rendering
|
|
560
|
+
// (`if (key in row.governanceRefs)`) then shows ZERO governance
|
|
561
|
+
// badges for such a flow, indistinguishable from a genuine
|
|
562
|
+
// nothing-to-disclose case — in tension with this codebase's own "missing
|
|
563
|
+
// evidence renders as unknown, never as not-applicable"
|
|
564
|
+
// convention (e.g. OSCAL's "an unassessed control gets no
|
|
565
|
+
// finding" is the SAME direction, never the reverse: absence of
|
|
566
|
+
// assessment must never read as absence of obligation). An
|
|
567
|
+
// unclassified data element still deserves the same governance
|
|
568
|
+
// scrutiny an operator-classified one does — its purpose/lawful
|
|
569
|
+
// basis/etc. are still real open questions, not inapplicable
|
|
570
|
+
// ones — so the honest answer is the full field set, all
|
|
571
|
+
// MANUAL_REQUIRED (or whatever the operator's scan-wide
|
|
572
|
+
// `config.default` supplies), never an empty object.
|
|
573
|
+
// `governanceRecordFor('(unclassified)', ...)` achieves this for
|
|
574
|
+
// free: an unmatched class key already falls through to
|
|
575
|
+
// `config.default`/MANUAL_REQUIRED in governanceRecordFor's own
|
|
576
|
+
// logic — no special-casing needed here.
|
|
577
|
+
const classesToResolve = dataClasses.length ? dataClasses : ['(unclassified)'];
|
|
578
|
+
const record = {};
|
|
579
|
+
for (const cls of classesToResolve) {
|
|
580
|
+
const clsRecord = governanceRecordFor(cls, opts.privacyGovernanceConfig ?? null);
|
|
581
|
+
for (const field of GOVERNANCE_FIELDS) {
|
|
582
|
+
// Worst-case-wins across multiple data classes on one flow,
|
|
583
|
+
// mirroring this package's own established
|
|
584
|
+
// aggregateVerdicts()-style precedent (protection.js) rather
|
|
585
|
+
// than silently picking whichever data class happened to be
|
|
586
|
+
// iterated last: an operator-provided value only wins over an
|
|
587
|
+
// already-recorded operator-provided value for the SAME field
|
|
588
|
+
// if this is the first class seen; MANUAL_REQUIRED never
|
|
589
|
+
// overwrites an already-resolved operator-provided value.
|
|
590
|
+
if (!record[field] || record[field].source === 'manual_required') {
|
|
591
|
+
record[field] = clsRecord[field];
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return record;
|
|
596
|
+
}),
|
|
597
|
+
// M5 deliverable #7 (FR-505/AC-29): identical composition pattern — a
|
|
598
|
+
// caller-supplied hook always wins. The default is installed ONLY when
|
|
599
|
+
// `opts.runtimeObservations` is genuinely defined; when it is `undefined`
|
|
600
|
+
// (no store on disk, per index.js's own existsSync gate) NO hook is
|
|
601
|
+
// installed at all, so `graph.runtimeCorroboration` stays absent and reads
|
|
602
|
+
// as `not_evaluated`. This is exactly the `undefined`-vs-`[]` distinction
|
|
603
|
+
// `opts.privacySinkPolicy` already draws for a missing privacy-policy.json,
|
|
604
|
+
// and collapsing the two would make "we never looked" indistinguishable
|
|
605
|
+
// from a genuine "we looked and saw nothing" — PRD line 2098's own prohibition.
|
|
606
|
+
correlateObservations: opts.correlateObservations
|
|
607
|
+
?? (opts.runtimeObservations !== undefined
|
|
608
|
+
? ((graph) => correlateObservations(graph, opts.runtimeObservations, {
|
|
609
|
+
// I3 (final review): this default hook used to resolve
|
|
610
|
+
// opts.environment ?? null with no environment-variable
|
|
611
|
+
// fallback, unlike the sibling flow.policyVerdict path
|
|
612
|
+
// (graph-builder.js's own opts.environment || process.env
|
|
613
|
+
// .AGENTIC_SECURITY_ENVIRONMENT || null) — so a scan run under
|
|
614
|
+
// AGENTIC_SECURITY_ENVIRONMENT=production correlated runtime
|
|
615
|
+
// observations with NO environment filter at all, letting a
|
|
616
|
+
// stale/wrong-environment observation corroborate a flow in the
|
|
617
|
+
// signed production graph. Matches graph-builder.js's own
|
|
618
|
+
// established convention exactly.
|
|
619
|
+
environment: opts.environment || process.env.AGENTIC_SECURITY_ENVIRONMENT || null,
|
|
620
|
+
windowStart: opts.observationWindowStart ?? null,
|
|
621
|
+
windowEnd: opts.observationWindowEnd ?? null,
|
|
622
|
+
}))
|
|
623
|
+
: undefined),
|
|
624
|
+
// M5 deliverable #8 (FR-304 "declared" half): identical composition
|
|
625
|
+
// pattern to `opts.correlateObservations` immediately above — a
|
|
626
|
+
// caller-supplied hook always wins. The default is installed ONLY when
|
|
627
|
+
// `opts.crossRepoLinkRecords` is genuinely defined (`index.js`'s own
|
|
628
|
+
// `existsSync` gate against `.agentic-security/cross-repo-links.json`
|
|
629
|
+
// found the file); when it is `undefined`, NO hook is installed and
|
|
630
|
+
// `graph.crossRepoLinks` stays `[]` via `graph-builder.js`'s own
|
|
631
|
+
// unconditional default — there is no `not_evaluated` state for this
|
|
632
|
+
// array to preserve, unlike `correlateObservations`'s own
|
|
633
|
+
// `undefined`-vs-`[]` distinction. When installed, the default hook
|
|
634
|
+
// drops any record whose `local.nodeId` no longer resolves against the
|
|
635
|
+
// CURRENT graph's own real node set (a stale declaration from before a
|
|
636
|
+
// rescan renamed/removed the node) rather than silently keeping it —
|
|
637
|
+
// matching `applyScenario`'s own "skippedOperations, never thrown"
|
|
638
|
+
// honesty precedent — and reports every drop via `console.error`.
|
|
639
|
+
crossRepoLinks: opts.crossRepoLinks
|
|
640
|
+
?? (opts.crossRepoLinkRecords !== undefined
|
|
641
|
+
? ((graph) => {
|
|
642
|
+
const nodeIds = new Set((graph.nodes ?? []).map((n) => n.id));
|
|
643
|
+
const kept = [];
|
|
644
|
+
const dropped = [];
|
|
645
|
+
for (const record of opts.crossRepoLinkRecords) {
|
|
646
|
+
if (record && record.local && nodeIds.has(record.local.nodeId)) kept.push(record);
|
|
647
|
+
else dropped.push(record);
|
|
648
|
+
}
|
|
649
|
+
if (dropped.length > 0) {
|
|
650
|
+
console.error(`agentic-security: dropped ${dropped.length} stale cross-repo link${dropped.length === 1 ? '' : 's'} (local.nodeId not found in the current graph): ${dropped.map((r) => (r && r.id) || '(malformed)').join(', ')}`);
|
|
651
|
+
}
|
|
652
|
+
return kept;
|
|
653
|
+
})
|
|
654
|
+
: undefined),
|
|
655
|
+
});
|
|
656
|
+
built.graph.coverage = buildCoverageLedger(built, opts);
|
|
657
|
+
return built;
|
|
658
|
+
}
|