@clear-capabilities/agentic-security-scanner 0.172.1 → 0.173.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 +15 -0
- package/bin/agentic-security.js +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +135 -11
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +27 -27
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +28 -3
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +2 -0
- package/src/context-intelligence/config.js +3 -1
- package/src/context-intelligence/controls.js +49 -1
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +2 -2
- package/src/context-intelligence/review.js +68 -4
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +272 -20
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/util/string-memo.js +38 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
eab94f59f539296c01e17e2d914f419e131e2b4d1234865c011f755f11a64441 agentic-security.mjs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clear-capabilities/agentic-security-scanner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.173.0",
|
|
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",
|
|
@@ -67,6 +67,17 @@
|
|
|
67
67
|
"test": "node ../scripts/run-unit-tests.mjs",
|
|
68
68
|
"test:extras": "AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js && node --experimental-test-module-mocks --test test/fault-injection.test.js && node --experimental-test-module-mocks --test test/detector-fault-injection.test.js && node --experimental-test-module-mocks --test test/detector-fault-injection-k8s-admission.test.js && node --experimental-test-module-mocks --test test/lineage-fault-injection.test.js && npm run test:python",
|
|
69
69
|
"loop": "node ../scripts/loop-engineering/run.mjs",
|
|
70
|
+
"test:engineering-excellence": "node --test --test-concurrency=1 test/engineering-excellence/e0-01-inventory.test.js test/engineering-excellence/e0-02-baseline.test.js test/engineering-excellence/e0-03-contract.test.js test/engineering-excellence/e0-04-task-manifest.test.js test/engineering-excellence/e0-05-status-runner.test.js test/engineering-excellence/e0-06-backlog.test.js test/engineering-excellence/e1-01-dataset-provenance.test.js test/engineering-excellence/e1-02-splits.test.js test/engineering-excellence/e1-03-scoring.test.js test/engineering-excellence/e1-04-baseline-run.test.js test/engineering-excellence/e1-05-forensics.test.js test/engineering-excellence/e1-05-baseline-forensics.test.js test/engineering-excellence/e1-06-adversarial-controls.test.js test/engineering-excellence/e1-07-realcode-policy.test.js test/engineering-excellence/e1-07-baseline-gate.test.js test/engineering-excellence/e2-06-cross-language.test.js test/engineering-excellence/e2-08-suppression-attribution.test.js test/engineering-excellence/e2-09-semantic-gates.test.js test/engineering-excellence/e4-06-dci-real-app.test.js test/engineering-excellence/e6-08-perf-budget.test.js test/engineering-excellence/e8-02-e2e-demo.test.js test/engineering-excellence/e8-03-regression-report.test.js test/engineering-excellence/e8-04-docs.test.js test/engineering-excellence/e8-05-handoff.test.js test/engineering-excellence/e8-06-release-decision.test.js test/engineering-excellence/u01-ruby-path-resolver.test.js test/engineering-excellence/e1-08-scorecard.test.js test/engineering-excellence/e3-01-patch-routes.test.js test/engineering-excellence/e3-02-fix-verification-record.test.js test/engineering-excellence/e3-03-central-verifier.test.js test/engineering-excellence/e3-04-exploit-replay.test.js test/engineering-excellence/e3-05-functional-preservation.test.js test/engineering-excellence/e3-06-patch-policy.test.js test/engineering-excellence/e3-07-rollback-crash-safety.test.js test/engineering-excellence/e3-08-fix-benchmark.test.js test/engineering-excellence/e2-01-semantic-graph.test.js test/engineering-excellence/e2-02-cross-file-resolution.test.js test/engineering-excellence/e2-03-heap-fields-aliases.test.js test/engineering-excellence/e2-04-async-callbacks-frameworks.test.js test/engineering-excellence/e2-05-interprocedural-contexts.test.js test/engineering-excellence/e2-07-service-edges.test.js test/engineering-excellence/e4-01-dci-audit.test.js test/engineering-excellence/e4-02-invariants.test.js test/engineering-excellence/e4-03-cross-route.test.js test/engineering-excellence/e4-04-counterexample.test.js test/engineering-excellence/e4-05-llm-safeguards.test.js test/engineering-excellence/e4-07-dci-findings.test.js test/engineering-excellence/e6-01-telemetry.test.js test/engineering-excellence/e5-01-runtime-audit.test.js test/engineering-excellence/e5-02-build-identity.test.js test/engineering-excellence/e5-03-privacy-ingest.test.js test/engineering-excellence/e5-04-graph-reconciliation.test.js test/engineering-excellence/e5-05-exploitability-evidence.test.js test/engineering-excellence/e5-06-service-chain-graph.test.js test/engineering-excellence/e5-07-runtime-explanation.test.js test/engineering-excellence/e6-05-supervised-analyzers.test.js test/engineering-excellence/e6-06-determinism.test.js test/engineering-excellence/e6-07-adversarial-boundaries.test.js test/engineering-excellence/e6-03-shared-preprocessing.test.js test/engineering-excellence/e6-04-analysis-cache.test.js test/engineering-excellence/e7-01-evidence-contracts.test.js test/engineering-excellence/e7-03-release-package.test.js test/engineering-excellence/e7-04-compliance-truth.test.js test/engineering-excellence/e7-05-waivers-tenancy.test.js test/engineering-excellence/e7-02-provenance-coverage.test.js test/engineering-excellence/e7-06-fleet-exposure.test.js test/engineering-excellence/e7-07-perspectives.test.js test/engineering-excellence/e7-08-assurance-e2e.test.js test/engineering-excellence/e6-02-completion.test.js",
|
|
71
|
+
"ee:status": "node ../scripts/engineering-excellence/status.mjs --once",
|
|
72
|
+
"ee:init": "node ../scripts/engineering-excellence/init.mjs",
|
|
73
|
+
"ee:inventory": "node ../scripts/engineering-excellence/inventory.mjs",
|
|
74
|
+
"ee:contract": "node ../scripts/engineering-excellence/contract.mjs",
|
|
75
|
+
"ee:backlog": "node ../scripts/engineering-excellence/backlog.mjs",
|
|
76
|
+
"ee:dataset-audit": "node ../scripts/engineering-excellence/dataset-audit.mjs",
|
|
77
|
+
"ee:independent-baseline": "node ../scripts/engineering-excellence/independent-baseline.mjs",
|
|
78
|
+
"ee:dci-audit": "node ../scripts/engineering-excellence/dci-audit.mjs",
|
|
79
|
+
"ee:runtime-audit": "node ../scripts/engineering-excellence/runtime-audit.mjs",
|
|
80
|
+
"ee:ir-matrix": "node ../scripts/engineering-excellence/ir-matrix.mjs",
|
|
70
81
|
"test:loop-real-prd": "node --test ../scripts/loop-engineering/test/loop-real-prd.test.js",
|
|
71
82
|
"test:loop": "node --test --test-concurrency=1 ../scripts/loop-engineering/test/loop-manifest.test.js ../scripts/loop-engineering/test/loop-watchdog.test.js ../scripts/loop-engineering/test/loop-background.test.js ../scripts/loop-engineering/test/loop-dashboard.test.js ../scripts/loop-engineering/test/loop-evidence.test.js ../scripts/loop-engineering/test/loop-recovery.test.js ../scripts/loop-engineering/test/loop-remote.test.js ../scripts/loop-engineering/test/loop-runbook.test.js ../scripts/loop-engineering/test/loop-assurance-profile.test.js ../scripts/loop-engineering/test/loop-assurance-runtime.test.js ../scripts/loop-engineering/test/loop-bounds.test.js ../scripts/loop-engineering/test/loop-spend.test.js ../scripts/loop-engineering/test/loop-ci-attest.test.js",
|
|
72
83
|
"test:python": "node ../scripts/run-python-tests.mjs",
|
|
@@ -137,6 +148,9 @@
|
|
|
137
148
|
"bench:cve-replay:ci": "node ../bench/cve-replay/runner.mjs --ci",
|
|
138
149
|
"bench:cve-replay:check": "node ../bench/cve-replay/runner.mjs --check-baseline",
|
|
139
150
|
"bench:cve-replay:update-baseline": "node ../bench/cve-replay/runner.mjs --update-baseline",
|
|
151
|
+
"bench:realcode:report": "node ../bench/independent/realcode.mjs report",
|
|
152
|
+
"bench:realcode:check": "node ../bench/independent/realcode.mjs check --tier release",
|
|
153
|
+
"bench:realcode:check:quick": "node ../bench/independent/realcode.mjs check --tier quick",
|
|
140
154
|
"bench:independent:gate:check": "node ../scripts/independent-population-gate.mjs",
|
|
141
155
|
"bench:independent:update-gate-baseline": "node ../scripts/independent-population-gate.mjs --update-baseline",
|
|
142
156
|
"bench:router-replay": "node ../bench/router-replay/runner.mjs",
|
|
@@ -163,9 +177,12 @@
|
|
|
163
177
|
"bench:self-scan": "node ../bench/self-scan/measure.mjs",
|
|
164
178
|
"bench:self-scan:check": "node ../bench/self-scan/check.mjs",
|
|
165
179
|
"bench:nist-800-171-labels:check": "node ../bench/nist-800-171-labels/check.mjs",
|
|
166
|
-
"bench:mutation:check": "node ../bench/mutation/runner.mjs",
|
|
180
|
+
"bench:dci-real-app": "node ../bench/dci-real-app/runner.mjs", "bench:dci-real-app:check": "node ../bench/dci-real-app/runner.mjs --check", "ee:perf-budget": "node ../scripts/engineering-excellence/perf-budget.mjs", "ee:perf-budget:check": "node ../scripts/engineering-excellence/perf-budget.mjs --check", "ee:e2e-demo": "node ../scripts/engineering-excellence/e2e-demo.mjs", "ee:e2e-demo:check": "node ../scripts/engineering-excellence/e2e-demo.mjs --check", "ee:doc-captures": "node ../scripts/engineering-excellence/doc-captures.mjs", "ee:doc-captures:check": "node ../scripts/engineering-excellence/doc-captures.mjs --check", "ee:regression-report": "node ../scripts/engineering-excellence/regression-report.mjs", "ee:regression-report:check": "node ../scripts/engineering-excellence/regression-report.mjs --check", "ee:final-report": "node ../scripts/engineering-excellence/final-report.mjs", "ee:final-report:check": "node ../scripts/engineering-excellence/final-report.mjs --check", "bench:mutation:check": "node ../bench/mutation/runner.mjs",
|
|
167
181
|
"baseline:capture": "node ../scripts/baseline-capture.mjs",
|
|
168
182
|
"bench:protection-verdict:check": "node ../bench/protection-verdict/runner.mjs",
|
|
183
|
+
"bench:independent:forensics": "node ../bench/independent/forensics.mjs",
|
|
184
|
+
"bench:fix-verify": "node ../bench/fix-verification/runner.mjs",
|
|
185
|
+
"bench:fix-verify:check": "node ../bench/fix-verification/runner.mjs --check",
|
|
169
186
|
"bench:fix-correctness": "node ../bench/fix-correctness/runner.mjs",
|
|
170
187
|
"bench:iac-coverage": "node ../bench/iac-coverage/runner.mjs",
|
|
171
188
|
"bench:prompt-injection": "node ../bench/prompt-injection/runner.mjs",
|
|
@@ -241,7 +258,15 @@
|
|
|
241
258
|
"test:nixos-host": "node --test test/nix/nixos-host-runtime.test.js",
|
|
242
259
|
"test:language-gates": "node --test --test-concurrency=1 test/language/language-benchmark-gates.test.js",
|
|
243
260
|
"test:language-slow": "node --test --test-concurrency=1 test/language/language-doc-examples-metrics.test.js test/language/language-end-to-end.test.js test/language/language-package-ci.test.js test/language/language-scan-modes.test.js",
|
|
244
|
-
"test:chrome": "AGENTIC_SECURITY_CHROME_TESTS=1 node --test test/export-image.test.js test/server/cmd-dataflow-export.test.js"
|
|
261
|
+
"test:chrome": "AGENTIC_SECURITY_CHROME_TESTS=1 node --test test/export-image.test.js test/server/cmd-dataflow-export.test.js",
|
|
262
|
+
"ee:parse-reuse": "node ../scripts/engineering-excellence/parse-reuse-measure.mjs",
|
|
263
|
+
"ee:analysis-cache": "node ../scripts/engineering-excellence/analysis-cache-measure.mjs",
|
|
264
|
+
"ee:evidence-contracts": "node ../scripts/engineering-excellence/evidence-inventory.mjs",
|
|
265
|
+
"release:verify-package": "node ../scripts/verify-release-package.mjs",
|
|
266
|
+
"ee:completion": "node ../scripts/engineering-excellence/completion-analysis.mjs",
|
|
267
|
+
"ee:reproduce-timeouts": "node ../scripts/engineering-excellence/reproduce-timeouts.mjs",
|
|
268
|
+
"ee:provenance-measure": "node ../scripts/engineering-excellence/provenance-measure.mjs",
|
|
269
|
+
"ee:provenance-baseline": "node ../scripts/engineering-excellence/provenance-baseline.mjs"
|
|
245
270
|
},
|
|
246
271
|
"author": "Ross Young <ross@clearcapabilities.com>",
|
|
247
272
|
"license": "PolyForm-Internal-Use-1.0.0",
|
|
@@ -101,7 +101,8 @@ export async function adjudicate(candidates, opts = {}) {
|
|
|
101
101
|
origin: 'context-intelligence', questionPack: c.questionPack, witnesses: c.witnesses, witnessCount: c.witnesses.length,
|
|
102
102
|
sourceSpanEvidence: c.sourceSpanEvidence, scopeIntegrity: c.scopeIntegrity, reviewStatus: c.reviewStatus,
|
|
103
103
|
uncertainties: c.uncertainties, recommendedValidation: c.recommendedValidation,
|
|
104
|
-
|
|
104
|
+
...(c.deterministic ? { deterministic: c.deterministic } : {}),
|
|
105
|
+
note: c.deterministic ? 'Derived from the code analysis and a deterministic counterexample attempt; no model produced this. Severity comes from the confirmation tier only.' : 'A model hypothesis checked for citations, not a proof. Severity comes from the confirmation tier only.',
|
|
105
106
|
},
|
|
106
107
|
};
|
|
107
108
|
};
|
|
@@ -16,7 +16,8 @@ const BUILD = [
|
|
|
16
16
|
{ language: 'javascript/typescript', kind: 'http', frameworks: 'Express-style call registration (get/post/put/patch/delete/all, router.use, app.use)', status: 'verified', evidence: [T('build-js.test.js')], limits: 'Router mounts in another file and middleware factories are not followed (reported as router-mount-not-resolved); destructured and named imports are followed, re-exports and tsconfig path aliases are not.' },
|
|
17
17
|
{ language: 'python', kind: 'http', frameworks: 'decorator registration (Flask/FastAPI-style @app.route / @router.get), FastAPI Depends()/Security() guards', status: 'experimental', evidence: [T('build-python-haskell.test.js'), T('frameworks.test.js')], limits: 'Decorator guards are read from the decorator lines and Depends() guards from the signature (only dependencies named like an auth or user provider bind the principal); framework-level before_request hooks and Django URL conf are not modelled.' },
|
|
18
18
|
{ language: 'haskell', kind: 'http', frameworks: 'Scotty, WAI, Servant, Yesod (through the existing web model)', status: 'verified', evidence: [T('build-python-haskell.test.js'), T('frameworks.test.js')], limits: 'Static source analysis; the existing web model decides what a route is. Dynamic routes are reported unresolved.' },
|
|
19
|
-
{ language: '
|
|
19
|
+
{ language: 'php', kind: 'http', frameworks: 'Laravel Route:: registration (the entry point only)', status: 'experimental', evidence: ['test/engineering-excellence/e4-01-dci-audit.test.js'], limits: 'Measured by the E4-01 audit on one fixture: the routes resolve, but no control or sensitive operation was extracted from the handlers.' },
|
|
20
|
+
{ language: 'java/kotlin/c#/go/ruby/rust', kind: 'http', frameworks: 'whatever the engine route scan recognises', status: 'unsupported', evidence: [], limits: 'Measured by the E4-01 audit (docs/engineering-excellence/DCI_MATURITY.md) on one fixture per language (Spring, net/http, ASP.NET, Rails): no entry point was found, so no bundle is built.' },
|
|
20
21
|
{ language: 'any with a recognised surface', kind: 'webhook/queue/cron/upload/cli', frameworks: 'existing entry-point inventory', status: 'experimental', evidence: [T('build-js.test.js')], limits: 'Handler is the enclosing declaration of the surface; no framework wiring beyond that.' },
|
|
21
22
|
{ language: 'haskell', kind: 'compiler metadata import', frameworks: 'hie-export/1 JSON export of GHC declarations and references', status: 'experimental', evidence: [T('hie-adapter.test.js')], limits: 'Reads an export; never runs GHC or hiedb. The export is validated against the source snapshot. No export produced by a real GHC run is checked in.' },
|
|
22
23
|
{ language: 'any', kind: 'compiler metadata build', frameworks: 'automatic metadata generation', status: 'blocked', evidence: [], limits: 'Running a compiler on untrusted source needs a sandbox proof this build does not have (Template Haskell, custom Setup and plugins run code).' },
|
|
@@ -351,6 +351,8 @@ async function cmdReview(root, args, format) {
|
|
|
351
351
|
maxCalls: num(flag(args, 'max-calls')) || config.review.maxCalls,
|
|
352
352
|
maxWallSeconds: num(flag(args, 'max-wall-seconds')) || config.review.maxWallSeconds,
|
|
353
353
|
maxTokensPerJob: config.review.maxTokensPerJob,
|
|
354
|
+
maxTokensPerRepo: config.review.maxTokensPerRepo,
|
|
355
|
+
anonymizePrompts: config.review.anonymizePrompts,
|
|
354
356
|
maxRetries: config.review.maxRetries,
|
|
355
357
|
jobTimeoutMs: (num(flag(args, 'job-timeout-seconds')) || 120) * 1000,
|
|
356
358
|
maxCostUsd: num(flag(args, 'max-cost-usd')) ?? config.review.maxCostUsd,
|
|
@@ -22,7 +22,7 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
22
22
|
maxFiles: 3000,
|
|
23
23
|
semanticMetadata: { mode: 'off', haskellHieDir: null },
|
|
24
24
|
context: { profile: 'standard', maxTokens: null, pruneLowValue: true, maxSymbols: 60, maxDepth: 12 },
|
|
25
|
-
review: { enabled: false, allowRemote: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3 },
|
|
25
|
+
review: { enabled: false, allowRemote: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3, maxTokensPerRepo: null, anonymizePrompts: false },
|
|
26
26
|
replay: { contentAddressed: true, retention: 'inherit' },
|
|
27
27
|
});
|
|
28
28
|
|
|
@@ -71,6 +71,8 @@ export function normalizeConfig(raw) {
|
|
|
71
71
|
maxCostUsd: Number.isFinite(rv.maxCostUsd) && rv.maxCostUsd >= 0 ? rv.maxCostUsd : null,
|
|
72
72
|
maxTokensPerJob: clamp(rv.maxTokensPerJob, 1000, 400000, d.review.maxTokensPerJob),
|
|
73
73
|
maxRetries: clamp(rv.maxRetries, 0, 3, d.review.maxRetries),
|
|
74
|
+
maxTokensPerRepo: Number.isFinite(rv.maxTokensPerRepo) && rv.maxTokensPerRepo >= 1000 ? Math.trunc(rv.maxTokensPerRepo) : null,
|
|
75
|
+
anonymizePrompts: rv.anonymizePrompts === true,
|
|
74
76
|
},
|
|
75
77
|
replay: { contentAddressed: rp.contentAddressed !== false, retention: typeof rp.retention === 'string' ? rp.retention : 'inherit' },
|
|
76
78
|
};
|
|
@@ -139,6 +139,51 @@ function clip(s) { return s.trim().slice(0, 160); }
|
|
|
139
139
|
* lines the FILE's lines (0-based array)
|
|
140
140
|
* Returns [{ symbolId, line, category:'control'|'sensitive', kind, subkind, evidence, basis, rejectionSeen, text }]
|
|
141
141
|
*/
|
|
142
|
+
/**
|
|
143
|
+
* Blank the comments (and Python docstrings) of a source file, keeping every character position and newline, so line numbers do not move.
|
|
144
|
+
* The analysis looks for calls and comparisons in code; a comment that SAYS `ownerId: req.user.id` or `requireUser(req)` is prose, and prose is the
|
|
145
|
+
* easiest thing for a hostile or merely careless author to write. Strings are left alone (the principal and SQL rules already read around them).
|
|
146
|
+
*/
|
|
147
|
+
export function blankComments(text, file = '') {
|
|
148
|
+
const src = String(text || '');
|
|
149
|
+
const hash = /\.(?:py|rb|sh|php|pl|r)$/i.test(file);
|
|
150
|
+
const dashes = /\.l?hs$/i.test(file);
|
|
151
|
+
const cLike = !dashes && !/\.(?:py|rb|sh|pl|r)$/i.test(file);
|
|
152
|
+
const out = src.split('');
|
|
153
|
+
const n = src.length;
|
|
154
|
+
let i = 0;
|
|
155
|
+
const blank = (a, b) => { for (let k = a; k < b && k < n; k++) if (out[k] !== '\n') out[k] = ' '; };
|
|
156
|
+
while (i < n) {
|
|
157
|
+
const c = src[i]; const d = src[i + 1];
|
|
158
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
159
|
+
if (/\.py$/i.test(file) && src.startsWith(c.repeat(3), i)) {
|
|
160
|
+
const end = src.indexOf(c.repeat(3), i + 3); const stop = end < 0 ? n : end + 3;
|
|
161
|
+
blank(i, stop); i = stop; continue; // a docstring or triple-quoted block is prose
|
|
162
|
+
}
|
|
163
|
+
let j = i + 1;
|
|
164
|
+
while (j < n && src[j] !== c && !(src[j] === '\n' && c !== '`')) { if (src[j] === '\\') j++; j++; }
|
|
165
|
+
i = j + 1; continue;
|
|
166
|
+
}
|
|
167
|
+
if (cLike && c === '/' && d === '*') { const end = src.indexOf('*/', i + 2); const stop = end < 0 ? n : end + 2; blank(i, stop); i = stop; continue; }
|
|
168
|
+
if (cLike && c === '/' && d === '/' && (i === 0 || /[\s;,)}{\]]/.test(src[i - 1]))) { let j = src.indexOf('\n', i); if (j < 0) j = n; blank(i, j); i = j; continue; }
|
|
169
|
+
if (hash && c === '#' && !(file.endsWith('.php') && d === '[')) { let j = src.indexOf('\n', i); if (j < 0) j = n; blank(i, j); i = j; continue; }
|
|
170
|
+
if (dashes && c === '-' && d === '-' && !/[!#$%&*+./<=>?@\\^|~:]/.test(src[i + 2] || ' ')) { let j = src.indexOf('\n', i); if (j < 0) j = n; blank(i, j); i = j; continue; }
|
|
171
|
+
if (dashes && c === '{' && d === '-') { const end = src.indexOf('-}', i + 2); const stop = end < 0 ? n : end + 2; blank(i, stop); i = stop; continue; }
|
|
172
|
+
i++;
|
|
173
|
+
}
|
|
174
|
+
return out.join('');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function bodyAfterSignature(raw) {
|
|
178
|
+
const arrow = raw.indexOf('=>');
|
|
179
|
+
let rest = null;
|
|
180
|
+
if (arrow >= 0) rest = raw.slice(arrow + 2);
|
|
181
|
+
else { const m = /\)\s*\{(.*)$/.exec(raw); if (m) rest = m[1]; }
|
|
182
|
+
if (rest === null) return '';
|
|
183
|
+
const brace = /^\s*\{(.*)$/.exec(rest);
|
|
184
|
+
return brace ? brace[1] : rest;
|
|
185
|
+
}
|
|
186
|
+
|
|
142
187
|
export function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
143
188
|
const out = [];
|
|
144
189
|
const seen = new Set();
|
|
@@ -171,7 +216,10 @@ export function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
|
|
|
171
216
|
const calls = [];
|
|
172
217
|
const re = /([A-Za-z_$][\w$]*(?:(?:\.|::)[A-Za-z_$][\w$]*)*)\s*\(/g;
|
|
173
218
|
let m;
|
|
174
|
-
|
|
219
|
+
// A one-line handler (`app.get('/x', guard, async (req, res) => { res.json(await db.docs.findOne({ id })); })`) is its own declaration
|
|
220
|
+
// line, so skipping the whole line also skipped its body. Only the SIGNATURE is skipped: the calls after the arrow or the opening brace count.
|
|
221
|
+
const scanText = isDeclLine ? bodyAfterSignature(raw) : raw;
|
|
222
|
+
while ((m = re.exec(scanText))) calls.push(m[1]);
|
|
175
223
|
if (haskell && !isDeclLine) {
|
|
176
224
|
const hre = /\b([A-Z][\w.]*\.[a-z][\w']*|[a-z][\w']*)\b/g;
|
|
177
225
|
while ((m = hre.exec(raw))) if (AUTH_NAME.test(m[1]) || HS_SENSITIVE.test(m[1])) calls.push(m[1]);
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Deterministic counterexample validation for a candidate invariant violation (E4-04).
|
|
2
|
+
//
|
|
3
|
+
// A candidate (for example a cross-route inconsistency from E4-03) says "this route looks weaker than its sibling". This module tries to settle it
|
|
4
|
+
// WITHOUT a model, in layers, and reports where it got to as one of four states that are never blended:
|
|
5
|
+
//
|
|
6
|
+
// refuted the claim is contradicted: the control IS on the path, the route is declared public by policy, there is no sensitive operation
|
|
7
|
+
// on the resource, or an executed attempt by the attacker was denied
|
|
8
|
+
// unresolved the analysis cannot say: an unresolved call or a traversal limit could hide the control, or the fixture could not run
|
|
9
|
+
// plausible every edge is resolved and the path reaches the resource's sensitive operation with no control of that kind before it, but nothing
|
|
10
|
+
// was executed. This is static evidence only
|
|
11
|
+
// proven an authorized LOCAL request fixture ran the real handler code and the attacker, who does not own the object, read or changed the
|
|
12
|
+
// victim's object. Only an execution can produce this state
|
|
13
|
+
//
|
|
14
|
+
// MODEL OUTPUT NEVER ENTERS. This file imports nothing from the review or model paths and reads no prose: facts come from the code analysis, which
|
|
15
|
+
// skips comments, docstrings and string contents, so a comment that claims a check exists, or tells a reviewer to ignore the finding, cannot move a
|
|
16
|
+
// state. The evidence trace is a list of steps each naming its kind (`static` or `executed`), so a reader can see which layer decided.
|
|
17
|
+
//
|
|
18
|
+
// THE FIXTURE RUNNER executes handler code, so it is confined: a child process under Node's permission model with NO file access (the fixture's sources are loaded from memory), no
|
|
19
|
+
// child processes and no workers, a driver that removes `fetch` and refuses every builtin and package, a scrubbed environment, a wall-clock
|
|
20
|
+
// limit and capped output. Network is not part of Node's permission model, so it is blocked by the driver, not by the runtime: lower assurance. Express-style
|
|
21
|
+
// apps are supported through a small in-process shim for `express`, and `db` is a recording in-memory store the fixture supplies. Code that needs
|
|
22
|
+
// anything else (a real database, another module system, a missing dependency) ends as `unresolved`, never as `proven`.
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
|
|
26
|
+
export const STATES = Object.freeze(['proven', 'plausible', 'refuted', 'unresolved']);
|
|
27
|
+
const FIXTURE_TIMEOUT_MS = 8000;
|
|
28
|
+
const MAX_OUTPUT = 200_000;
|
|
29
|
+
const MAX_FIXTURE_BYTES = 100_000;
|
|
30
|
+
|
|
31
|
+
// ---- the fixture runner -----------------------------------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const DRIVER = String.raw`
|
|
34
|
+
'use strict';
|
|
35
|
+
const path = require('node:path');
|
|
36
|
+
const spec = JSON.parse(process.env.FIXTURE_SPEC);
|
|
37
|
+
const ops = [];
|
|
38
|
+
const store = JSON.parse(JSON.stringify(spec.store || {}));
|
|
39
|
+
const match = (doc, q) => Object.entries(q || {}).every(([k, v]) => (v && typeof v === 'object' && !Array.isArray(v)) ? true : doc[k] === v);
|
|
40
|
+
const coll = (name) => ({
|
|
41
|
+
async findOne(q) { ops.push({ op: 'findOne', c: name, q }); return (store[name] || []).find((d) => match(d, q)) || null; },
|
|
42
|
+
async find(q) { ops.push({ op: 'find', c: name, q }); return (store[name] || []).filter((d) => match(d, q)); },
|
|
43
|
+
async updateOne(q, u) { ops.push({ op: 'updateOne', c: name, q }); const d = (store[name] || []).find((x) => match(x, q)); if (d) Object.assign(d, (u && u.$set) || u); return { matchedCount: d ? 1 : 0 }; },
|
|
44
|
+
async deleteOne(q) { ops.push({ op: 'deleteOne', c: name, q }); const i = (store[name] || []).findIndex((x) => match(x, q)); if (i >= 0) store[name].splice(i, 1); return { deletedCount: i >= 0 ? 1 : 0 }; },
|
|
45
|
+
async insertOne(d) { ops.push({ op: 'insertOne', c: name }); (store[name] ||= []).push(d); return { insertedId: d.id }; },
|
|
46
|
+
});
|
|
47
|
+
global.db = new Proxy({}, { get: (_, name) => coll(String(name)) });
|
|
48
|
+
const routes = []; const mw = [];
|
|
49
|
+
const app = {};
|
|
50
|
+
for (const m of ['get', 'post', 'put', 'patch', 'delete']) app[m] = (p, ...hs) => { routes.push({ method: m.toUpperCase(), path: p, handlers: hs.flat() }); return app; };
|
|
51
|
+
app.use = (...a) => { const fn = a.find((x) => typeof x === 'function'); if (fn) mw.push(fn); return app; };
|
|
52
|
+
app.listen = () => app; app.set = () => app; app.disable = () => app;
|
|
53
|
+
const express = () => app; express.json = () => (q, s, n) => n(); express.urlencoded = () => (q, s, n) => n(); express.Router = () => app;
|
|
54
|
+
// The fixture's files are loaded from memory: nothing is read from disk, so the process needs no file permission at all, and require can only
|
|
55
|
+
// reach the shim for express and the fixture's own relative modules. Everything else, builtins included, is refused.
|
|
56
|
+
delete globalThis.fetch; delete globalThis.WebSocket;
|
|
57
|
+
const vm = require('node:vm');
|
|
58
|
+
const cache = new Map();
|
|
59
|
+
const norm = (p) => path.posix.normalize(p).replace(/^\.\//, '');
|
|
60
|
+
const fileFor = (from, request) => {
|
|
61
|
+
const base = path.posix.join(path.posix.dirname(from), request);
|
|
62
|
+
for (const c of [base, base + '.js', base + '.cjs', base + '/index.js']) if (Object.hasOwn(spec.files, norm(c))) return norm(c);
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
const load = (file) => {
|
|
66
|
+
if (cache.has(file)) return cache.get(file).exports;
|
|
67
|
+
const mod = { exports: {} }; cache.set(file, mod);
|
|
68
|
+
const req = (request) => {
|
|
69
|
+
if (request === 'express') return express;
|
|
70
|
+
if (request.startsWith('.')) { const f = fileFor(file, request); if (f) return load(f); throw Object.assign(new Error('no such fixture module ' + request), { code: 'FIXTURE_UNSUPPORTED' }); }
|
|
71
|
+
throw Object.assign(new Error('fixture runner does not provide module ' + request), { code: 'FIXTURE_UNSUPPORTED' });
|
|
72
|
+
};
|
|
73
|
+
const wrapper = '(function (exports, require, module, __filename, __dirname) {' + spec.files[file] + '\n})';
|
|
74
|
+
vm.runInThisContext(wrapper, { filename: file })(mod.exports, req, mod, file, path.posix.dirname(file));
|
|
75
|
+
return mod.exports;
|
|
76
|
+
};
|
|
77
|
+
const matchRoute = (r, method, url) => {
|
|
78
|
+
if (r.method !== method) return null;
|
|
79
|
+
const a = r.path.split('/'); const b = url.split('/');
|
|
80
|
+
if (a.length !== b.length) return null;
|
|
81
|
+
const params = {};
|
|
82
|
+
for (let i = 0; i < a.length; i++) { if (a[i].startsWith(':')) params[a[i].slice(1)] = b[i]; else if (a[i] !== b[i]) return null; }
|
|
83
|
+
return params;
|
|
84
|
+
};
|
|
85
|
+
(async () => {
|
|
86
|
+
let result;
|
|
87
|
+
try {
|
|
88
|
+
load(norm(spec.entry));
|
|
89
|
+
const req0 = spec.request;
|
|
90
|
+
const hit = routes.map((r) => ({ r, params: matchRoute(r, req0.method, req0.path) })).find((x) => x.params);
|
|
91
|
+
if (!hit) { result = { error: 'no-route', detail: req0.method + ' ' + req0.path }; }
|
|
92
|
+
else {
|
|
93
|
+
const user = spec.user ? JSON.parse(JSON.stringify(spec.user)) : undefined;
|
|
94
|
+
const req = { method: req0.method, params: hit.params, query: req0.query || {}, body: req0.body || {}, headers: {}, user, get: () => undefined };
|
|
95
|
+
let done; const finished = new Promise((r) => { done = r; });
|
|
96
|
+
const res = { statusCode: 200, body: undefined, sent: false,
|
|
97
|
+
status(c) { this.statusCode = c; return this; }, json(b) { this.body = b; this.sent = true; done(); return this; }, send(b) { this.body = b; this.sent = true; done(); return this; },
|
|
98
|
+
sendStatus(c) { this.statusCode = c; this.sent = true; done(); return this; }, set() { return this; }, end() { this.sent = true; done(); return this; }, type() { return this; }, locals: {} };
|
|
99
|
+
const chain = [...mw, ...hit.r.handlers];
|
|
100
|
+
let i = 0;
|
|
101
|
+
const next = (err) => { if (err) { res.statusCode = 500; res.sent = true; done(); return; } const h = chain[i++]; if (!h) { done(); return; } try { const p = h(req, res, next); if (p && typeof p.then === 'function') p.catch((e) => { res.statusCode = 500; res.body = String(e && e.message); res.sent = true; done(); }); } catch (e) { res.statusCode = 500; res.body = String(e && e.message); res.sent = true; done(); } };
|
|
102
|
+
next();
|
|
103
|
+
await Promise.race([finished, new Promise((r) => setTimeout(r, 2000))]);
|
|
104
|
+
result = { status: res.statusCode, body: res.body, sent: res.sent, ops, store };
|
|
105
|
+
}
|
|
106
|
+
} catch (e) { result = { error: e && e.code === 'FIXTURE_UNSUPPORTED' ? 'unsupported' : 'load-failed', detail: String(e && e.message).slice(0, 300) }; }
|
|
107
|
+
process.stdout.write('@@RESULT@@' + JSON.stringify(result));
|
|
108
|
+
})();
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run one request against the real handler code under confinement.
|
|
113
|
+
* @param {{files:Record<string,string>, entry:string, request:{method:string,path:string,body?:object,query?:object}, user?:object, store?:object}} f
|
|
114
|
+
* @returns {{ok:boolean, status?:number, body?:any, ops?:object[], store?:object, error?:string, detail?:string}}
|
|
115
|
+
*/
|
|
116
|
+
export function runFixture(f) {
|
|
117
|
+
const total = Object.values(f.files || {}).reduce((n, t) => n + String(t).length, 0);
|
|
118
|
+
if (total > MAX_FIXTURE_BYTES) return { ok: false, error: 'too-large', detail: `${total} bytes of fixture source` };
|
|
119
|
+
for (const rel of Object.keys(f.files || {})) if (path.isAbsolute(rel) || rel.split('/').includes('..')) return { ok: false, error: 'bad-path', detail: rel };
|
|
120
|
+
// `--permission` with no allow flags: no file reads or writes, no child processes, no workers. The driver comes in through -e and the fixture's
|
|
121
|
+
// files through the environment, so nothing needs the disk. Network is not covered by the permission model; the driver removes `fetch` and
|
|
122
|
+
// refuses every builtin and package, which is the whole of its network reach.
|
|
123
|
+
const r = spawnSync(process.execPath, ['--permission', '-e', DRIVER], {
|
|
124
|
+
encoding: 'utf8', timeout: FIXTURE_TIMEOUT_MS, maxBuffer: MAX_OUTPUT,
|
|
125
|
+
env: { PATH: '/usr/bin:/bin', FIXTURE_SPEC: JSON.stringify({ files: f.files, entry: f.entry, request: f.request, user: f.user, store: f.store }) },
|
|
126
|
+
});
|
|
127
|
+
if (r.error) return { ok: false, error: r.error.code === 'ETIMEDOUT' ? 'timeout' : 'spawn-failed', detail: String(r.error.message).slice(0, 200) };
|
|
128
|
+
const out = String(r.stdout || ''); const i = out.indexOf('@@RESULT@@');
|
|
129
|
+
if (i < 0) return { ok: false, error: 'no-result', detail: String(r.stderr || '').slice(0, 300) };
|
|
130
|
+
let parsed; try { parsed = JSON.parse(out.slice(i + 10)); } catch { return { ok: false, error: 'bad-result' }; }
|
|
131
|
+
return parsed.error ? { ok: false, error: parsed.error, detail: parsed.detail } : { ok: true, ...parsed };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Make the attacker's request against the real handler code and say what happened to the victim's object. Used by assessCandidate and, after a patch,
|
|
136
|
+
* by the final verification of a fix (which must execute even when a static look already says the control is present).
|
|
137
|
+
* @returns {{state:'proven'|'refuted'|'unresolved', trace:object[], reasons:string[], reason?:string, status?:number}}
|
|
138
|
+
*/
|
|
139
|
+
export function executeAttempt(candidate, fx) {
|
|
140
|
+
const trace = [];
|
|
141
|
+
const method = fx.method || (candidate.operations?.includes('delete') ? 'DELETE' : candidate.operations?.includes('update') ? 'PUT' : 'GET');
|
|
142
|
+
const attack = runFixture({ files: fx.files, entry: fx.entry, request: { method, path: fx.path, body: fx.body }, user: fx.attacker.user, store: fx.store });
|
|
143
|
+
if (!attack.ok) { trace.push(step('executed', 'attacker request', `${attack.error}: ${attack.detail || ''}`)); return { state: 'unresolved', trace, reasons: [`the fixture could not run (${attack.error}); the claim stays unproven`], reason: `the fixture could not run (${attack.error}); the claim stays unproven` }; }
|
|
144
|
+
trace.push(step('executed', 'attacker request', `${method} ${fx.path} as ${JSON.stringify(fx.attacker.user)} -> ${attack.status}`));
|
|
145
|
+
const marker = fx.victim.marker;
|
|
146
|
+
const leaked = marker ? JSON.stringify(attack.body ?? '').includes(marker) : attack.status >= 200 && attack.status < 300 && attack.body != null;
|
|
147
|
+
const changed = JSON.stringify(attack.store?.[fx.victim.collection] || []) !== JSON.stringify(fx.store[fx.victim.collection] || []);
|
|
148
|
+
if (leaked || changed) {
|
|
149
|
+
trace.push(step('executed', 'effect on the victim\'s object', leaked ? 'the attacker received the victim\'s data' : 'the victim\'s object was modified by the attacker'));
|
|
150
|
+
if (fx.siblingPath) {
|
|
151
|
+
const sib = runFixture({ files: fx.files, entry: fx.entry, request: { method: fx.siblingMethod || 'GET', path: fx.siblingPath }, user: fx.attacker.user, store: fx.store });
|
|
152
|
+
if (sib.ok) trace.push(step('executed', 'the enforcing sibling, same attacker', `${fx.siblingMethod || 'GET'} ${fx.siblingPath} -> ${sib.status}${marker && !JSON.stringify(sib.body ?? '').includes(marker) ? ' (victim data withheld)' : ''}`));
|
|
153
|
+
}
|
|
154
|
+
return { state: 'proven', trace, reasons: ['an executed local request let a principal who does not own the object ' + (leaked ? 'read' : 'change') + ' it'], status: attack.status };
|
|
155
|
+
}
|
|
156
|
+
trace.push(step('executed', 'effect on the victim\'s object', 'none: the attacker was denied or got nothing'));
|
|
157
|
+
return { state: 'refuted', trace, reasons: [`an executed attempt by the attacker was denied (status ${attack.status})`], status: attack.status };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- the assessment ---------------------------------------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
const step = (kind, what, detail = null) => ({ kind, what, ...(detail ? { detail } : {}) });
|
|
163
|
+
const sameBundle = (b, route) => b.entrypoint && b.entrypoint.label === route;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {{route:string, resource:string, dimension:string, operations?:string[], enforcedAt?:object[], waivedBy?:string[]}} candidate e.g. a finding from findCrossRouteInconsistencies
|
|
167
|
+
* @param {{bundles:object[], fixture?:{files:Record<string,string>, entry:string, victim:{user:object, ownsId:string, collection:string, marker?:string}, attacker:{user:object}, store:object, path:string, method?:string, body?:object, siblingPath?:string, siblingMethod?:string}}} ctx
|
|
168
|
+
* @returns {{state:'proven'|'plausible'|'refuted'|'unresolved', trace:object[], reasons:string[]}}
|
|
169
|
+
*/
|
|
170
|
+
export function assessCandidate(candidate, ctx) {
|
|
171
|
+
const trace = [];
|
|
172
|
+
const fail = (state, reason) => ({ state, trace, reasons: [reason] });
|
|
173
|
+
const bundle = (ctx.bundles || []).find((b) => sameBundle(b, candidate.route));
|
|
174
|
+
if (!bundle || !bundle.manifest) { trace.push(step('static', 'locate the route bundle', 'no bundle for this route')); return fail('unresolved', `no analysed bundle for ${candidate.route}`); }
|
|
175
|
+
trace.push(step('static', 'locate the route bundle', bundle.entrypoint.id));
|
|
176
|
+
|
|
177
|
+
if (candidate.waivedBy && candidate.waivedBy.length) { trace.push(step('static', 'check policy', `public-by-design: ${candidate.waivedBy.join(', ')}`)); return fail('refuted', `declared public by ${candidate.waivedBy.join(', ')}`); }
|
|
178
|
+
|
|
179
|
+
const facts = bundle.reach?.facts || [];
|
|
180
|
+
const sens = facts.filter((f) => f.category === 'sensitive');
|
|
181
|
+
if (!sens.length) { trace.push(step('static', 'find a sensitive operation', 'none on the path')); return fail('refuted', 'no sensitive operation is reachable from this route, so there is nothing to protect'); }
|
|
182
|
+
const first = Math.min(...sens.map((f) => f.effLine));
|
|
183
|
+
const kinds = candidate.dimension === 'ownership' ? ['ownership'] : [candidate.dimension];
|
|
184
|
+
const before = facts.filter((f) => f.category === 'control' && kinds.includes(f.kind) && (f.effLine < first || (f.basis === 'query-filter' && f.effLine <= first)));
|
|
185
|
+
if (before.length) { trace.push(step('static', `look for ${candidate.dimension} before the first sensitive operation`, `found at ${before[0].file}:${before[0].line}`)); return fail('refuted', `${candidate.dimension} is enforced at ${before[0].file}:${before[0].line} before the sensitive operation`); }
|
|
186
|
+
trace.push(step('static', `look for ${candidate.dimension} before the first sensitive operation`, 'none'));
|
|
187
|
+
|
|
188
|
+
const hidden = (bundle.manifest.unknowns || []).some((u) => u.material) || bundle.reach?.capped;
|
|
189
|
+
if (hidden) { trace.push(step('static', 'check for unresolved calls', 'a material unresolved call or a traversal limit')); return fail('unresolved', 'an unresolved call or a traversal limit could hide the control'); }
|
|
190
|
+
trace.push(step('static', 'check for unresolved calls', 'none'));
|
|
191
|
+
|
|
192
|
+
const fx = ctx.fixture;
|
|
193
|
+
if (!fx) return { state: 'plausible', trace, reasons: ['every edge on the path is resolved and no control precedes the sensitive operation; nothing was executed'] };
|
|
194
|
+
|
|
195
|
+
const ex = executeAttempt(candidate, fx);
|
|
196
|
+
trace.push(...ex.trace);
|
|
197
|
+
if (ex.state === 'unresolved') return fail('unresolved', ex.reason);
|
|
198
|
+
if (ex.state === 'proven') return { state: 'proven', trace, reasons: ex.reasons };
|
|
199
|
+
return { state: 'refuted', trace, reasons: ex.reasons };
|
|
200
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Cross-route consistency (E4-03): does every route that touches a resource enforce what its sibling routes enforce?
|
|
2
|
+
//
|
|
3
|
+
// The classic authorization bug is not a missing check on the only route; it is a check on `GET /orders/:id` and none on `PUT /orders/:id` or
|
|
4
|
+
// `DELETE /orders/:id`. A per-route review sees each handler as fine. This compares, per resource, the controls the routes carry, using the facts
|
|
5
|
+
// DCI already extracted along route -> middleware -> handler -> service -> persistence, so a check made in a service layer counts for the route that
|
|
6
|
+
// reaches it.
|
|
7
|
+
//
|
|
8
|
+
// WHAT IT CLAIMS. For a resource touched by two or more routes, a control (authentication, authorization-role, ownership, tenant-scope) that at
|
|
9
|
+
// least one route enforces before its sensitive operation and another does not is an INCONSISTENCY. It is evidence that the missing route is
|
|
10
|
+
// weaker than its sibling, not proof of a vulnerability: that needs a counterexample (E4-04).
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT REFUSES TO CLAIM.
|
|
13
|
+
// - A route whose control could hide behind an unresolved call or a traversal limit is `unknown`, never "missing": absence of evidence in an
|
|
14
|
+
// incomplete context is not absence of the control, and dynamic dispatch is not proof of safety either.
|
|
15
|
+
// - A route that an invariant declares public-by-design for that control is SUPPRESSED, with the policy id and reason kept.
|
|
16
|
+
// - A resource touched by a single route has nothing to compare and produces nothing.
|
|
17
|
+
// - The resource name comes from the text of the data operation (collection, table or model); an operation whose target cannot be named is
|
|
18
|
+
// left out and counted, not guessed.
|
|
19
|
+
import { evaluateInvariants } from './invariants.js';
|
|
20
|
+
|
|
21
|
+
export const CONSISTENCY_DIMENSIONS = Object.freeze(['authentication', 'authorization-role', 'ownership', 'tenant-scope']);
|
|
22
|
+
|
|
23
|
+
const SQL_TARGET = /\b(?:FROM|INTO|UPDATE)\s+[`"']?([A-Za-z_][\w.]*)/i;
|
|
24
|
+
const COLLECTION = /\bcollection\s*\(\s*['"`]([\w.-]+)['"`]\s*\)/;
|
|
25
|
+
const DB_MEMBER = /\b(?:db|database|mongo|prisma|knex|models?)\s*\.\s*([A-Za-z_]\w*)\s*\.\s*(?:find|findOne|findMany|findById|findAll|findUnique|findFirst|insert|insertOne|insertMany|create|update|updateOne|updateMany|delete|deleteOne|deleteMany|remove|save|upsert|count|aggregate|get)\b/;
|
|
26
|
+
const MODEL = /\b([A-Z][A-Za-z0-9]+)\s*\.\s*(?:find|findOne|findAll|findByPk|findById|findUnique|create|update|destroy|delete|remove|save|where|objects)\b/;
|
|
27
|
+
const REPO_CALL = /\b([a-z][A-Za-z0-9]*?)(?:Repo|Repository|Dao|Store)\s*\.\s*\w+\s*\(/;
|
|
28
|
+
|
|
29
|
+
/** Resource key: lowercase, singular-ish (`Orders`, `order`, `orders` all become `order`). */
|
|
30
|
+
export function resourceKey(text) {
|
|
31
|
+
const t = String(text || '');
|
|
32
|
+
const m = COLLECTION.exec(t) || DB_MEMBER.exec(t) || SQL_TARGET.exec(t) || MODEL.exec(t) || REPO_CALL.exec(t);
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
const raw = m[1].split('.').pop().toLowerCase();
|
|
35
|
+
if (!raw || ['db', 'models', 'model', 'table', 'tables', 'users_table_placeholder'].includes(raw)) return null;
|
|
36
|
+
return raw.replace(/ies$/, 'y').replace(/(?<!s)s$/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function operationOf(fact) {
|
|
40
|
+
const t = String(fact.text || '');
|
|
41
|
+
if (/\b(?:delete|remove|destroy|drop)\w*/i.test(t)) return 'delete';
|
|
42
|
+
if (/\b(?:update|set|patch|save|upsert|replace)\w*/i.test(t)) return 'update';
|
|
43
|
+
if (/\b(?:insert|create|add)\w*/i.test(t)) return 'create';
|
|
44
|
+
return fact.subkind === 'write' ? 'update' : 'read';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stateOf(bundle, dim) {
|
|
48
|
+
const facts = bundle.reach?.facts || [];
|
|
49
|
+
const sens = facts.filter((f) => f.category === 'sensitive');
|
|
50
|
+
const first = sens.length ? Math.min(...sens.map((f) => f.effLine)) : null;
|
|
51
|
+
const kinds = dim === 'ownership' ? ['ownership'] : [dim];
|
|
52
|
+
const ctl = facts.filter((f) => f.category === 'control' && kinds.includes(f.kind) && (first === null || f.effLine < first || (f.basis === 'query-filter' && f.effLine <= first)));
|
|
53
|
+
if (ctl.length) return { state: 'enforced', evidence: ctl.slice(0, 3).map((f) => ({ file: f.file, line: f.line, basis: f.basis, evidence: f.evidence })) };
|
|
54
|
+
const prot = bundle.manifest?.protection?.[dim === 'tenant-scope' ? 'ownership' : dim];
|
|
55
|
+
if (prot && prot.state === 'present' && dim !== 'tenant-scope' && dim !== 'ownership') return { state: 'enforced', evidence: prot.evidence || [] };
|
|
56
|
+
const unresolved = (bundle.manifest?.unknowns || []).some((u) => u.material) || bundle.reach?.capped || prot?.state === 'not_resolved' || prot?.state === 'omitted_due_to_budget';
|
|
57
|
+
return unresolved ? { state: 'unknown' } : { state: 'absent' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {{entrypoint:object, manifest:object, reach:object}[]} bundles
|
|
62
|
+
* @param {{invariants?:object, now?:Date}} [opts] an invariants document: its public-by-design waivers suppress findings
|
|
63
|
+
*/
|
|
64
|
+
export function findCrossRouteInconsistencies(bundles, opts = {}) {
|
|
65
|
+
const now = opts.now || new Date();
|
|
66
|
+
const built = bundles.filter((b) => b.manifest && b.reach);
|
|
67
|
+
const byResource = new Map();
|
|
68
|
+
let unnamed = 0;
|
|
69
|
+
for (const b of built) {
|
|
70
|
+
const sens = (b.reach.facts || []).filter((f) => f.category === 'sensitive' && String(f.kind).startsWith('data-'));
|
|
71
|
+
const touched = new Map();
|
|
72
|
+
for (const f of sens) {
|
|
73
|
+
const key = resourceKey(f.text);
|
|
74
|
+
if (!key) { unnamed++; continue; }
|
|
75
|
+
if (!touched.has(key)) touched.set(key, new Set());
|
|
76
|
+
touched.get(key).add(operationOf(f));
|
|
77
|
+
}
|
|
78
|
+
for (const [key, ops] of touched) {
|
|
79
|
+
if (!byResource.has(key)) byResource.set(key, []);
|
|
80
|
+
byResource.get(key).push({ bundle: b, ops: [...ops].sort() });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const findings = []; const suppressed = []; const unknown = [];
|
|
84
|
+
const waiver = (b, dim) => {
|
|
85
|
+
if (!opts.invariants) return null;
|
|
86
|
+
const r = evaluateInvariants({ ...opts.invariants, invariants: [...opts.invariants.invariants, { id: 'PROBE-DIM', title: 'probe', rule: 'requires', appliesTo: { routes: ['**'] }, requires: [dim] }] }, [b], { now });
|
|
87
|
+
const probe = r.results.find((x) => x.invariantId === 'PROBE-DIM');
|
|
88
|
+
return probe && probe.verdict === 'public_by_policy' ? probe.waivedBy : null;
|
|
89
|
+
};
|
|
90
|
+
for (const [resource, entries] of [...byResource].sort(([a], [c]) => (a < c ? -1 : 1))) {
|
|
91
|
+
const routes = [...new Map(entries.map((e) => [e.bundle.entrypoint.id, e])).values()];
|
|
92
|
+
if (routes.length < 2) continue;
|
|
93
|
+
for (const dim of CONSISTENCY_DIMENSIONS) {
|
|
94
|
+
const states = routes.map((r) => ({ r, ...stateOf(r.bundle, dim) }));
|
|
95
|
+
const enforced = states.filter((s) => s.state === 'enforced');
|
|
96
|
+
if (!enforced.length) continue;
|
|
97
|
+
for (const s of states) {
|
|
98
|
+
if (s.state === 'enforced') continue;
|
|
99
|
+
const ep = s.r.bundle.entrypoint;
|
|
100
|
+
const base = { resource, dimension: dim, route: ep.label, file: ep.rootSpan?.file || ep.registrationSite?.file || '', operations: s.r.ops, enforcedAt: enforced.map((e) => ({ route: e.r.bundle.entrypoint.label, operations: e.r.ops, evidence: e.evidence })) };
|
|
101
|
+
if (s.state === 'unknown') { unknown.push({ ...base, reason: 'an unresolved call or a traversal limit could hide the control; dynamic dispatch is not proof of absence' }); continue; }
|
|
102
|
+
const w = waiver(s.r.bundle, dim);
|
|
103
|
+
if (w) { suppressed.push({ ...base, waivedBy: w, reason: 'declared public-by-design for this control' }); continue; }
|
|
104
|
+
const klass = dim === 'tenant-scope' ? 'cross-tenant' : dim === 'ownership' ? 'idor' : 'missing-control';
|
|
105
|
+
findings.push({ id: `xroute:${resource}:${dim}:${ep.id}`, class: klass, ...base, confidence: 'inconsistency', explanation: `${enforced.map((e) => e.r.bundle.entrypoint.label).join(', ')} enforce ${dim} on ${resource}; ${ep.label} (${s.r.ops.join('/')}) does not` });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
findings.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
110
|
+
return { findings, suppressed, unknown, resourcesCompared: [...byResource].filter(([, e]) => new Set(e.map((x) => x.bundle.entrypoint.id)).size >= 2).map(([k]) => k).sort(), operationsWithoutResource: unnamed };
|
|
111
|
+
}
|