@clear-capabilities/agentic-security-scanner 0.195.23 → 0.195.25
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 +31 -0
- package/dist/3596.index.js +1 -1
- package/dist/5752.index.js +1 -1
- package/dist/{01087ded263953062654.json → 809c900a90284017aaff.json} +2 -2
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +2 -2
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/engine.js +3 -0
- package/src/lineage/CLAUDE.md +25 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +23 -0
- package/src/lineage/boundary-join.js +384 -0
- package/src/lineage/coverage.js +4 -1
- package/src/lineage/driver.js +19 -2
- package/src/lineage/engine.js +44 -1
- package/src/lineage/graph-builder.js +165 -33
- package/src/lineage/index.js +37 -0
- package/src/lineage/lineage-source-catalog.js +143 -0
- package/src/lineage/source-seeding.js +152 -16
- package/src/lineage/summaries.js +21 -5
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
ea2e63293cf62b7a65de89db1da0f32c8490a8b41c5d9bbfd20fba1c3acb284f agentic-security.mjs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clear-capabilities/agentic-security-scanner",
|
|
3
|
-
"version": "0.195.
|
|
3
|
+
"version": "0.195.25",
|
|
4
4
|
"description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"test:dataflow": "node --test test/privacy-catalog.test.js test/privacy-deep-walker.test.js test/privacy-deep-e2e.test.js test/container-taint.test.js test/stored-taint-families.test.js test/py-annotation-sources.test.js test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/llm-validator-preset.test.js test/parser-py-cst.test.js test/chrome-probe.test.js test/parser-cs-kt.test.js test/parser-cs-annotations.test.js test/parser-cs-control-flow.test.js test/parser-kt-control-flow.test.js test/parser-js-annotations.test.js test/parser-java-annotations.test.js test/parser-java-control-flow.test.js test/parser-java-calls.test.js test/parser-go.test.js test/parser-rust.test.js test/rust-taint-flow.test.js test/parser-php-rb.test.js test/parser-php-control-flow.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/layer-recall.test.js test/java-taint-flow.test.js test/kt-taint-flow.test.js test/sanitizer-typed-flow.test.js test/sanitizer-gate-unit.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js test/ir-stats.test.js test/parser-cpp.test.js test/parser-js-decorators.test.js test/parser-js-if-else-cfg.test.js test/cpp-integration.test.js test/engine-reconnect.test.js test/phase2-scoping.test.js test/engine-recall.test.js test/catalog-annotation-source.test.js test/catalog-cs-p1.test.js test/catalog-expanded.test.js test/builtin-summaries.test.js test/interproc-mutation-crash.test.js test/mutated-param-taint.test.js test/return-sink-check.test.js test/higher-order-inline-callback.test.js test/interproc-findings-discard.test.js test/runscan-deep-option.test.js test/summary-cache-convergence.test.js test/parser-rb-calls.test.js test/parser-rb-module-level.test.js test/balanced-call.test.js test/destructuring-taint.test.js test/computed-member-taint.test.js test/string-interpolation-taint.test.js test/dataflow-deadcode-severity.test.js test/catalog-dotted-callee-lookup.test.js test/builtin-summary-family-blind-kill.test.js test/deep-mode-annotator-pipeline.test.js test/receiver-type-and-nested-calls.test.js test/member-write-and-loop-taint.test.js test/parser-py-module-level.test.js test/parser-php-module-level.test.js test/r14b-module-level-e2e.test.js test/annotation-taint-engine.test.js test/interproc-nested-call-taint.test.js test/catalog-xpath-injection.test.js test/catalog-ldap-injection.test.js test/catalog-xxe.test.js test/catalog-response-splitting.test.js test/catalog-code-injection.test.js test/parser-rb-control-flow.test.js test/catalog-ruby-p1.test.js test/callee-receiver-taint.test.js test/catalog-command-injection-p4.test.js test/catalog-xss-p4.test.js test/catalog-path-ssrf-p3.test.js test/catalog-ai-model-provider-precision.test.js test/catalog-orm-write.test.js test/dataflow/provenance-pipeline-integration.test.js test/dataflow-progress.test.js test/scan-progress.test.js test/sard-leakage-pipeline.test.js test/sard-split-scoring.test.js test/sard-llm-isolation.test.js test/sard-mutation-adversarial.test.js test/sard-baseline-adaptive-tolerance.test.js test/sard-independent-holdout.test.js test/sard-flow-aware-scoring.test.js test/sard-macro-score-support-floor.test.js test/sard-php-split.test.js test/parser-java-nary-concat.test.js test/sard-naive-baseline.test.js test/field-taint-cross-method.test.js test/field-taint-engine-unit.test.js test/coercion-sanitizer-kill.test.js test/constant-propagation-path-sensitivity.test.js test/parser-java-assignments.test.js test/catalog-java-sard.test.js test/parser-cs-juliet-shapes.test.js test/catalog-cs-sard-p2.test.js test/sard-cwe-key-merge.test.js test/parser-php-backtick.test.js test/parser-php-include.test.js test/php-include-merge.test.js test/parser-php-array-field-taint.test.js test/parser-php-cond-assign.test.js test/parser-rust-redos.test.js test/multi-sink-taint-chain.test.js test/static-field-cross-class-call-string.test.js test/regex-guard-narrowing.test.js test/object-instance-field-taint.test.js test/param-type-inference.test.js test/smt-path-feasibility.test.js test/cross-service-taint.test.js test/dataflow-expr-taint-scaling.test.js test/evaluation/guard-dominance.test.js test/evaluation/engine-mechanisms.test.js test/rx-exec-receiver.test.js test/incremental-dependency.test.js test/incremental-integrity.test.js test/demand-query.test.js test/ir-cache.test.js test/canonicalize-parity.test.js test/dataflow-source-attribution.test.js",
|
|
91
91
|
"test:mcp": "node --test test/mcp.test.js test/mcp-apply-fix-multifile.test.js test/mcp-protocol-smoke.test.js test/mcp-audit.test.js test/audit-cli.test.js test/mcp-scratchpad.test.js test/mcp-offload.test.js test/sca-upgrade.test.js test/lsp-server.test.js test/mcp-dataflow-tools.test.js test/cli/governance-propose-edit.test.js test/cli/remediation-open-update.test.js test/cli/remediation-verify-reopen.test.js test/cli/dataflow-observations.test.js test/cli/federate-declare-list.test.js test/capabilities/tools.test.js",
|
|
92
92
|
"test:report": "node --test test/report-scan-invariants.test.js test/junit.test.js test/ci.test.js test/poc-generator.test.js test/verifier.test.js test/verifier-target.test.js test/annotator-errors.test.js test/git-history-batch.test.js test/default-scan-drift.test.js test/bundle-optional-deps.test.js test/scan-health.test.js test/finding-schema.test.js test/pipeline/finding-schema-provenance.test.js test/evidence-provenance.test.js test/report/provenance-output.test.js test/report/provenance-format-parity.test.js test/legacy-compat.test.js test/annotator-runner.test.js test/detector-runner.test.js test/detector-errors.test.js test/analyzer-supervisor.test.js test/cascade-worker-pool.test.js test/cascade-pool-wiring.test.js test/coverage-ledger.test.js test/coverage-ledger-completeness.test.js test/assurance-mode.test.js test/artifact-registry.test.js test/artifact-registry-completeness.test.js test/retention-policy.test.js test/state-lifecycle-report.test.js test/legal-hold.test.js test/production-feedback.test.js test/calibration-feedback.test.js test/encryption-provider.test.js test/grader-calibration.test.js test/pr-delta-gate.test.js test/vex.test.js test/report-render.test.js test/oscal-conformance.test.js test/cli-fix-apply.test.js test/apply-fix-service.test.js test/high-impact-approval-gate.test.js test/approver-registry.test.js test/suppression-exceptions.test.js test/fix-history.test.js test/producer-registry.test.js test/producer-collector.test.js test/enrichment-completion.test.js",
|
|
93
|
-
"test:lineage": "node --test test/lineage/analysis-completion.test.js test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js test/lineage/runtime-assurance.test.js test/lineage/runtime-edge-states.test.js test/lineage/runtime-explorer-view.test.js test/cli/runtime-explorer-cli.test.js test/cli/runtime-identity-cli.test.js test/cli/runtime-twin-cli.test.js test/lineage/cut-planner.test.js test/lineage/scc.test.js test/lineage/scc-solver.test.js test/graph/graph-views.test.js test/lineage/dag07-completion.test.js test/lineage/owned-build.test.js test/dataflow/boundary-propagation.test.js test/lineage/boundary-contract.test.js test/lineage/correlation-v2.test.js test/lineage/path-control-evidence.test.js test/lineage/smt-z3-backend.test.js test/cli/dag-commands.test.js",
|
|
93
|
+
"test:lineage": "node --test test/lineage/analysis-completion.test.js test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/seeding-across-calls.test.js test/lineage/boundary-join.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js test/lineage/runtime-assurance.test.js test/lineage/runtime-edge-states.test.js test/lineage/runtime-explorer-view.test.js test/cli/runtime-explorer-cli.test.js test/cli/runtime-identity-cli.test.js test/cli/runtime-twin-cli.test.js test/lineage/cut-planner.test.js test/lineage/scc.test.js test/lineage/scc-solver.test.js test/graph/graph-views.test.js test/lineage/dag07-completion.test.js test/lineage/owned-build.test.js test/dataflow/boundary-propagation.test.js test/lineage/boundary-contract.test.js test/lineage/correlation-v2.test.js test/lineage/path-control-evidence.test.js test/lineage/smt-z3-backend.test.js test/cli/dag-commands.test.js",
|
|
94
94
|
"test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
|
|
95
95
|
"test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
|
|
96
96
|
"test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-remediation-kdf.test.js test/haskell/haskell-remediation-html-text.test.js test/haskell/haskell-remediation-scotty-body.test.js test/haskell/haskell-remediation-entropy.test.js test/haskell/haskell-remediation-text-command.test.js test/haskell/haskell-remediation-path-contain.test.js test/haskell/haskell-remediation-0195-16.test.js test/haskell/haskell-taint-shapes.test.js test/haskell/haskell-real-advisory-shapes.test.js test/haskell/haskell-discovery-shapes.test.js test/haskell/haskell-quasiquote-interp.test.js test/haskell/haskell-record-dot.test.js test/haskell/haskell-global-cell.test.js test/haskell/haskell-syntax-extensions.test.js test/haskell/haskell-rule-precision.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-trust-fixed-file.test.js test/haskell/haskell-predictable-random-flow.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
|
package/src/dataflow/CLAUDE.md
CHANGED
|
@@ -118,7 +118,7 @@ Tests: `test/evaluation/engine-mechanisms.test.js`.
|
|
|
118
118
|
|
|
119
119
|
## Boundary-contract consumer seeding (DAG-08)
|
|
120
120
|
|
|
121
|
-
`cross-service-taint.js` still only ANNOTATES findings for `consumes` declarations. The separate `runBoundaryTaint(perFileIR, callGraph, contracts)` seeds bound boundary contracts (see `../lineage/boundary-contract.js`): it sets `fn.boundarySeeds` (access paths such as `evt.customerId`, read by `_unionAnnotationTaint`) for one seed per engine run and always removes it afterwards. Findings new relative to an unseeded baseline carry `boundary` evidence (`modeledInput: true`, `observed: false`) and the consumer field that reached them. A contract that does not bind yields a coverage gap, no finding. Cost is one `runTaintEngine` pass per seed, capped by `maxRuns` (default 64, reported as a `budget` gap when hit).
|
|
121
|
+
`cross-service-taint.js` still only ANNOTATES findings for `consumes` declarations. The separate `runBoundaryTaint(perFileIR, callGraph, contracts)` seeds bound boundary contracts (see `../lineage/boundary-contract.js`): it sets `fn.boundarySeeds` (access paths such as `evt.customerId`, read by `_unionAnnotationTaint`) for one seed per engine run and always removes it afterwards. Findings new relative to an unseeded baseline carry `boundary` evidence (`modeledInput: true`, `observed: false`) and the consumer field that reached them. A contract that does not bind yields a coverage gap, no finding. Cost is one `runTaintEngine` pass per seed, capped by `maxRuns` (default 64, reported as a `budget` gap when hit). The LINEAGE graph has its own, separate consumer of the same contracts since 0.195.25 (`../lineage/boundary-join.js`, opt-in with `AGENTIC_SECURITY_LINEAGE_BOUNDARIES=1`): it seeds field identity, not taint, so nothing here, and no SAST finding, changes when it runs.
|
|
122
122
|
## Demand-driven sink queries (DAG-02, `demand-query.js`)
|
|
123
123
|
|
|
124
124
|
Opt-in (`AGENTIC_SECURITY_DEMAND=1` plus an explicit query list or the `runDemandSinkQuery` API; the default scan never calls it). A query is keyed by exact sink site (file, line, optional node id, callee, sink id), argument index, field/access path inside that argument, context (only `whole-program` is supported; anything else is `unavailable`), rule family (CWE or family name) and the graph and configuration digests (`graphDigest`, `configDigest`, `sinkQueryKey`).
|
package/src/engine.js
CHANGED
|
@@ -9774,6 +9774,9 @@ function _deterministicFileTimings(timings) {
|
|
|
9774
9774
|
deterministic: isDeterministic(),
|
|
9775
9775
|
perFile,
|
|
9776
9776
|
fileContents,
|
|
9777
|
+
// GA-03.3 (0.195.25): cross-service handoff joins in the lineage
|
|
9778
|
+
// graph, opt-in. Off, the graph is the one 0.195.24 built.
|
|
9779
|
+
...(process.env.AGENTIC_SECURITY_LINEAGE_BOUNDARIES === '1' ? { boundaries: { enabled: true } } : {}),
|
|
9777
9780
|
};
|
|
9778
9781
|
// DAG-07 R07.7: with AGENTIC_SECURITY_LINEAGE_DEADLINE_MS set, the build runs in an
|
|
9779
9782
|
// owned worker with a watchdog. Unset: the in-process call, exactly as before.
|
package/src/lineage/CLAUDE.md
CHANGED
|
@@ -1328,3 +1328,28 @@ report would read as a real-code measurement. No taint-recall tier is borrowed.
|
|
|
1328
1328
|
## Runtime twin additions (PRD 2 gap closure)
|
|
1329
1329
|
|
|
1330
1330
|
`runtime-edge-states.js` derives the six edge states and the identity / absent-from-static queries from the existing correlation, as a pure read: nothing is written into the static graph. `RUNTIME_EXPLOIT_CORRELATED` is never inferred from observation; it needs validated operator evidence. `identity-token.js` supplies a keyed-hash tokenizer injected into the adapters through `context.tokenizeIdentity`; the adapters stay zero-import and drop identity when no tokenizer is given. The attribute allowlist test pins `identity.token`; widen it deliberately, never by loosening the grammar.
|
|
1331
|
+
|
|
1332
|
+
## Lineage sources across aliases and calls (GA-03.1, 0.195.24)
|
|
1333
|
+
|
|
1334
|
+
The data-lineage corpus (`bench/data-lineage/`) went from 17 of 24 to 24 of 24, every fixture regression-tier, through three engine changes and one runner change. Each is measured by `test/lineage/seeding-across-calls.test.js`.
|
|
1335
|
+
|
|
1336
|
+
| Module | Responsibility |
|
|
1337
|
+
|---|---|
|
|
1338
|
+
| `lineage-source-catalog.js` | Six JavaScript collection points that are lineage sources but NOT taint sources: `process.argv` (`cli-argument`), `req.file`/`req.files` (`http-upload`), `fs.readFileSync` (`storage-read`), bare `prompt()` and `readlineSync.question()` (`user-input`). The main taint catalog had no JavaScript entry for those four categories, and adding them there would make a file's contents or a console prompt attacker-controlled for every SAST finding. Same isolation as `dataflow/privacy-catalog.js`; imports nothing from `dataflow/`. `matchLineageSource(expr, file)` is consulted by `source-seeding.js` after `matchSource` and only for a JavaScript or TypeScript file; `LINEAGE_SOURCE_DECISIONS` proves at load time that every entry's `provenance` resolves through `source-registry.js`. The test pins that `matchSource` returns null for every shape here. |
|
|
1339
|
+
| `source-seeding.js` (extended) | Two seed kinds (`SEED_KINDS`). `entry` is §3's mechanism unchanged. `after-node` is a CALL-shaped source assigned straight to a variable (`const record = fs.readFileSync(p)`, `const data = res.json()`): the call has no access path, so the seed lands on the assignment target in the state LEAVING that CFG node, applied by `engine.js`'s worklist through `ctx.seedAfterNode(qid, nodeId)` (`seedAfterNodeFactory`). A call-shaped source anywhere else is still `unseedable[]`. `extendThroughAliases` follows a container alias within one function (`const body = req.body; body.card_number`): the alias assignment names `body`, every outermost pure member read rooted at an alias mints the field seed on the ORIGINAL path (`req.body.card_number`, classified `PCI`), sited where the data entered; chained aliases are followed for at most four rounds, a wildcard segment is never seeded, a parameter is never an alias. |
|
|
1340
|
+
| `summaries.js` (extended) | `createCallSummaryResolver(cache, lookupCallee, { seedEntryState })` joins the callee's OWN seeds onto the entry state derived from the call arguments: `req.body.card_number` read inside `extractCard` is a source wherever it is called from, so the caller's resolution of `extractCard(req)` returns the PCI element and the `call-resolved` hop joins the caller's path to the callee's return node. The cache key is the joined state, so the driver's analysis of the callee and a clean call site share one key; §3.6's rule (no seeded summary under the empty-entry key) stands, see `DESIGN_GRAPH_BUILDER.md` §3.8. Bind hops and the cap-degradation loss hops enumerate only the call-derived half. `ctx.seedAfterNode` is forwarded to the callee's ctx. `driver.js` memoises the hook per function and passes it to the resolver. |
|
|
1341
|
+
| `engine.js` (extended) | After `step()`, the worklist adds that node's `after-node` seeds to the outgoing state (idempotent on revisit, no hop: the first read creates the path node with no in-edges, an origin, as for an entry seed). **Disclosed gap:** a bare call statement is still never resolved through the summary, so a sink inside the callee never sees an argument's identity; resolving it was tried and reverted (it consumes a summary context per call site and under a tight cap degrades a later resolution, moving seven pinned provenance measurements). Pinned by `seeding-across-calls.test.js` IP/2. |
|
|
1342
|
+
| `bench/data-lineage/runner.mjs` (extended) | `expectUnsupportedSink: true` asserts a `kind: 'process'`, `subtype: null`, `coverageStatus: 'unsupported'` node with a reason (the `exec` fixture). An EMPTY `dataClass` label matches the flow on the two categories alone (a file's contents, a positional argument); a non-empty label is unchanged. |
|
|
1343
|
+
|
|
1344
|
+
## Cross-service handoffs (GA-03.3, 0.195.25)
|
|
1345
|
+
|
|
1346
|
+
A value leaving one function through a queue publish or a store write and re-entering another through a queue receive or a store read is a HANDOFF. The engine sees an escape and an unrelated call result; nothing in the hop stream connects them, so the multi-service fixture of `bench/dag` reported 3 of its 8 labelled positives (the two site labels it still misses are for the taint engine, not for a flow). `boundary-join.js` joins the two halves, opt-in: `buildDataFlowGraph(callGraph, { boundaries: { enabled: true, contracts } })`, `AGENTIC_SECURITY_LINEAGE_BOUNDARIES=1` on a scan. Off, a build is byte-identical to 0.195.24 (`test/lineage/boundary-join.test.js` BJ/9).
|
|
1347
|
+
|
|
1348
|
+
| Module | Responsibility |
|
|
1349
|
+
|---|---|
|
|
1350
|
+
| `boundary-join.js` | Pure (imports `node:crypto`, `dataflow/access-paths.js`, `source-seeding.js`'s walkers, `boundary-contract.js`, `classification.js`). `collectBoundaryEndpoints(callGraph, sites)`: producers are the sink sites of category `queue` (including the AWS SDK v3 shape `client.send(new SendMessageCommand(input))`) and the store writes `collection('<literal>').insertOne|insertMany|replaceOne(record)`; consumers are assignments whose source is `client.send(new ReceiveMessageCommand(input))` (record path `Messages.Body`, JSON) or `collection('<literal>').find(q).toArray()` / `.findOne(q)`. An identity is a string or number literal, or `process.env.NAME`, read through at most three module-scope constants (`resolveIdentityExpr`); a store identity is the collection literal, and two sides with different literal `db('<name>')` names never join. A computed identity is `unjoined` with `dynamic-identity`; an identity with no partner is `no-producer-in-scope`. Two sites that share only a field name are never joined. `recordShape(recordExpr, siteHops)`: the ids that reached the producer site (`write-out/call-arg` hops) keyed by the record's object-literal keys, each key taking the ids whose contributing state key (`production/ident` and `selection/member` hops) its value expression references through its OUTERMOST access paths; a record that is not an object literal is `whole-record`; nothing reached is `none`. `planBoundaryJoins`: a discovered join seeds the consumer's assignment target under `target.recordPath.key` as an `after-node` seed carrying the PRODUCER's data-element id; a declared DAG-08 contract (bound by `bindBoundaryContracts`) seeds the handler parameter in the entry state under the contract's `fieldMappings` (renames applied), from the producer site the contract names, and a contract without a producer site is a gap (`producer-site-required`). Every seed carries `boundary: { joinId, kind, identity, provenance: 'code' \| 'manual', contractId, basis, producer, modeledInput: true, observed: false, exploited: false }`. |
|
|
1351
|
+
| `graph-builder.js` (extended) | Sites are enumerated before the analysis so the producers exist for the rounds. With boundaries on, a round plans the joins the current hop stream supports and, when it minted seeds, re-runs the field analysis (`MAX_BOUNDARY_ROUNDS` = 4, each round one full `runFieldIdentityAnalysis`); a still-productive last round is `truncated` and the graph `partial` with `analysisCompletion.boundaries` (BJ/10), never read as convergence. The source node of a flow from a boundary seed is the PRODUCER's sink node, so the graph reads as one chain (HTTP to queue to store to HTTP response); the edge carries `provenance: 'code'` or `'manual'` and one `boundaryCrossings` entry; the flow's limitations say the handoff is modeled, not observed. A data element seeded in several functions keeps the name and classes of its collection point (`{contact: email}` is still `email`) and gains one `sourceLocations` entry per consumer; `seedForPath` picks the seed in the function the path's terminal node lives in. `enumerateSinkSites` resolves the v3 send command through the privacy catalog's queue entry (the general catalog's bare `send` entry had claimed it as a koa-send path sink) and skips the receive command (a read, not a sink). `coverage.boundaries` (also in `coverage.js`'s ledger, only when enabled): rounds, joins, joined fields, declared vs discovered, every unjoined endpoint with its reason, contract gaps. |
|
|
1352
|
+
| `engine.js` (extended) | `JSON.parse(x)` and `JSON.stringify(x)` are structure-preserving: the call forwards its first argument's `{flat, byPath, widened}` the way `assign-expr` forwards its source, and records no hop of its own. Without this the consumer's `body = JSON.parse(m.Body)` collapsed to the whole record's flat union and every key crossed under every field. `source-seeding.js`'s alias extension unwraps the same pair (`const back = JSON.parse(s)` is an alias of the container `s` was serialized from). |
|
|
1353
|
+
| `index.js` / `engine.js` (scan) | `AGENTIC_SECURITY_LINEAGE_BOUNDARIES=1` sets `boundaries.enabled`; `index.js` reads `.agentic-security/boundary-contracts.json` (the file `dataflow contracts` reads) once, existence-gated, a malformed file printed and treated as no contracts. `bench/dag/lib/scan-unit.mjs` forces the variable on, as it does deep mode. |
|
|
1354
|
+
|
|
1355
|
+
Measured on the multi-service fixture, scan path (`node bench/dag/run.mjs --only=fixture`): flow labels 6 of 6 positive and 3 of 3 negative, where 0.195.24 found 3 of 6; the two site labels (stored value into HTML) stay FN, they need a storage-read source in the taint engine; one reported flow matches no label (`qty` into `orders.total`, a derived numeric write the label set does not list; left unlabelled under the no-relabelling rule). Precision of the join rests on three things stated above: a literal identity on both sides, the ids that really reached the producer site, and outermost-path field mapping. What it does not do: a queue consumed through a callback (`consumer.on('message', cb)`), a store read through an ORM model, a batch send's per-entry bodies, an identity that is a template string or a concatenation, and a producer in another repository (that is `cross-repo-link.js`'s declared edge, unchanged).
|
|
@@ -215,6 +215,29 @@ empty one.
|
|
|
215
215
|
`E1/driver-3` proves **both** directions: a local replica of the pre-fix loop
|
|
216
216
|
(the mutant) fabricates; the shipped driver does not.
|
|
217
217
|
|
|
218
|
+
### 3.8 Revision (GA-03.1, 0.195.24): a callee's seeds are facts about its body
|
|
219
|
+
|
|
220
|
+
§3.6's table read "`[]` — correct" for the caller's local after
|
|
221
|
+
`const v = helper(o)`. That was the right KEY and the wrong CONSEQUENCE.
|
|
222
|
+
`helper`'s seed is `req.body.secret`, a source expression inside `helper`'s
|
|
223
|
+
own body; it is read whoever calls `helper`, so the honest answer at every
|
|
224
|
+
call site is that `v` carries it. Under the shipped resolver the caller
|
|
225
|
+
resolved `helper` under the call-derived state alone, the seed was absent
|
|
226
|
+
from it, and the lineage corpus's interprocedural fixture produced the right
|
|
227
|
+
nodes, the right data element and zero flows.
|
|
228
|
+
|
|
229
|
+
`createCallSummaryResolver` now joins the callee's own seeds
|
|
230
|
+
(`seedEntryState(fn)`, the same hook the driver uses, memoised per function)
|
|
231
|
+
onto the call-derived entry state before computing the summary. The rule
|
|
232
|
+
§3.6 established is untouched: the cache key is the JOINED state, so nothing
|
|
233
|
+
is ever written under the empty-entry key for a seeded function, and the
|
|
234
|
+
driver's own analysis of the callee and a clean call site share one key and
|
|
235
|
+
one summary. `E1/driver-3` now pins exactly that in both directions (the
|
|
236
|
+
mutant still stores the seeded answer under the empty key; the shipped
|
|
237
|
+
driver never does, and the caller receives the seed). Bind hops and the
|
|
238
|
+
cap-degradation loss hops enumerate only the call-derived half: a seed is
|
|
239
|
+
not an argument.
|
|
240
|
+
|
|
218
241
|
### 3.7 What real seeding measures
|
|
219
242
|
|
|
220
243
|
| Project | seeds | hops | pnodes / pedges | node kinds reached |
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
//
|
|
2
|
+
// boundary-join.js: cross-service handoffs in the lineage graph (GA plan,
|
|
3
|
+
// GA-03.3: "integrate existing cross-service boundary joins into the
|
|
4
|
+
// documented opt-in scan path; validate producer/consumer schemas, argument
|
|
5
|
+
// mapping and field protection; unknown dynamic edges must remain unknown").
|
|
6
|
+
//
|
|
7
|
+
// A HANDOFF is a value leaving one function through a queue publish or a
|
|
8
|
+
// storage write and re-entering another function through a queue receive or
|
|
9
|
+
// a storage read. The field-identity engine sees the two halves as an escape
|
|
10
|
+
// and an unrelated call result; nothing in the hop stream connects them, so
|
|
11
|
+
// before this module the multi-service fixture's queue consumer and its
|
|
12
|
+
// reporting handlers reported no flow at all (3 of 8 labelled positives).
|
|
13
|
+
//
|
|
14
|
+
// What a join IS, and what it is not:
|
|
15
|
+
// * A join is a MODELED connection: the producer and the consumer name the
|
|
16
|
+
// SAME identity by a literal the parser can read (the same string literal
|
|
17
|
+
// or the same `process.env` name for a queue, the same collection literal
|
|
18
|
+
// for a store), or an operator declared the edge in a boundary contract
|
|
19
|
+
// (boundary-contract.js, DAG-08). Nothing here observes a message on the
|
|
20
|
+
// wire or a row in a table: every seed carries `modeledInput: true,
|
|
21
|
+
// observed: false`, exactly the vocabulary DAG-08 fixed.
|
|
22
|
+
// * Two sites that share only a FIELD NAME are never joined. Two sites whose
|
|
23
|
+
// identity is computed (`QueueUrl: makeUrl(env)`, `collection(name)`) are
|
|
24
|
+
// reported as `unjoined` with the reason `dynamic-identity`; an identity
|
|
25
|
+
// with no partner in the scanned tree is `no-producer-in-scope`.
|
|
26
|
+
// * The fields that cross are the fields that REACHED the producer site in
|
|
27
|
+
// the analysis: the record expression's object-literal keys are mapped to
|
|
28
|
+
// the data-element ids whose contributing state keys the key's value
|
|
29
|
+
// expression references (`{note: body.note, total: body.qty * PRICE}`
|
|
30
|
+
// hands `note` to the key `note` and `qty` to the key `total`). A record
|
|
31
|
+
// that is not an object literal hands every reaching id to the record as
|
|
32
|
+
// a whole, disclosed as `basis: 'whole-record'`.
|
|
33
|
+
// * The consumer is seeded on the parsed record's FIELDS, after the call
|
|
34
|
+
// that received it (`source-seeding.js`'s `after-node` placement), under
|
|
35
|
+
// the consumer-side record path the receive shape implies (an SQS
|
|
36
|
+
// `ReceiveMessageCommand` result is `Messages[].Body`; a Mongo `find()`
|
|
37
|
+
// result is the record itself). A declared contract seeds the handler
|
|
38
|
+
// parameter the contract binds, in the function's entry state.
|
|
39
|
+
//
|
|
40
|
+
// The join needs the producer's analysis before the consumer can be seeded,
|
|
41
|
+
// and a chain (HTTP to queue to store to HTTP response) needs the store
|
|
42
|
+
// writer analysed WITH its queue seeds before the store reader can be
|
|
43
|
+
// joined. graph-builder.js therefore re-runs the field analysis in bounded
|
|
44
|
+
// rounds (`MAX_BOUNDARY_ROUNDS`) while a round mints new seeds, and reports
|
|
45
|
+
// `truncated` when the last round still would.
|
|
46
|
+
//
|
|
47
|
+
// Pure: no file access, no engine import. graph-builder.js owns the rounds;
|
|
48
|
+
// index.js loads the contracts file; engine.js reads the opt-in variable.
|
|
49
|
+
|
|
50
|
+
import { createHash } from 'node:crypto';
|
|
51
|
+
import { accessPathOf } from '../dataflow/access-paths.js';
|
|
52
|
+
import { exprRoots, walkExpr } from './source-seeding.js';
|
|
53
|
+
import { bindBoundaryContracts } from './boundary-contract.js';
|
|
54
|
+
import { classifyDataElementName } from './classification.js';
|
|
55
|
+
|
|
56
|
+
export const MAX_BOUNDARY_ROUNDS = 4;
|
|
57
|
+
export const BOUNDARY_JOIN_KINDS = Object.freeze(['queue', 'storage']);
|
|
58
|
+
|
|
59
|
+
// AWS SDK v3 command classes: `client.send(new <Command>(input))`. The input
|
|
60
|
+
// object carries the identity (`QueueUrl`/`TopicArn`) and the record.
|
|
61
|
+
export const QUEUE_SEND_COMMANDS = Object.freeze({
|
|
62
|
+
SendMessageCommand: { provider: 'sqs', record: 'MessageBody', entry: 'privacy-js-queue-sendMessage' },
|
|
63
|
+
SendMessageBatchCommand: { provider: 'sqs', record: 'Entries', entry: 'privacy-js-queue-sendMessage' },
|
|
64
|
+
PublishCommand: { provider: 'sns', record: 'Message', entry: 'privacy-js-queue-publish' },
|
|
65
|
+
});
|
|
66
|
+
export const QUEUE_RECEIVE_COMMANDS = Object.freeze({
|
|
67
|
+
ReceiveMessageCommand: { provider: 'sqs', recordPath: 'Messages.Body', encoding: 'json' },
|
|
68
|
+
});
|
|
69
|
+
const QUEUE_IDENTITY_KEYS = Object.freeze(['QueueUrl', 'TopicArn', 'topic', 'queueName']);
|
|
70
|
+
// Record-bearing keys for the v2 shapes `sqs.sendMessage({...})` / `sns.publish({...})`.
|
|
71
|
+
const QUEUE_RECORD_KEYS = Object.freeze(['MessageBody', 'Message']);
|
|
72
|
+
|
|
73
|
+
// MongoDB driver shapes on a `collection('<literal>')` handle.
|
|
74
|
+
const STORAGE_WRITE_METHODS = Object.freeze({ insertOne: 0, replaceOne: 1, insertMany: 0 });
|
|
75
|
+
const STORAGE_READ_METHODS = Object.freeze({ find: 'many', findOne: 'one', findMany: 'many' });
|
|
76
|
+
const STORAGE_READ_CHAIN_TAIL = Object.freeze(['toArray']);
|
|
77
|
+
|
|
78
|
+
const isObj = (v) => v !== null && typeof v === 'object';
|
|
79
|
+
const propOf = (objExpr, key) => (objExpr && objExpr.kind === 'object' ? (objExpr.props || []).find((p) => !p.spread && p.key === key) : null);
|
|
80
|
+
|
|
81
|
+
/** The file's module-scope function (`<file>::top::<module>@N`), when the call graph has one. */
|
|
82
|
+
function moduleFunctionOf(callGraph, file) {
|
|
83
|
+
for (const fn of callGraph.functions.values()) {
|
|
84
|
+
if (fn.file === file && /::top::<module>@/.test(String(fn.qid))) return fn;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The single module-scope assignment to `name` in `file`, or null (none, or more than one). */
|
|
90
|
+
function moduleConstant(callGraph, file, name) {
|
|
91
|
+
const fn = moduleFunctionOf(callGraph, file);
|
|
92
|
+
if (!fn) return null;
|
|
93
|
+
let found = null; let count = 0;
|
|
94
|
+
for (const node of Object.values(fn.cfg?.nodes ?? {})) {
|
|
95
|
+
if (node.kind === 'assign' && node.target === name && node.source) { found = node.source; count += 1; }
|
|
96
|
+
}
|
|
97
|
+
return count === 1 ? found : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve an identity-bearing expression to a literal the parser can read:
|
|
102
|
+
* `{kind: 'literal', value}` for a string/number literal, `{kind: 'env',
|
|
103
|
+
* value: NAME}` for `process.env.NAME`, following a module-scope constant up
|
|
104
|
+
* to three hops. Anything else is null: a computed identity is unknown.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveIdentityExpr(expr, callGraph, file, depth = 0) {
|
|
107
|
+
if (!isObj(expr) || depth > 3) return null;
|
|
108
|
+
if (expr.kind === 'literal' && (typeof expr.value === 'string' || typeof expr.value === 'number')) return { kind: 'literal', value: String(expr.value) };
|
|
109
|
+
if (expr.kind === 'member' && typeof expr.prop === 'string' && expr.object?.kind === 'member'
|
|
110
|
+
&& expr.object.prop === 'env' && expr.object.object?.kind === 'ident' && expr.object.object.name === 'process') {
|
|
111
|
+
return { kind: 'env', value: expr.prop };
|
|
112
|
+
}
|
|
113
|
+
if (expr.kind === 'ident' && callGraph) {
|
|
114
|
+
const c = moduleConstant(callGraph, file, expr.name);
|
|
115
|
+
return c ? resolveIdentityExpr(c, callGraph, file, depth + 1) : null;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const identityKey = (kind, r) => (r ? `${kind}:${r.kind}:${r.value}` : null);
|
|
121
|
+
|
|
122
|
+
/** `client.send(new SendMessageCommand({...}))` and friends: the command descriptor, or null. */
|
|
123
|
+
export function queueCommandOf(calleeExpr, args) {
|
|
124
|
+
if (!isObj(calleeExpr) || calleeExpr.kind !== 'member' || calleeExpr.prop !== 'send') return null;
|
|
125
|
+
const a0 = Array.isArray(args) ? args[0] : null;
|
|
126
|
+
if (!isObj(a0) || a0.kind !== 'call' || !a0.isNew || a0.callee?.kind !== 'ident') return null;
|
|
127
|
+
const name = a0.callee.name;
|
|
128
|
+
if (QUEUE_SEND_COMMANDS[name]) return { role: 'send', command: name, ...QUEUE_SEND_COMMANDS[name], input: a0.args?.[0] ?? null };
|
|
129
|
+
if (QUEUE_RECEIVE_COMMANDS[name]) return { role: 'receive', command: name, ...QUEUE_RECEIVE_COMMANDS[name], input: a0.args?.[0] ?? null };
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function queueIdentityOf(inputExpr, callGraph, file) {
|
|
134
|
+
if (!isObj(inputExpr) || inputExpr.kind !== 'object') return { identity: null, reason: 'no-literal-input' };
|
|
135
|
+
for (const key of QUEUE_IDENTITY_KEYS) {
|
|
136
|
+
const p = propOf(inputExpr, key);
|
|
137
|
+
if (!p) continue;
|
|
138
|
+
const r = resolveIdentityExpr(p.value, callGraph, file);
|
|
139
|
+
return r ? { identity: identityKey('queue', r), detail: { key, ...r } } : { identity: null, reason: 'dynamic-identity', detail: { key } };
|
|
140
|
+
}
|
|
141
|
+
return { identity: null, reason: 'no-identity-key' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `JSON.stringify(x)` → x; anything else unchanged. */
|
|
145
|
+
function unwrapSerialization(e) {
|
|
146
|
+
if (isObj(e) && e.kind === 'call' && e.callee?.kind === 'member' && e.callee.prop === 'stringify'
|
|
147
|
+
&& e.callee.object?.kind === 'ident' && e.callee.object.name === 'JSON' && e.args?.length) return e.args[0];
|
|
148
|
+
return e;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `<handle>.collection('<literal>')` → `{table, handleExpr}`, or null. */
|
|
152
|
+
function collectionCallOf(e) {
|
|
153
|
+
if (!isObj(e) || e.kind !== 'call' || e.callee?.kind !== 'member' || e.callee.prop !== 'collection') return null;
|
|
154
|
+
const a0 = e.args?.[0];
|
|
155
|
+
if (!isObj(a0)) return { table: null, handleExpr: e.callee.object, reason: 'dynamic-identity' };
|
|
156
|
+
if (a0.kind !== 'literal' || typeof a0.value !== 'string') return { table: null, handleExpr: e.callee.object, reason: 'dynamic-identity' };
|
|
157
|
+
return { table: a0.value, handleExpr: e.callee.object };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The database name behind a collection handle when it is `x.db('<literal>')` reachable through module constants. */
|
|
161
|
+
function databaseOf(handleExpr, callGraph, file) {
|
|
162
|
+
let cur = handleExpr; let depth = 0;
|
|
163
|
+
while (isObj(cur) && depth++ < 4) {
|
|
164
|
+
if (cur.kind === 'ident') { cur = moduleConstant(callGraph, file, cur.name); continue; }
|
|
165
|
+
if (cur.kind === 'call' && cur.callee?.kind === 'member' && cur.callee.prop === 'db') {
|
|
166
|
+
const a0 = cur.args?.[0];
|
|
167
|
+
return isObj(a0) && a0.kind === 'literal' && typeof a0.value === 'string' ? a0.value : null;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function storageIdentity(coll, callGraph, file) {
|
|
175
|
+
if (!coll || !coll.table) return { identity: null, reason: coll ? coll.reason : 'no-collection', detail: null };
|
|
176
|
+
const database = databaseOf(coll.handleExpr, callGraph, file);
|
|
177
|
+
return { identity: `storage:collection:${coll.table}`, detail: { table: coll.table, database } };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A storage WRITE site: `<handle>.collection('<lit>').insertOne(record)` and friends. */
|
|
181
|
+
function storageWriteOf(calleeExpr, args) {
|
|
182
|
+
if (!isObj(calleeExpr) || calleeExpr.kind !== 'member') return null;
|
|
183
|
+
const argIndex = STORAGE_WRITE_METHODS[calleeExpr.prop];
|
|
184
|
+
if (argIndex === undefined) return null;
|
|
185
|
+
const coll = collectionCallOf(calleeExpr.object);
|
|
186
|
+
if (!coll) return null;
|
|
187
|
+
return { method: calleeExpr.prop, coll, recordExpr: Array.isArray(args) ? args[argIndex] ?? null : null };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A storage READ expression: `<handle>.collection('<lit>').find(q).toArray()` / `.findOne(q)`. */
|
|
191
|
+
export function storageReadOf(expr) {
|
|
192
|
+
if (!isObj(expr) || expr.kind !== 'call' || expr.callee?.kind !== 'member') return null;
|
|
193
|
+
let e = expr;
|
|
194
|
+
if (STORAGE_READ_CHAIN_TAIL.includes(e.callee.prop)) e = e.callee.object;
|
|
195
|
+
if (!isObj(e) || e.kind !== 'call' || e.callee?.kind !== 'member') return null;
|
|
196
|
+
const cardinality = STORAGE_READ_METHODS[e.callee.prop];
|
|
197
|
+
if (!cardinality) return null;
|
|
198
|
+
const coll = collectionCallOf(e.callee.object);
|
|
199
|
+
if (!coll) return null;
|
|
200
|
+
return { method: e.callee.prop, cardinality, coll };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Every producer (a sink site that publishes to a queue or writes a store)
|
|
205
|
+
* and every consumer (an assignment whose source receives from a queue or
|
|
206
|
+
* reads a store) in the call graph. `sites` is graph-builder.js's
|
|
207
|
+
* `enumerateSinkSites(callGraph).sites`. Endpoints whose identity cannot be
|
|
208
|
+
* read are listed in `unjoined` with the reason, never silently dropped.
|
|
209
|
+
*/
|
|
210
|
+
export function collectBoundaryEndpoints(callGraph, sites) {
|
|
211
|
+
const producers = []; const consumers = []; const unjoined = [];
|
|
212
|
+
for (const site of sites || []) {
|
|
213
|
+
const cat = site.decision?.category;
|
|
214
|
+
if (cat === 'queue') {
|
|
215
|
+
const cmd = queueCommandOf(site.calleeExpr, site.args);
|
|
216
|
+
let input = cmd ? cmd.input : (site.args?.[0] ?? null);
|
|
217
|
+
let recordExpr = null;
|
|
218
|
+
if (cmd && cmd.role === 'send') recordExpr = propOf(input, cmd.record)?.value ?? null;
|
|
219
|
+
else for (const k of QUEUE_RECORD_KEYS) { const p = propOf(input, k); if (p) { recordExpr = p.value; break; } }
|
|
220
|
+
const id = queueIdentityOf(input, callGraph, site.file);
|
|
221
|
+
// A producer is kept whether or not its identity resolved: a declared
|
|
222
|
+
// contract names its producer by site, not by identity. Only a
|
|
223
|
+
// discovered join needs the identity, and only an identified
|
|
224
|
+
// producer takes part in one.
|
|
225
|
+
producers.push({ kind: 'queue', site, identity: id.identity, detail: id.detail ?? null, recordExpr: unwrapSerialization(recordExpr), provider: cmd?.provider ?? null });
|
|
226
|
+
if (!id.identity) unjoined.push({ side: 'producer', kind: 'queue', file: site.file, line: site.line, reason: id.reason });
|
|
227
|
+
} else if (cat === 'database') {
|
|
228
|
+
const w = storageWriteOf(site.calleeExpr, site.args);
|
|
229
|
+
if (!w) continue;
|
|
230
|
+
const id = storageIdentity(w.coll, callGraph, site.file);
|
|
231
|
+
producers.push({ kind: 'storage', site, identity: id.identity, detail: id.detail, recordExpr: w.recordExpr, method: w.method });
|
|
232
|
+
if (!id.identity) unjoined.push({ side: 'producer', kind: 'storage', file: site.file, line: site.line, reason: id.reason });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const fn of callGraph.functions.values()) {
|
|
236
|
+
for (const [nid, node] of Object.entries(fn.cfg?.nodes ?? {})) {
|
|
237
|
+
if (node.kind !== 'assign' || typeof node.target !== 'string' || node.target.includes('.') || !isObj(node.source) || node.source.kind !== 'call') continue;
|
|
238
|
+
const base = { file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null, target: node.target };
|
|
239
|
+
const cmd = queueCommandOf(node.source.callee, node.source.args);
|
|
240
|
+
if (cmd && cmd.role === 'receive') {
|
|
241
|
+
const id = queueIdentityOf(cmd.input, callGraph, fn.file);
|
|
242
|
+
const ep = { ...base, kind: 'queue', identity: id.identity, detail: id.detail ?? null, recordPath: cmd.recordPath, encoding: cmd.encoding, provider: cmd.provider };
|
|
243
|
+
if (id.identity) consumers.push(ep); else unjoined.push({ side: 'consumer', kind: 'queue', file: fn.file, line: base.line, reason: id.reason });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const r = storageReadOf(node.source);
|
|
247
|
+
if (r) {
|
|
248
|
+
const id = storageIdentity(r.coll, callGraph, fn.file);
|
|
249
|
+
const ep = { ...base, kind: 'storage', identity: id.identity, detail: id.detail, recordPath: '', cardinality: r.cardinality, method: r.method };
|
|
250
|
+
if (id.identity) consumers.push(ep); else unjoined.push({ side: 'consumer', kind: 'storage', file: fn.file, line: base.line, reason: id.reason });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { producers, consumers, unjoined };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const pathCovers = (a, b) => a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`);
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Which data-element ids crossed at a producer site, per record key.
|
|
261
|
+
* `siteHops` are the hops stamped with the site's scope and node id: the
|
|
262
|
+
* `write-out/call-arg` hops say which ids reached the call at all, the
|
|
263
|
+
* `production`/`selection` hops say which state key each id came from. A
|
|
264
|
+
* key's ids are the reaching ids whose contributing key its value expression
|
|
265
|
+
* references.
|
|
266
|
+
*/
|
|
267
|
+
export function recordShape(recordExpr, siteHops) {
|
|
268
|
+
const reached = new Set();
|
|
269
|
+
const byKey = new Map();
|
|
270
|
+
const contributions = [];
|
|
271
|
+
for (const h of siteHops || []) {
|
|
272
|
+
if (h.kind === 'write-out' && h.subKind === 'call-arg') reached.add(h.dataElementId);
|
|
273
|
+
// An identifier read is a `production/ident` hop, a field read a
|
|
274
|
+
// `selection/member` hop; both carry the contributing state key.
|
|
275
|
+
if ((h.kind === 'production' || h.kind === 'selection') && typeof h.fromPath === 'string') contributions.push({ path: h.fromPath, id: h.dataElementId });
|
|
276
|
+
}
|
|
277
|
+
if (!reached.size) return { basis: 'none', byKey, whole: new Set() };
|
|
278
|
+
const props = isObj(recordExpr) && recordExpr.kind === 'object' ? recordExpr.props || [] : null;
|
|
279
|
+
if (!props || props.some((p) => p.spread) || !props.length) return { basis: 'whole-record', byKey, whole: new Set(reached) };
|
|
280
|
+
for (const p of props) {
|
|
281
|
+
if (typeof p.key !== 'string' || p.key === '*') continue;
|
|
282
|
+
const refs = new Set();
|
|
283
|
+
// Outermost access paths only: `body.note` references `body.note`, not
|
|
284
|
+
// `body`, or every sibling field the record carries would be joined too.
|
|
285
|
+
walkExpr(p.value, (e, parent) => {
|
|
286
|
+
if (e.kind !== 'ident' && e.kind !== 'member') return;
|
|
287
|
+
if (parent && parent.kind === 'member' && parent.object === e) return;
|
|
288
|
+
const ap = accessPathOf(e);
|
|
289
|
+
if (ap && !ap.includes('*')) refs.add(ap);
|
|
290
|
+
});
|
|
291
|
+
const ids = new Set();
|
|
292
|
+
for (const c of contributions) if (reached.has(c.id) && [...refs].some((r) => pathCovers(r, c.path))) ids.add(c.id);
|
|
293
|
+
if (ids.size) byKey.set(p.key, ids);
|
|
294
|
+
}
|
|
295
|
+
return { basis: 'object-literal', byKey, whole: new Set() };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const joinIdOf = (parts) => `join:${createHash('sha256').update(parts.join('\u0000')).digest('hex').slice(0, 12)}`;
|
|
299
|
+
|
|
300
|
+
function mintSeed({ consumer, placement, seedPath, id, join }) {
|
|
301
|
+
const canonicalName = seedPath.slice(seedPath.lastIndexOf('.') + 1);
|
|
302
|
+
return {
|
|
303
|
+
file: consumer.file, qid: consumer.qid, nodeId: consumer.nodeId, line: consumer.line,
|
|
304
|
+
entryId: `boundary:${join.kind}`, seedPath, canonicalName, seedKind: placement,
|
|
305
|
+
category: join.kind === 'queue' ? 'queue-message' : 'database-read', coverageStatus: 'modeled', externality: 'unknown',
|
|
306
|
+
reason: `${join.kind} handoff ${join.provenance === 'manual' ? 'declared by contract ' + join.contractId : 'discovered from the literal identity ' + join.identity}; modeled, not observed`,
|
|
307
|
+
dataElementId: id, dataClasses: classifyDataElementName(canonicalName).classes,
|
|
308
|
+
boundary: {
|
|
309
|
+
joinId: join.joinId, kind: join.kind, identity: join.identity, provenance: join.provenance, contractId: join.contractId ?? null,
|
|
310
|
+
basis: join.basis, producer: join.producer, modeledInput: true, observed: false, exploited: false,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Plan the consumer seeds for every join the current hop stream supports.
|
|
317
|
+
* `minted` is the cross-round set of seed keys already handed out, so a
|
|
318
|
+
* round only reports what is new. Returns `{seeds, joins, unjoined, gaps}`.
|
|
319
|
+
*/
|
|
320
|
+
export function planBoundaryJoins({ endpoints, hops, contracts = [], callGraph, minted = new Set() }) {
|
|
321
|
+
const hopsBySite = new Map();
|
|
322
|
+
for (const h of hops || []) {
|
|
323
|
+
const k = `${h.scope}|${h.nodeId}`;
|
|
324
|
+
if (!hopsBySite.has(k)) hopsBySite.set(k, []);
|
|
325
|
+
hopsBySite.get(k).push(h);
|
|
326
|
+
}
|
|
327
|
+
const seeds = []; const joins = []; const unjoined = [...(endpoints.unjoined || [])]; const gaps = [];
|
|
328
|
+
const siteKey = (s) => `${s.qid}|${s.nodeId}`;
|
|
329
|
+
const shapeOf = (p) => recordShape(p.recordExpr, hopsBySite.get(siteKey(p.site)) ?? []);
|
|
330
|
+
const emit = (consumer, producer, placement, basePath, fieldMappings, join) => {
|
|
331
|
+
const shape = shapeOf(producer);
|
|
332
|
+
const fields = [];
|
|
333
|
+
const add = (path, id, from, to) => {
|
|
334
|
+
const key = `${consumer.qid}|${consumer.nodeId}|${path}|${id}`;
|
|
335
|
+
if (minted.has(key)) return;
|
|
336
|
+
minted.add(key);
|
|
337
|
+
seeds.push(mintSeed({ consumer, placement, seedPath: path, id, join: { ...join, basis: shape.basis } }));
|
|
338
|
+
fields.push({ from, to, dataElementId: id });
|
|
339
|
+
};
|
|
340
|
+
if (fieldMappings) {
|
|
341
|
+
for (const m of fieldMappings) {
|
|
342
|
+
const ids = shape.byKey.get(m.from) ?? (shape.basis === 'whole-record' ? shape.whole : new Set());
|
|
343
|
+
for (const id of ids) add([basePath, m.to].filter(Boolean).join('.'), id, m.from, m.to);
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
for (const [key, ids] of shape.byKey) for (const id of ids) add([basePath, key].filter(Boolean).join('.'), id, key, key);
|
|
347
|
+
for (const id of shape.whole) add(basePath, id, null, null);
|
|
348
|
+
}
|
|
349
|
+
joins.push({ ...join, basis: shape.basis, fields, consumer: { file: consumer.file, line: consumer.line, qid: consumer.qid, nodeId: consumer.nodeId }, reached: shape.whole.size + [...shape.byKey.values()].reduce((n, s) => n + s.size, 0) });
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Discovered joins: the same literal identity on both sides.
|
|
353
|
+
for (const c of endpoints.consumers) {
|
|
354
|
+
const partners = endpoints.producers.filter((p) => p.kind === c.kind && p.identity && p.identity === c.identity
|
|
355
|
+
&& !(p.kind === 'storage' && p.detail?.database && c.detail?.database && p.detail.database !== c.detail.database));
|
|
356
|
+
if (!partners.length) { unjoined.push({ side: 'consumer', kind: c.kind, file: c.file, line: c.line, identity: c.identity, reason: 'no-producer-in-scope' }); continue; }
|
|
357
|
+
for (const p of partners) {
|
|
358
|
+
const producer = { file: p.site.file, line: p.site.line, qid: p.site.qid, nodeId: p.site.nodeId };
|
|
359
|
+
const join = { joinId: joinIdOf([c.kind, c.identity, siteKey(p.site), `${c.qid}|${c.nodeId}`]), kind: c.kind, identity: c.identity, provenance: 'code', contractId: null, producer };
|
|
360
|
+
emit(c, p, 'after-node', [c.target, c.recordPath].filter(Boolean).join('.'), null, join);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Declared joins: a DAG-08 contract bound to a consumer parameter; the
|
|
364
|
+
// producer is the contract's producer site, matched by file and function.
|
|
365
|
+
const bound = contracts.length ? bindBoundaryContracts(contracts, callGraph) : { bindings: [], gaps: [], rejected: [] };
|
|
366
|
+
gaps.push(...bound.gaps.map((g) => ({ ...g, source: 'contract' })), ...bound.rejected.map((r) => ({ contractId: r.contractId, kind: 'rejected-contract', errors: r.errors, source: 'contract' })));
|
|
367
|
+
for (const b of bound.bindings) {
|
|
368
|
+
const contract = contracts.find((c) => c.id === b.contractId);
|
|
369
|
+
const psite = contract?.producer?.site;
|
|
370
|
+
if (!psite) { gaps.push({ contractId: b.contractId, kind: 'unbound-boundary', side: 'producer', reason: 'producer-site-required', detail: 'a lineage join needs the producer site to read which fields reached it', source: 'contract' }); continue; }
|
|
371
|
+
const norm = (f) => String(f || '').replace(/^\.\//, '').replace(/\\/g, '/');
|
|
372
|
+
const partners = endpoints.producers.filter((p) => p.kind === b.kind && norm(p.site.file) === norm(psite.file)
|
|
373
|
+
&& (() => { const fn = callGraph.functions.get(p.site.qid) ?? [...callGraph.functions.values()].find((f) => f.qid === p.site.qid); return fn && (fn.name === psite.function || String(fn.name).endsWith(`.${psite.function}`)); })());
|
|
374
|
+
if (!partners.length) { gaps.push({ contractId: b.contractId, kind: 'unbound-boundary', side: 'producer', reason: 'producer-site-not-a-' + b.kind + '-site', detail: `${psite.function} in ${psite.file}`, source: 'contract' }); continue; }
|
|
375
|
+
const consumer = { file: b.file, qid: b.qid, nodeId: null, line: b.line, target: b.param };
|
|
376
|
+
const recordPath = contract.consumer.site.recordPath || '';
|
|
377
|
+
for (const p of partners) {
|
|
378
|
+
const producer = { file: p.site.file, line: p.site.line, qid: p.site.qid, nodeId: p.site.nodeId };
|
|
379
|
+
const join = { joinId: joinIdOf([b.kind, b.contractId, siteKey(p.site), b.qid]), kind: b.kind, identity: b.channel, provenance: 'manual', contractId: b.contractId, producer };
|
|
380
|
+
emit(consumer, p, 'entry', [b.param, recordPath].filter(Boolean).join('.'), b.mappedFields, join);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return { seeds, joins, unjoined, gaps };
|
|
384
|
+
}
|
package/src/lineage/coverage.js
CHANGED
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
|
|
57
57
|
import { reclassifySink } from './sink-registry.js';
|
|
58
58
|
import { DEFAULTS as PATH_QUERY_DEFAULTS } from './path-query.js';
|
|
59
|
-
import { buildDataFlowGraph } from './graph-builder.js';
|
|
59
|
+
import { buildDataFlowGraph, boundaryCoverage } from './graph-builder.js';
|
|
60
60
|
// Milestone 2, Sub-project A, increment 1: `resolveDestination` composes
|
|
61
61
|
// with `resolveSiteDecision` below the same way it's imported here — a real
|
|
62
62
|
// ES module cycle (resolve-destination.js imports THREE things back from
|
|
@@ -438,6 +438,9 @@ export function buildCoverageLedger(built, opts = {}) {
|
|
|
438
438
|
|
|
439
439
|
degradedTerminals: built.degraded.length,
|
|
440
440
|
unresolvedDestinations,
|
|
441
|
+
// GA-03.3: present only when the build opted into handoff joins, so a
|
|
442
|
+
// ledger from a build that did not is unchanged.
|
|
443
|
+
...(built.boundaries ? { boundaries: boundaryCoverage(built.boundaries) } : {}),
|
|
441
444
|
|
|
442
445
|
paths: { enumerated: built.stats.pathsEnumerated, projected: built.stats.pathsProjected, truncatedQueries: built.stats.truncatedQueries },
|
|
443
446
|
budgets: { ...PATH_QUERY_DEFAULTS, ...(opts.budget ?? {}) },
|
package/src/lineage/driver.js
CHANGED
|
@@ -140,14 +140,31 @@ export function runFieldIdentityAnalysis(callGraph, opts = {}) {
|
|
|
140
140
|
// seeding hook. `opts.seedEntryState(fn)` returns the entry state `fn` is
|
|
141
141
|
// analyzed under; a falsy return means "no seed". With no hook, this IS
|
|
142
142
|
// `emptyState()` and every output is byte-identical to the pre-hook driver.
|
|
143
|
-
|
|
143
|
+
// GA-03.1 (0.195.24): the hook's answer is memoised per function, so it
|
|
144
|
+
// is still consulted exactly once per function even though the
|
|
145
|
+
// call-summary resolver below now reads it at every resolved call site.
|
|
146
|
+
const seedMemo = new Map();
|
|
147
|
+
const seedOf = opts.seedEntryState
|
|
148
|
+
? (fn) => {
|
|
149
|
+
if (!seedMemo.has(fn.qid)) seedMemo.set(fn.qid, opts.seedEntryState(fn) || null);
|
|
150
|
+
return seedMemo.get(fn.qid);
|
|
151
|
+
}
|
|
152
|
+
: null;
|
|
153
|
+
const entryStateOf = (fn) => (seedOf ? (seedOf(fn) || emptyState()) : emptyState());
|
|
144
154
|
const ctxFor = (fn) => {
|
|
145
155
|
const lookupCallee = createCallGraphLookup(callGraph, fn.file);
|
|
146
|
-
|
|
156
|
+
// GA-03.1 (0.195.24): the resolver receives the same seeding hook the
|
|
157
|
+
// driver uses, so a callee's own source seeds hold under every call
|
|
158
|
+
// context (summaries.js, createCallSummaryResolver). Without a hook the
|
|
159
|
+
// resolver is built exactly as before.
|
|
160
|
+
const resolveCallSummary = createCallSummaryResolver(cache, lookupCallee, seedOf ? { seedEntryState: seedOf } : {});
|
|
147
161
|
// Path provenance (Sub-project C, increment 3, section 13.7 item 14):
|
|
148
162
|
// thread opts.recordHop CONDITIONALLY so a caller that supplies none gets
|
|
149
163
|
// a byte-identical `{ resolveCallSummary }` object (Decision 7.2).
|
|
150
164
|
const ctx = opts.recordHop ? { resolveCallSummary, recordHop: opts.recordHop } : { resolveCallSummary };
|
|
165
|
+
// GA-03.1: `opts.seedAfterNode(qid, nodeId)` (source-seeding.js's
|
|
166
|
+
// seedAfterNodeFactory), consulted by engine.js after every CFG node.
|
|
167
|
+
if (typeof opts.seedAfterNode === 'function') ctx.seedAfterNode = opts.seedAfterNode;
|
|
151
168
|
// DAG-03: correlation version 2 labels hops with derivation, value-version
|
|
152
169
|
// and invocation evidence. Off unless asked for; needs a recorder.
|
|
153
170
|
if (opts.recordHop && resolveCorrelationVersion(opts.correlationVersion) === 2) ctx.correlationVersion = 2;
|