From ebad83009ea8a24cf04a6d1fd26d4573178cff07 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:41:32 +0200 Subject: [PATCH] feat(pre-push-safety-net): connect change context to ranked questions --- .../implementation-group-3.md | 36 + .kiro/specs/pre-push-safety-net/spec.json | 7 +- .kiro/specs/pre-push-safety-net/tasks.md | 5 +- .../2026-09-13-change-context-smoke.json | 942 ++++++++++++++++++ ops/README.md | 12 + ops/smoke_context.py | 120 +++ ops/smoke_context_boundaries.py | 64 ++ src/openultrasast/cpg/queries/taint.sc | 52 +- src/openultrasast/model/regions.py | 82 +- src/openultrasast/model/scan.py | 96 +- tests/test_model_change_context.py | 171 ++++ 11 files changed, 1570 insertions(+), 17 deletions(-) create mode 100644 benchmarks/measurements/2026-09-13-change-context-smoke.json create mode 100644 ops/smoke_context.py create mode 100644 ops/smoke_context_boundaries.py create mode 100644 tests/test_model_change_context.py diff --git a/.kiro/specs/pre-push-safety-net/implementation-group-3.md b/.kiro/specs/pre-push-safety-net/implementation-group-3.md index 406c8dc..c476c80 100644 --- a/.kiro/specs/pre-push-safety-net/implementation-group-3.md +++ b/.kiro/specs/pre-push-safety-net/implementation-group-3.md @@ -90,3 +90,39 @@ This manually declared entry-region smoke does not validate mapper discovery, PMPro/NodeGoat replay, capability admission or hook latency. Nine of 27 tasks are now complete; tasks 3.3–3.4 remain for this group. + +## Task 3.3 evidence + +Independent review APPROVED after one required repair; verification status VERIFIED, +claim type TASK. The fresh reviewer suite passed 1,128 tests, nine skipped, in +105.95 seconds. Global Ruff, formatting (224 files), mypy (108 source files) and +diff checks passed. The context suite contains 23 focused cases. + +The generic driver accepts `change_context` and retains its enriched form. Existing +taint evidence optionally projects full first-party method paths/ranges for entry, +callee, same-file field, known hook and sanitizer context. Decoded snapshot changes +and lexical anchors attach to exact questions without changing ranking weights or +introducing another scope selector. Missing base, locations, empty projections, +dynamic/external/depth dependencies and unsupported configuration/deleted-dependency +projections remain explicit gaps. Missing context prevents tier-zero pruning and +complete-negative outcomes; raw answers remain available. + +Review found that broad source/sink/sanitizer text matching could mistakenly exempt +an unknown consumer such as `unknown(req.body)` from missing-context reporting. +The production-query regression reproduced exit 1 with that question incorrectly +pruned as tier zero. The task-local repair restricts the exemption to directly +invoked fact-modeled operations. Existing detector matchers are unchanged. After +repair, consumers of a source, nested sink and sanitizer name all remain unresolved; +a direct modeled sink completes, and the direct sanitizer retains tier zero without +a false context gap. + +`benchmarks/measurements/2026-09-13-change-context-smoke.json` retains the final image +and matching backend/driver/regions/snapshot/query hashes, plus both runtime checks. +Real Git histories remove a caller guard while keeping helper code unchanged. PHP +reads 124 source bytes and reaches `helper.php:2:helper` in 63.48 seconds; JavaScript +reads 133 bytes and reaches `helper.js:1:helper` through an ES-module import in +39.63 seconds. Both preserve the dirty checkout/index and remove snapshot scratch. +These are controlled change-to-question/witness checks, not proof that the removed +line enforced security, that the defect is new, or that a hook meets its latency bar. + +Ten of 27 tasks are now complete; task 3.4 remains for this group. diff --git a/.kiro/specs/pre-push-safety-net/spec.json b/.kiro/specs/pre-push-safety-net/spec.json index 9eb113c..5b749f6 100644 --- a/.kiro/specs/pre-push-safety-net/spec.json +++ b/.kiro/specs/pre-push-safety-net/spec.json @@ -1,7 +1,7 @@ { "feature_name": "pre-push-safety-net", "created_at": "2026-09-12T15:35:00Z", - "updated_at": "2026-09-13T08:16:14.274231+00:00", + "updated_at": "2026-09-13T08:41:28.781759+00:00", "language": "en", "phase": "implementation", "approvals": { @@ -71,9 +71,10 @@ "2.2", "2.3", "3.1", - "3.2" + "3.2", + "3.3" ], - "remaining_tasks": 18, + "remaining_tasks": 17, "verification_record": "implementation-group-3.md", "verification_records": [ "implementation-group-1.md", diff --git a/.kiro/specs/pre-push-safety-net/tasks.md b/.kiro/specs/pre-push-safety-net/tasks.md index 2eab9ff..b428b67 100644 --- a/.kiro/specs/pre-push-safety-net/tasks.md +++ b/.kiro/specs/pre-push-safety-net/tasks.md @@ -1,7 +1,7 @@ # Implementation Plan: pre-push-safety-net Status: task plan approved by the maintainer's "approve." on 2026-09-12. Groups 1 and 2 (tasks 1.1–2.3) -are complete after independent review and fresh verification. Group 3 is in progress (3.1–3.2 verified); groups 4–8 remain pending; +are complete after independent review and fresh verification. Group 3 is in progress (3.1–3.3 verified); groups 4–8 remain pending; the hook and capability admission are not yet implemented. See `implementation-group-1.md` and `implementation-group-2.md`. ## Execution contract @@ -95,13 +95,14 @@ the hook and capability admission are not yet implemented. See `implementation-g - _Boundary: Ranker Scope Contract, generic driver integration_ - _Requirements: 2.1, 2.2, 6.4, 7.3, 8.1, 8.2_ - _Verified 2026-09-13:_ independent review APPROVED; 1,105 passed, nine skipped; global Ruff/format/mypy passed. Twenty-four independent order/budget/missing-family combinations retained all question identities. Packaged PHP/JavaScript smoke selected the same controlled risky operation at tier 4/score 2.5, with exact execution/outcome IDs and reconciled family coverage. Missing evidence, partial graphs and interrupted arbitration remain explicit; CLI consumes authoritative scope. Evidence: `benchmarks/measurements/2026-09-13-ranker-scope-smoke.json`. No new ranking weights or hook admission claim. -- [ ] 3.3 Connect change context to supported first-party relationships +- [x] 3.3 Connect change context to supported first-party relationships - Use existing entry/callee, field, hook and guard relationships to supply affected context to ranked questions, including unchanged sinks after source/guard changes. - Preserve current abstract semantics; absent dynamic or external relationships remain unresolved rather than guessed safe. A required new feature or fact is reported to its owning spec. - Done when controlled cross-file and removed-guard cases reach the relevant question/witness, while an unresolved boundary cannot produce a complete negative. - _Boundary: Ranker Scope Contract, Snapshot Adapter integration_ - _Depends: 2.3, 3.2_ - _Requirements: 2.1, 2.2, 2.4, 8.1, 8.2_ + - _Verified 2026-09-13:_ independent review APPROVED after an unknown-call boundary repair; fresh suite 1,128 passed, nine skipped; global Ruff/format/mypy passed. Real PHP/JavaScript Git guard-removal snapshots reached unchanged helper-file witnesses with local dirty state preserved. Production negative regression reproduced then fixed unknown(req.body) being pruned as tier zero: modeled arguments no longer summarize unknown consumers. Existing ranking and detector semantics unchanged; unsupported projections remain explicit. Evidence: `benchmarks/measurements/2026-09-13-change-context-smoke.json`. Lexical attribution is not semantic novelty or alert admission. - [ ] 3.4 Apply vendor exclusion and explicit language partitions - Reuse declared layout exclusions before target enumeration and graph build; retain unshipped first-party tests and separate browser/server or language capability coverage. - Build supported frontend partitions explicitly; preserve unknown cross-language and vendor semantics without restoring excluded sources. diff --git a/benchmarks/measurements/2026-09-13-change-context-smoke.json b/benchmarks/measurements/2026-09-13-change-context-smoke.json new file mode 100644 index 0000000..e7fa252 --- /dev/null +++ b/benchmarks/measurements/2026-09-13-change-context-smoke.json @@ -0,0 +1,942 @@ +{ + "recorded_at": "2026-09-13T08:40:22.821019+00:00", + "task": "3.3", + "image_id": "sha256:a30d41f7e0cde5d3c9b634889fef07f8c3aa089be176362f73645c3bda1e5953", + "build_exit_code": 0, + "cross_file_smoke_exit_code": 0, + "negative_regression_exit_code": 0, + "packaged_source_hashes": { + "openultrasast.cpg.backend": "2e327d26958efd592932bbe5a84aa122d969c0e49a1c13469a4d956b621a9a34", + "openultrasast.model.scan": "c803fa05a9f190b66df194616ed327043a592883c9f746a902768c89b253d36f", + "openultrasast.model.regions": "1ecafa9080ac537383d91d02147f204eadc72c5241618f357ed30c5a5ff79cc1", + "openultrasast.push.snapshot": "c6891f6de9221f63e534372c3009190d227172ca0b006bfb68bb6cffd7bf38f3", + "taint.sc": "d09b5608870571c57b5ec82b9e886943ad6df0fd2510a7c56cec4bcaa352d116" + }, + "packaged_sources_match_workspace": true, + "supporting_script_hashes": { + "ops/smoke_context.py": "4d515258c01e44e2a30ee71f8de8de2df06c88dac2e0ded6f2904f7682e347d9", + "ops/smoke_context_boundaries.py": "35cc2cabbfbdfe843c3f82c776efe43dfd0db8418eda4ce6e045bd3269d4cb6e" + }, + "cross_file_runtime": { + "purpose": "Committed removed-guard context reaches real ranked questions; not semantic novelty or admission proof", + "shared_budget_seconds": 900, + "languages": { + "php": { + "base_revision": "3d894f361e33b1801a770addb7e068f69dab7ee4", + "head_revision": "c6dee5e55aef86153a52e27835d51f8675eda0c8", + "source_bytes": 124, + "source_sha256": "e41333ddd3e341d08603a5cc6a3a9faedb442e6ef3b14627cd00516cdf12cedb", + "helper_bytes": 48, + "helper_sha256": "4eb14937fd2b027cc11bd5a79c5de38f572d9a5af8ee36a46d4a0e499817ae76", + "input_context": { + "base_revision": "3d894f361e33b1801a770addb7e068f69dab7ee4", + "head_revision": "c6dee5e55aef86153a52e27835d51f8675eda0c8", + "changed_paths": [ + "6170692e706870" + ], + "deleted_paths": [], + "spans": [ + { + "path": "6170692e706870", + "start_line": 4, + "end_line": 4, + "side": "base" + } + ], + "renames": [], + "declaration_paths": [], + "relationships": [], + "unresolved_boundaries": [], + "path_encoding": "filesystem-bytes-hex", + "line_correspondences": [ + { + "base_path": "6170692e706870", + "head_path": "6170692e706870", + "base_start_line": 1, + "base_end_line": 1, + "head_start_line": 1 + }, + { + "base_path": "6170692e706870", + "head_path": "6170692e706870", + "base_start_line": 2, + "base_end_line": 2, + "head_start_line": 2 + }, + { + "base_path": "6170692e706870", + "head_path": "6170692e706870", + "base_start_line": 3, + "base_end_line": 3, + "head_start_line": 3 + }, + { + "base_path": "6170692e706870", + "head_path": "6170692e706870", + "base_start_line": 5, + "base_end_line": 5, + "head_start_line": 4 + }, + { + "base_path": "6170692e706870", + "head_path": "6170692e706870", + "base_start_line": 6, + "base_end_line": 6, + "head_start_line": 5 + } + ] + }, + "result": { + "findings": [ + { + "site": "helper.php:2:helper", + "family": "injection", + "rung": "model_entailed", + "witness": "$req -> eval($value) in helper.php (line 2, 4 steps)", + "contradiction": "", + "reached_from": 1 + } + ], + "by_rung": { + "suspicion": 0, + "model_corroborated": 0, + "model_entailed": 1, + "execution_confirmed": 0 + }, + "regions_scanned": 1, + "regions_unjudged": 0, + "regions_unasked": 0, + "model_calls": 0, + "cost_usd": 0.0, + "seconds": 63.48, + "build_seconds": 28.21, + "query_seconds": 18.0, + "query_seconds_by_kind": { + "evidence": 17.21, + "taint": 18.0 + }, + "degradations": [], + "tiers": [ + [ + "api.php", + "handler", + "injection", + 4 + ] + ], + "tier_counts": { + "4": 1 + }, + "requests_pruned": 0, + "change_context": { + "base_revision": "3d894f361e33b1801a770addb7e068f69dab7ee4", + "head_revision": "c6dee5e55aef86153a52e27835d51f8675eda0c8", + "changed_paths": [ + "6170692e706870" + ], + "deleted_paths": [], + "spans": [ + { + "path": "6170692e706870", + "start_line": 4, + "end_line": 4, + "side": "base" + } + ], + "renames": [], + "declaration_paths": [], + "relationships": [ + { + "source": { + "unit": "context-smoke", + "language": "php", + "path": "api.php", + "function": "handler", + "family": "injection" + }, + "target": { + "unit": "context-smoke", + "language": "php", + "path": "api.php", + "function": "handler", + "family": "injection" + }, + "kind": "entry", + "evidence": [ + "base_span:api.php:4-4", + "unchanged_line:3;lexical_only", + "unchanged_line:4;lexical_only", + "unchanged_line:5;lexical_only" + ] + } + ], + "unresolved_boundaries": [], + "path_encoding": "filesystem-bytes-hex", + "line_correspondences": [ + { + "base_path": "6170692e706870", + "head_path": "6170692e706870", + "base_start_line": 1, + "base_end_line": 1, + "head_start_line": 1 + }, + { + "base_path": "6170692e706870", + "head_path": "6170692e706870", + "base_start_line": 2, + "base_end_line": 2, + "head_start_line": 2 + }, + { + "base_path": "6170692e706870", + "head_path": "6170692e706870", + "base_start_line": 3, + "base_end_line": 3, + "head_start_line": 3 + }, + { + "base_path": "6170692e706870", + "head_path": "6170692e706870", + "base_start_line": 5, + "base_end_line": 5, + "head_start_line": 4 + }, + { + "base_path": "6170692e706870", + "head_path": "6170692e706870", + "base_start_line": 6, + "base_end_line": 6, + "head_start_line": 5 + } + ] + }, + "scope": { + "ranker_version": "existing-static-evidence-v1", + "ranking_mode": "evidence", + "population_complete": true, + "selected": [ + { + "identity": { + "unit": "context-smoke", + "language": "php", + "path": "api.php", + "function": "handler", + "family": "injection" + }, + "priority": 0.0, + "evidence": [ + "static_rank=1.0", + "normalized_evidence={\"access_declared_public\":true,\"bound_names\":[\"mysqli_prepare\",\"mysqli_stmt_bind_param\",\"pg_execute\",\"pg_prepare\"],\"carried\":false,\"carried_weak\":false,\"entry\":true,\"family_in_repo\":true,\"shipped\":true,\"sinks\":[{\"arg0_literal\":false,\"arity\":1,\"cleansed_on_call\":false,\"code\":\"eval($value)\",\"line\":2,\"method\":\"helper\"}],\"source_local\":false,\"source_near\":false}", + "tier=4", + "score=2.5", + "change_sites={\"base_revision\":\"3d894f361e33b1801a770addb7e068f69dab7ee4\",\"changed_paths\":[\"6170692e706870\"],\"declaration_paths\":[],\"deleted_paths\":[],\"head_revision\":\"c6dee5e55aef86153a52e27835d51f8675eda0c8\",\"path_encoding\":\"filesystem-bytes-hex\"}", + "affected_relationship={\"evidence\":[\"base_span:api.php:4-4\",\"unchanged_line:3;lexical_only\",\"unchanged_line:4;lexical_only\",\"unchanged_line:5;lexical_only\"],\"kind\":\"entry\",\"source\":{\"family\":\"injection\",\"function\":\"handler\",\"language\":\"php\",\"path\":\"api.php\",\"unit\":\"context-smoke\"},\"target\":{\"family\":\"injection\",\"function\":\"handler\",\"language\":\"php\",\"path\":\"api.php\",\"unit\":\"context-smoke\"}}" + ], + "tier": 4, + "score": 2.5 + } + ], + "deferred": [], + "unresolved_boundaries": [] + }, + "question_outcomes": [ + { + "identity": { + "unit": "context-smoke", + "language": "php", + "path": "api.php", + "function": "handler", + "family": "injection" + }, + "status": "completed", + "reason": "query_answered_and_arbitrated", + "raw_rows_json": "[{\"bound\":\"\",\"bounded\":false,\"inLabeledScope\":true,\"length\":4,\"paths\":1,\"sanitized\":false,\"sink\":\"eval($value)\",\"sinkArg0Literal\":false,\"sinkArity\":1,\"sinkFile\":\"helper.php\",\"sinkLine\":\"2\",\"sinkMethod\":\"helper\",\"source\":\"$req\",\"sourceKind\":\"parameter\"}]" + } + ], + "family_coverage": [ + { + "unit": "context-smoke", + "language": "php", + "family": "injection", + "selected": 1, + "completed": 1, + "unanswered": 0, + "deferred": 0, + "unsupported": 0 + } + ] + } + }, + "javascript": { + "base_revision": "2f8c5857f56009b1bf3cc7e56b3579fd87a90239", + "head_revision": "cbec42fd18a83f3a29943f7084760437fbe971f2", + "source_bytes": 133, + "source_sha256": "ee6b40b0c84f84cda86b2f5d7d118f39504bc7237450d30e5c189f6b3517c965", + "helper_bytes": 47, + "helper_sha256": "173055ccb4d1b0cb76378bcb674ac9944bad02f848bf20139923775b57e7634d", + "input_context": { + "base_revision": "2f8c5857f56009b1bf3cc7e56b3579fd87a90239", + "head_revision": "cbec42fd18a83f3a29943f7084760437fbe971f2", + "changed_paths": [ + "6170692e6a73" + ], + "deleted_paths": [], + "spans": [ + { + "path": "6170692e6a73", + "start_line": 3, + "end_line": 3, + "side": "base" + } + ], + "renames": [], + "declaration_paths": [], + "relationships": [], + "unresolved_boundaries": [], + "path_encoding": "filesystem-bytes-hex", + "line_correspondences": [ + { + "base_path": "6170692e6a73", + "head_path": "6170692e6a73", + "base_start_line": 1, + "base_end_line": 1, + "head_start_line": 1 + }, + { + "base_path": "6170692e6a73", + "head_path": "6170692e6a73", + "base_start_line": 2, + "base_end_line": 2, + "head_start_line": 2 + }, + { + "base_path": "6170692e6a73", + "head_path": "6170692e6a73", + "base_start_line": 4, + "base_end_line": 4, + "head_start_line": 3 + }, + { + "base_path": "6170692e6a73", + "head_path": "6170692e6a73", + "base_start_line": 5, + "base_end_line": 5, + "head_start_line": 4 + } + ] + }, + "result": { + "findings": [ + { + "site": "helper.js:1:helper", + "family": "injection", + "rung": "model_entailed", + "witness": "req -> eval(value) in helper.js (line 1, 4 steps)", + "contradiction": "", + "reached_from": 1 + } + ], + "by_rung": { + "suspicion": 0, + "model_corroborated": 0, + "model_entailed": 1, + "execution_confirmed": 0 + }, + "regions_scanned": 1, + "regions_unjudged": 0, + "regions_unasked": 0, + "model_calls": 0, + "cost_usd": 0.0, + "seconds": 39.63, + "build_seconds": 5.76, + "query_seconds": 17.2, + "query_seconds_by_kind": { + "evidence": 16.65, + "taint": 17.2 + }, + "degradations": [], + "tiers": [ + [ + "api.js", + "handler", + "injection", + 4 + ] + ], + "tier_counts": { + "4": 1 + }, + "requests_pruned": 0, + "change_context": { + "base_revision": "2f8c5857f56009b1bf3cc7e56b3579fd87a90239", + "head_revision": "cbec42fd18a83f3a29943f7084760437fbe971f2", + "changed_paths": [ + "6170692e6a73" + ], + "deleted_paths": [], + "spans": [ + { + "path": "6170692e6a73", + "start_line": 3, + "end_line": 3, + "side": "base" + } + ], + "renames": [], + "declaration_paths": [], + "relationships": [ + { + "source": { + "unit": "context-smoke", + "language": "javascript", + "path": "api.js", + "function": "handler", + "family": "injection" + }, + "target": { + "unit": "context-smoke", + "language": "javascript", + "path": "api.js", + "function": "handler", + "family": "injection" + }, + "kind": "entry", + "evidence": [ + "base_span:api.js:3-3", + "unchanged_line:2;lexical_only", + "unchanged_line:3;lexical_only", + "unchanged_line:4;lexical_only" + ] + } + ], + "unresolved_boundaries": [], + "path_encoding": "filesystem-bytes-hex", + "line_correspondences": [ + { + "base_path": "6170692e6a73", + "head_path": "6170692e6a73", + "base_start_line": 1, + "base_end_line": 1, + "head_start_line": 1 + }, + { + "base_path": "6170692e6a73", + "head_path": "6170692e6a73", + "base_start_line": 2, + "base_end_line": 2, + "head_start_line": 2 + }, + { + "base_path": "6170692e6a73", + "head_path": "6170692e6a73", + "base_start_line": 4, + "base_end_line": 4, + "head_start_line": 3 + }, + { + "base_path": "6170692e6a73", + "head_path": "6170692e6a73", + "base_start_line": 5, + "base_end_line": 5, + "head_start_line": 4 + } + ] + }, + "scope": { + "ranker_version": "existing-static-evidence-v1", + "ranking_mode": "evidence", + "population_complete": true, + "selected": [ + { + "identity": { + "unit": "context-smoke", + "language": "javascript", + "path": "api.js", + "function": "handler", + "family": "injection" + }, + "priority": 0.0, + "evidence": [ + "static_rank=1.0", + "normalized_evidence={\"access_declared_public\":true,\"bound_names\":[\"execute\",\"query\"],\"carried\":false,\"carried_weak\":false,\"entry\":true,\"family_in_repo\":true,\"shipped\":true,\"sinks\":[{\"arg0_literal\":false,\"arity\":1,\"cleansed_on_call\":false,\"code\":\"eval(value)\",\"line\":1,\"method\":\"helper\"}],\"source_local\":false,\"source_near\":false}", + "tier=4", + "score=2.5", + "change_sites={\"base_revision\":\"2f8c5857f56009b1bf3cc7e56b3579fd87a90239\",\"changed_paths\":[\"6170692e6a73\"],\"declaration_paths\":[],\"deleted_paths\":[],\"head_revision\":\"cbec42fd18a83f3a29943f7084760437fbe971f2\",\"path_encoding\":\"filesystem-bytes-hex\"}", + "affected_relationship={\"evidence\":[\"base_span:api.js:3-3\",\"unchanged_line:2;lexical_only\",\"unchanged_line:3;lexical_only\",\"unchanged_line:4;lexical_only\"],\"kind\":\"entry\",\"source\":{\"family\":\"injection\",\"function\":\"handler\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-smoke\"},\"target\":{\"family\":\"injection\",\"function\":\"handler\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-smoke\"}}" + ], + "tier": 4, + "score": 2.5 + } + ], + "deferred": [], + "unresolved_boundaries": [] + }, + "question_outcomes": [ + { + "identity": { + "unit": "context-smoke", + "language": "javascript", + "path": "api.js", + "function": "handler", + "family": "injection" + }, + "status": "completed", + "reason": "query_answered_and_arbitrated", + "raw_rows_json": "[{\"bound\":\"\",\"bounded\":false,\"inLabeledScope\":true,\"length\":4,\"paths\":1,\"sanitized\":false,\"sink\":\"eval(value)\",\"sinkArg0Literal\":false,\"sinkArity\":1,\"sinkFile\":\"helper.js\",\"sinkLine\":\"1\",\"sinkMethod\":\"helper\",\"source\":\"req\",\"sourceKind\":\"parameter\"}]" + } + ], + "family_coverage": [ + { + "unit": "context-smoke", + "language": "javascript", + "family": "injection", + "selected": 1, + "completed": 1, + "unanswered": 0, + "deferred": 0, + "unsupported": 0 + } + ] + } + } + } + }, + "negative_runtime": { + "source_bytes": 255, + "source_sha256": "2460518ef54c6ae3a31007801a28fc75b0c374cdb579bfa31d3e425e48b6a9b4", + "result": { + "findings": [ + { + "site": "api.js:2:unknownSink", + "family": "injection", + "rung": "model_entailed", + "witness": "eval(req.body) -> unknown(eval(req.body)) in api.js (line 2, 1 steps)", + "contradiction": "", + "reached_from": 1 + }, + { + "site": "api.js:4:knownSink", + "family": "injection", + "rung": "model_entailed", + "witness": "req.body -> eval(req.body) in api.js (line 4, 1 steps)", + "contradiction": "", + "reached_from": 1 + } + ], + "by_rung": { + "suspicion": 0, + "model_corroborated": 0, + "model_entailed": 2, + "execution_confirmed": 0 + }, + "regions_scanned": 4, + "regions_unjudged": 1, + "regions_unasked": 0, + "model_calls": 0, + "cost_usd": 0.0, + "seconds": 58.88, + "build_seconds": 8.53, + "query_seconds": 24.91, + "query_seconds_by_kind": { + "evidence": 25.38, + "taint": 24.91 + }, + "degradations": [], + "tiers": [ + [ + "api.js", + "knownSanitizer", + "injection", + 0 + ], + [ + "api.js", + "knownSink", + "injection", + 4 + ], + [ + "api.js", + "unknownSanitizer", + "injection", + 0 + ], + [ + "api.js", + "unknownSink", + "injection", + 4 + ], + [ + "api.js", + "unknownSource", + "injection", + 0 + ] + ], + "tier_counts": { + "0": 3, + "4": 2 + }, + "requests_pruned": 1, + "change_context": { + "base_revision": "base", + "head_revision": "head", + "changed_paths": [ + "api.js" + ], + "deleted_paths": [], + "spans": [ + { + "path": "api.js", + "start_line": 1, + "end_line": 5, + "side": "head" + } + ], + "renames": [], + "declaration_paths": [], + "relationships": [ + { + "source": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSink", + "family": "injection" + }, + "target": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSink", + "family": "injection" + }, + "kind": "entry", + "evidence": [ + "head_span:api.js:1-5" + ] + }, + { + "source": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "knownSink", + "family": "injection" + }, + "target": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "knownSink", + "family": "injection" + }, + "kind": "entry", + "evidence": [ + "head_span:api.js:1-5" + ] + }, + { + "source": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "knownSanitizer", + "family": "injection" + }, + "target": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "knownSanitizer", + "family": "injection" + }, + "kind": "entry", + "evidence": [ + "head_span:api.js:1-5" + ] + }, + { + "source": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "knownSanitizer", + "family": "injection" + }, + "target": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "knownSanitizer", + "family": "injection" + }, + "kind": "guard", + "evidence": [ + "head_span:api.js:1-5" + ] + }, + { + "source": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSanitizer", + "family": "injection" + }, + "target": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSanitizer", + "family": "injection" + }, + "kind": "entry", + "evidence": [ + "head_span:api.js:1-5" + ] + }, + { + "source": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSanitizer", + "family": "injection" + }, + "target": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSanitizer", + "family": "injection" + }, + "kind": "guard", + "evidence": [ + "head_span:api.js:1-5" + ] + }, + { + "source": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSource", + "family": "injection" + }, + "target": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSource", + "family": "injection" + }, + "kind": "entry", + "evidence": [ + "head_span:api.js:1-5" + ] + } + ], + "unresolved_boundaries": [ + "dynamic_external_or_depth_context_unresolved:contributor-scan:71905af15166a20401cb2e53ffd500f3307c2a76da41017a64bc9ef47afce2e7", + "dynamic_external_or_depth_context_unresolved:contributor-scan:cb4c6cecef36f957c9646d31def81d962d9ff2d1d1455275a0a6e9b20b8ed0ea", + "dynamic_external_or_depth_context_unresolved:contributor-scan:460011e339d204cda534bca278ebc1936596121cec7924cc88a9c179c4d2c681" + ], + "path_encoding": "text", + "line_correspondences": [] + }, + "scope": { + "ranker_version": "existing-static-evidence-v1", + "ranking_mode": "evidence", + "population_complete": true, + "selected": [ + { + "identity": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSink", + "family": "injection" + }, + "priority": 0.0, + "evidence": [ + "static_rank=1.0", + "normalized_evidence={\"access_declared_public\":true,\"bound_names\":[\"execute\",\"query\"],\"carried\":false,\"carried_weak\":false,\"entry\":true,\"family_in_repo\":true,\"shipped\":true,\"sinks\":[{\"arg0_literal\":false,\"arity\":1,\"cleansed_on_call\":false,\"code\":\"unknown(eval(req.body))\",\"line\":2,\"method\":\"unknownSink\"},{\"arg0_literal\":false,\"arity\":1,\"cleansed_on_call\":false,\"code\":\"eval(req.body)\",\"line\":2,\"method\":\"unknownSink\"}],\"source_local\":true,\"source_near\":true}", + "tier=4", + "score=6.5", + "change_sites={\"base_revision\":\"base\",\"changed_paths\":[\"api.js\"],\"declaration_paths\":[],\"deleted_paths\":[],\"head_revision\":\"head\",\"path_encoding\":\"text\"}", + "affected_relationship={\"evidence\":[\"head_span:api.js:1-5\"],\"kind\":\"entry\",\"source\":{\"family\":\"injection\",\"function\":\"unknownSink\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"},\"target\":{\"family\":\"injection\",\"function\":\"unknownSink\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"}}", + "change_context_incomplete" + ], + "tier": 4, + "score": 6.5 + }, + { + "identity": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "knownSink", + "family": "injection" + }, + "priority": -1.0, + "evidence": [ + "static_rank=1.0", + "normalized_evidence={\"access_declared_public\":true,\"bound_names\":[\"execute\",\"query\"],\"carried\":false,\"carried_weak\":false,\"entry\":true,\"family_in_repo\":true,\"shipped\":true,\"sinks\":[{\"arg0_literal\":false,\"arity\":1,\"cleansed_on_call\":false,\"code\":\"eval(req.body)\",\"line\":4,\"method\":\"knownSink\"}],\"source_local\":true,\"source_near\":true}", + "tier=4", + "score=5.5", + "change_sites={\"base_revision\":\"base\",\"changed_paths\":[\"api.js\"],\"declaration_paths\":[],\"deleted_paths\":[],\"head_revision\":\"head\",\"path_encoding\":\"text\"}", + "affected_relationship={\"evidence\":[\"head_span:api.js:1-5\"],\"kind\":\"entry\",\"source\":{\"family\":\"injection\",\"function\":\"knownSink\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"},\"target\":{\"family\":\"injection\",\"function\":\"knownSink\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"}}" + ], + "tier": 4, + "score": 5.5 + }, + { + "identity": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSanitizer", + "family": "injection" + }, + "priority": -2.0, + "evidence": [ + "static_rank=1.0", + "normalized_evidence={\"access_declared_public\":true,\"bound_names\":[\"execute\",\"query\"],\"carried\":false,\"carried_weak\":false,\"entry\":true,\"family_in_repo\":true,\"shipped\":true,\"sinks\":[],\"source_local\":false,\"source_near\":false}", + "tier=0", + "score=0.0", + "change_sites={\"base_revision\":\"base\",\"changed_paths\":[\"api.js\"],\"declaration_paths\":[],\"deleted_paths\":[],\"head_revision\":\"head\",\"path_encoding\":\"text\"}", + "affected_relationship={\"evidence\":[\"head_span:api.js:1-5\"],\"kind\":\"entry\",\"source\":{\"family\":\"injection\",\"function\":\"unknownSanitizer\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"},\"target\":{\"family\":\"injection\",\"function\":\"unknownSanitizer\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"}}", + "affected_relationship={\"evidence\":[\"head_span:api.js:1-5\"],\"kind\":\"guard\",\"source\":{\"family\":\"injection\",\"function\":\"unknownSanitizer\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"},\"target\":{\"family\":\"injection\",\"function\":\"unknownSanitizer\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"}}", + "change_context_incomplete" + ], + "tier": 0, + "score": 0.0 + }, + { + "identity": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSource", + "family": "injection" + }, + "priority": -3.0, + "evidence": [ + "static_rank=1.0", + "normalized_evidence={\"access_declared_public\":true,\"bound_names\":[\"execute\",\"query\"],\"carried\":false,\"carried_weak\":false,\"entry\":true,\"family_in_repo\":true,\"shipped\":true,\"sinks\":[],\"source_local\":true,\"source_near\":true}", + "tier=0", + "score=0.0", + "change_sites={\"base_revision\":\"base\",\"changed_paths\":[\"api.js\"],\"declaration_paths\":[],\"deleted_paths\":[],\"head_revision\":\"head\",\"path_encoding\":\"text\"}", + "affected_relationship={\"evidence\":[\"head_span:api.js:1-5\"],\"kind\":\"entry\",\"source\":{\"family\":\"injection\",\"function\":\"unknownSource\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"},\"target\":{\"family\":\"injection\",\"function\":\"unknownSource\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"}}", + "change_context_incomplete" + ], + "tier": 0, + "score": 0.0 + } + ], + "deferred": [ + { + "identity": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "knownSanitizer", + "family": "injection" + }, + "reason": "tier_zero", + "evidence": [ + "static_rank=1.0", + "normalized_evidence={\"access_declared_public\":true,\"bound_names\":[\"execute\",\"query\"],\"carried\":false,\"carried_weak\":false,\"entry\":true,\"family_in_repo\":true,\"shipped\":true,\"sinks\":[],\"source_local\":true,\"source_near\":true}", + "tier=0", + "score=0.0", + "change_sites={\"base_revision\":\"base\",\"changed_paths\":[\"api.js\"],\"declaration_paths\":[],\"deleted_paths\":[],\"head_revision\":\"head\",\"path_encoding\":\"text\"}", + "affected_relationship={\"evidence\":[\"head_span:api.js:1-5\"],\"kind\":\"entry\",\"source\":{\"family\":\"injection\",\"function\":\"knownSanitizer\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"},\"target\":{\"family\":\"injection\",\"function\":\"knownSanitizer\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"}}", + "affected_relationship={\"evidence\":[\"head_span:api.js:1-5\"],\"kind\":\"guard\",\"source\":{\"family\":\"injection\",\"function\":\"knownSanitizer\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"},\"target\":{\"family\":\"injection\",\"function\":\"knownSanitizer\",\"language\":\"javascript\",\"path\":\"api.js\",\"unit\":\"context-boundaries\"}}" + ] + } + ], + "unresolved_boundaries": [ + "dynamic_external_or_depth_context_unresolved:contributor-scan:71905af15166a20401cb2e53ffd500f3307c2a76da41017a64bc9ef47afce2e7", + "dynamic_external_or_depth_context_unresolved:contributor-scan:cb4c6cecef36f957c9646d31def81d962d9ff2d1d1455275a0a6e9b20b8ed0ea", + "dynamic_external_or_depth_context_unresolved:contributor-scan:460011e339d204cda534bca278ebc1936596121cec7924cc88a9c179c4d2c681" + ] + }, + "question_outcomes": [ + { + "identity": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSink", + "family": "injection" + }, + "status": "unresolved", + "reason": "change_context_incomplete", + "raw_rows_json": "[{\"bound\":\"\",\"bounded\":false,\"inLabeledScope\":true,\"length\":1,\"paths\":2,\"sanitized\":false,\"sink\":\"unknown(eval(req.body))\",\"sinkArg0Literal\":false,\"sinkArity\":1,\"sinkFile\":\"api.js\",\"sinkLine\":\"2\",\"sinkMethod\":\"unknownSink\",\"source\":\"eval(req.body)\",\"sourceKind\":\"framework\"},{\"bound\":\"\",\"bounded\":false,\"inLabeledScope\":true,\"length\":2,\"paths\":2,\"sanitized\":false,\"sink\":\"unknown(eval(req.body))\",\"sinkArg0Literal\":false,\"sinkArity\":1,\"sinkFile\":\"api.js\",\"sinkLine\":\"2\",\"sinkMethod\":\"unknownSink\",\"source\":\"req.body\",\"sourceKind\":\"framework\"},{\"bound\":\"\",\"bounded\":false,\"inLabeledScope\":true,\"length\":3,\"paths\":2,\"sanitized\":false,\"sink\":\"unknown(eval(req.body))\",\"sinkArg0Literal\":false,\"sinkArity\":1,\"sinkFile\":\"api.js\",\"sinkLine\":\"2\",\"sinkMethod\":\"unknownSink\",\"source\":\"req\",\"sourceKind\":\"parameter\"},{\"bound\":\"\",\"bounded\":false,\"inLabeledScope\":true,\"length\":1,\"paths\":1,\"sanitized\":false,\"sink\":\"eval(req.body)\",\"sinkArg0Literal\":false,\"sinkArity\":1,\"sinkFile\":\"api.js\",\"sinkLine\":\"2\",\"sinkMethod\":\"unknownSink\",\"source\":\"req.body\",\"sourceKind\":\"framework\"},{\"bound\":\"\",\"bounded\":false,\"inLabeledScope\":true,\"length\":2,\"paths\":1,\"sanitized\":false,\"sink\":\"eval(req.body)\",\"sinkArg0Literal\":false,\"sinkArity\":1,\"sinkFile\":\"api.js\",\"sinkLine\":\"2\",\"sinkMethod\":\"unknownSink\",\"source\":\"req\",\"sourceKind\":\"parameter\"}]" + }, + { + "identity": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "knownSink", + "family": "injection" + }, + "status": "completed", + "reason": "query_answered_and_arbitrated", + "raw_rows_json": "[{\"bound\":\"\",\"bounded\":false,\"inLabeledScope\":true,\"length\":1,\"paths\":1,\"sanitized\":false,\"sink\":\"eval(req.body)\",\"sinkArg0Literal\":false,\"sinkArity\":1,\"sinkFile\":\"api.js\",\"sinkLine\":\"4\",\"sinkMethod\":\"knownSink\",\"source\":\"req.body\",\"sourceKind\":\"framework\"},{\"bound\":\"\",\"bounded\":false,\"inLabeledScope\":true,\"length\":2,\"paths\":1,\"sanitized\":false,\"sink\":\"eval(req.body)\",\"sinkArg0Literal\":false,\"sinkArity\":1,\"sinkFile\":\"api.js\",\"sinkLine\":\"4\",\"sinkMethod\":\"knownSink\",\"source\":\"req\",\"sourceKind\":\"parameter\"}]" + }, + { + "identity": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSanitizer", + "family": "injection" + }, + "status": "unresolved", + "reason": "change_context_incomplete", + "raw_rows_json": "[]" + }, + { + "identity": { + "unit": "context-boundaries", + "language": "javascript", + "path": "api.js", + "function": "unknownSource", + "family": "injection" + }, + "status": "unresolved", + "reason": "change_context_incomplete", + "raw_rows_json": "[]" + } + ], + "family_coverage": [ + { + "unit": "context-boundaries", + "language": "javascript", + "family": "injection", + "selected": 4, + "completed": 1, + "unanswered": 3, + "deferred": 1, + "unsupported": 0 + } + ] + } + }, + "review_repair": { + "red_exit_code": 1, + "red_symptom": "unknownSource was excluded as tier_zero with no unresolved boundaries", + "repair": "Only directly invoked fact-modeled calls can exempt an external call; argument/subtree mentions cannot.", + "additional_parent_assertions": "Direct knownSanitizer remains tier_zero with no question context gap; verified against captured GREEN output." + }, + "limits": [ + "Controlled declared entry regions, not full mapper/workload replay.", + "Lexical change-to-question relationships do not prove semantic guard removal or novelty.", + "Current field/hook abstraction limits preserved; missing dominance/config/deleted dependencies remain explicit.", + "No hook latency, capability admission or broad Joern reliability claim." + ] +} diff --git a/ops/README.md b/ops/README.md index 11bd2d3..32daab2 100644 --- a/ops/README.md +++ b/ops/README.md @@ -97,6 +97,18 @@ that the lower static-ranked risky function is selected, that the harmless funct recorded as deferred, and that selected IDs match completed query outcomes and per-family coverage. It uses one 900-second lab budget; it is not a mapper or hook-quality evaluation. +`ops/smoke_context.py` uses the same invocation with its own filename. It creates local +PHP/JavaScript Git histories with a removed caller guard and an unchanged helper-file sink, then feeds +immutable snapshot change evidence into the real driver. It checks affected-question +relationships, a controlled witness and preservation of the dirty checkout. Its lexical +change attribution does not prove that the removed guard enforced security or that the +finding is new; comparison and admission are separate stages. + +`ops/smoke_context_boundaries.py` is the production-query negative regression using +the same invocation. Unknown consumers of a framework source, nested sink, or sanitizer +name must retain unresolved context rather than become tier-zero exclusions. Direct +modeled sink and sanitizer calls remain positive controls for the boundary projection. + Why this exists: Joern was first installed here by hand with `curl` and `unzip`, which is fine once and unreproducible thereafter. The engine decides whether a finding is a `suspicion` or a `model_entailed`, and every ceiling number in `benchmarks/measurements` is only comparable against a fixed version — so the version diff --git a/ops/smoke_context.py b/ops/smoke_context.py new file mode 100644 index 0000000..52165cc --- /dev/null +++ b/ops/smoke_context.py @@ -0,0 +1,120 @@ +"""Real Git-to-engine change-context smoke; lexical attribution is not novelty proof.""" + +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import tempfile +import time +from dataclasses import asdict +from pathlib import Path + +from openultrasast.cpg.backend import JoernBackend +from openultrasast.model.contracts import ExecutionBudget +from openultrasast.model.regions import ScanRegion +from openultrasast.model.scan import ScanBudget, scan_repository +from openultrasast.push.snapshot import SnapshotAdapter + + +def main() -> None: + budget = ExecutionBudget(time.monotonic() + 900, 2.0) + report: dict[str, object] = { + "purpose": "Committed removed-guard context reaches real ranked questions; not semantic novelty or admission proof", + "shared_budget_seconds": 900, + "languages": {}, + } + languages = report["languages"] + assert isinstance(languages, dict) + with tempfile.TemporaryDirectory(prefix="ousast-context-smoke-") as temporary: + for language, filename, before, removed, helper_name, helper in ( + ( + "php", + "api.php", + b" bytes: + return subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True, timeout=10).stdout + + def commit(message: str, path: str = filename) -> str: + git("add", path) + git("commit", "-m", message) + return git("rev-parse", "HEAD").decode().strip() + + git("init") + git("config", "user.name", "Context smoke") + git("config", "user.email", "context@example.invalid") + git("config", "core.hooksPath", os.devnull) + source = repo / filename + source.write_bytes(before) + (repo / helper_name).write_bytes(helper) + git("add", helper_name) + base = commit("base") + after = before.replace(removed, b"") + source.write_bytes(after) + head = commit("removed guard") + source.write_bytes(b"uncommitted content\n") + index = (repo / ".git/index").read_bytes() + status = git("status", "--porcelain=v1", "-z") + adapter = SnapshotAdapter(repo) + (comparison,) = adapter.resolve_updates(f"refs/heads/topic {head} refs/heads/topic {base}\n").comparisons + context = adapter.compare(comparison, declaration_paths=(), budget=budget) + assert any(span.side == "base" for span in context.spans), context + with adapter.materialize(head, budget=budget) as snapshot: + data = (snapshot.root / filename).read_bytes() + assert data == after and data + assert (snapshot.root / helper_name).read_bytes() == helper + result = scan_repository( + snapshot.root, + (ScanRegion(filename, "handler", language, ("injection",), 1.0, "entry_point"),), + backend=JoernBackend(heap_mb=1024), + budget=ScanBudget(max_regions=1), + execution_budget=budget, + ranking_mode="evidence", + unit="context-smoke", + population_complete=True, + change_context=context, + ) + enriched = result.change_context + assert enriched is not None and enriched.relationships, "changed context did not reach any question" + assert result.scope is not None and result.scope.selected + selected = {question.identity for question in result.scope.selected} + assert any(relation.target in selected for relation in enriched.relationships) + assert result.findings, "controlled unchanged sink has no witness" + assert any(Path(finding.site.split(":", 1)[0]).name == helper_name for finding in result.findings), result.findings + languages[language] = { + "base_revision": base, + "head_revision": head, + "source_bytes": len(data) + len(helper), + "source_sha256": hashlib.sha256(data).hexdigest(), + "helper_bytes": len(helper), + "helper_sha256": hashlib.sha256(helper).hexdigest(), + "input_context": context.to_payload(), + "result": asdict(result), + } + assert not snapshot.root.exists() + assert source.read_bytes() == b"uncommitted content\n" + assert (repo / ".git/index").read_bytes() == index + assert git("status", "--porcelain=v1", "-z") == status + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/ops/smoke_context_boundaries.py b/ops/smoke_context_boundaries.py new file mode 100644 index 0000000..078b9af --- /dev/null +++ b/ops/smoke_context_boundaries.py @@ -0,0 +1,64 @@ +"""Production-query regression: a known argument does not summarize an unknown callee.""" + +from __future__ import annotations + +import hashlib +import json +import tempfile +import time +from dataclasses import asdict +from pathlib import Path + +from openultrasast.cpg.backend import JoernBackend +from openultrasast.model.contracts import ChangeContext, ChangedSpan, ExecutionBudget +from openultrasast.model.regions import ScanRegion +from openultrasast.model.scan import ScanBudget, scan_repository + + +def main() -> None: + content = ( + b"function unknownSource(req) { unknown(req.body); }\n" + b"function unknownSink(req) { unknown(eval(req.body)); }\n" + b'function unknownSanitizer(req) { unknown("escape"); }\n' + b"function knownSink(req) { eval(req.body); }\n" + b"function knownSanitizer(req) { escape(req.body); }\n" + ) + with tempfile.TemporaryDirectory(prefix="ousast-context-boundary-") as temporary: + root = Path(temporary) + source = root / "api.js" + source.write_bytes(content) + assert source.read_bytes() == content + names = ("unknownSource", "unknownSink", "unknownSanitizer", "knownSink", "knownSanitizer") + result = scan_repository( + root, + tuple(ScanRegion("api.js", name, "javascript", ("injection",), 1.0, "entry_point") for name in names), + backend=JoernBackend(heap_mb=1024), + budget=ScanBudget(max_regions=5), + execution_budget=ExecutionBudget(time.monotonic() + 600, 2.0), + ranking_mode="evidence", + unit="context-boundaries", + population_complete=True, + change_context=ChangeContext("base", "head", ("api.js",), (), (ChangedSpan("api.js", 1, 5, "head"),), (), (), (), ()), + ) + assert result.scope is not None + outcomes = {item.identity.function: item for item in result.question_outcomes} + for name in names[:3]: + assert name in outcomes, (name, "unknown call was excluded", result.scope) + assert outcomes[name].status == "unresolved", (name, outcomes[name]) + question_id = outcomes[name].identity.question_id + assert any( + "dynamic_external_or_depth_context_unresolved" in gap and question_id in gap for gap in result.scope.unresolved_boundaries + ) + assert outcomes["knownSink"].status == "completed", outcomes["knownSink"] + known_sanitizer = next(item for item in result.scope.deferred if item.identity.function == "knownSanitizer") + assert known_sanitizer.reason == "tier_zero", known_sanitizer + assert not any(known_sanitizer.identity.question_id in gap for gap in result.scope.unresolved_boundaries) + print( + json.dumps( + {"source_bytes": len(content), "source_sha256": hashlib.sha256(content).hexdigest(), "result": asdict(result)}, indent=2 + ) + ) + + +if __name__ == "__main__": + main() diff --git a/src/openultrasast/cpg/queries/taint.sc b/src/openultrasast/cpg/queries/taint.sc index fae0fda..8e58c53 100644 --- a/src/openultrasast/cpg/queries/taint.sc +++ b/src/openultrasast/cpg/queries/taint.sc @@ -95,7 +95,8 @@ evidenceS: String, fileS: String, depthS: String, - boundedS: String + boundedS: String, + contextS: String = "" ): List[ujson.Obj] = { // Word-boundary matching, never substring. `resolveUrl` contains `resolve`, so a bare-substring sanitizer @@ -692,7 +693,51 @@ "cleansedOnCall" -> sink.argument.ast.l.exists(node => sanitizesHere(node)) ) } - return summary :: perSink + // Export locations already used by this query, for change attribution only. No new + // source/sink/guard inference and no change to the evidence vector or its weights. + val contextRows = if (contextS != "true") Nil else { + def location(m: io.shiftleft.codepropertygraph.generated.nodes.Method, kind: String): ujson.Obj = + ujson.Obj("kind" -> "context_method", "path" -> m.filename, "function" -> m.name, + "startLine" -> m.lineNumber.getOrElse(-1), "endLine" -> m.lineNumberEnd.getOrElse(-1), + "relationship" -> kind) + val scoped = cpg.method.filterNot(_.isExternal).filter(m => inScope(m)).l + val methods = scoped.map(m => location(m, if (labeledMethods.exists(_.id == m.id)) "entry" else "callee")) + // Field carrying is deliberately same-file in the existing abstraction. Retain + // its file context conservatively, never claim every method carries the value. + val fieldFiles = if (fieldCarriedKind.isEmpty) Set.empty[String] else + cpg.call.nameExact(FIELD_ACCESS).filter(c => inScope(c.method)).l.map(_.method.filename).toSet + val fields = fieldFiles.toList.flatMap(f => methodsIn(f).filterNot(_.isExternal).map(m => location(m, "field"))) + val callbacks = if (hookApply.isEmpty) Nil else cpg.call.filter(c => hookApply.contains(c.name)) + .filter(c => inScope(c.method)).l.flatMap { call => + val hook = call.argument.l.headOption.map(a => unquote(a.code)).getOrElse("") + hookTable.getOrElse(hook, Nil).flatMap(name => cpg.method.nameExact(name).filterNot(_.isExternal).l) + }.distinct + val hooks = callbacks.map(m => location(m, "hook")) + // Export sanitizer locations as guard context, not proof of discharge. Removed + // guards are retained through base spans and unchanged-line correspondence. + val guards = scoped.filter(_.ast.l.exists(sanitizesHere)).map(m => location(m, "guard")) + // Reachability remains bounded. External/dynamic calls and calls outside the + // current reachability set cannot establish a complete negative for a change. + // A modeled argument does not summarize its consumer: unknown(req.body), + // unknown(eval(...)) and unknown("escape") all retain an external boundary. + // Only the directly invoked fact-named operation can use this exemption. + def directlyModeled(c: io.shiftleft.codepropertygraph.generated.nodes.Call): Boolean = + (sinkNames ++ sanitizerNames ++ sourcePatterns).exists { raw => + val name = raw.stripSuffix("(") + c.name == name || c.code.startsWith(name + "(") + } + val missing = scoped.exists(m => m.ast.isCall.l.exists { c => + !c.name.startsWith("") && !directlyModeled(c) && { + val destinations = c.callee.l + destinations.isEmpty || destinations.exists(d => d.isExternal || !inScope(d)) + } + }) + val boundaries = if (scoped.isEmpty) List(ujson.Obj("kind" -> "context_boundary", + "reason" -> "context_scope_empty:contributor-scan")) else if (missing) List(ujson.Obj("kind" -> "context_boundary", + "reason" -> "dynamic_external_or_depth_context_unresolved:contributor-scan")) else Nil + ujson.Obj("kind" -> "context_summary") :: (methods ++ fields ++ hooks ++ guards ++ boundaries) + } + return summary :: (perSink ++ contextRows) } val rows = sinkCalls.l.flatMap { sink => @@ -797,7 +842,8 @@ evidence, field("file"), field("callDepth"), - field("boundedSinks") + field("boundedSinks"), + field("contextEvidence") ): _* ) } diff --git a/src/openultrasast/model/regions.py b/src/openultrasast/model/regions.py index 04bddc3..03a2c4d 100644 --- a/src/openultrasast/model/regions.py +++ b/src/openultrasast/model/regions.py @@ -18,9 +18,12 @@ Two restraints are deliberate: from __future__ import annotations -from collections.abc import Sequence +import time +from collections.abc import Mapping, Sequence from dataclasses import dataclass, replace +from pathlib import Path +from .contracts import AffectedRelationship, ChangeContext, ExecutionBudget, QuestionIdentity from .specs import config_specs, dominance_specs, taint_specs # From `mapping.AccessLevel`. A publicly reachable handler at a trust boundary is where a missing check costs @@ -174,3 +177,80 @@ def _families(language: str, *, has_handler: bool) -> tuple[str, ...]: if has_handler: families |= set(dominance_specs(language=language)) return tuple(sorted(families)) + + +def affected_context( + context: ChangeContext, + questions: Sequence[QuestionIdentity], + rows_by_question: Mapping[QuestionIdentity, Sequence[Mapping[str, object]]], + root: Path, + execution_budget: ExecutionBudget | None = None, +) -> tuple[ChangeContext, set[QuestionIdentity]]: + """Attach existing graph locations to changes; never select or reprioritize a question. + + Base spans are file-level hints unless an unchanged line anchors them in the head. + A method relationship is context, not proof of taint, guard removal or novelty. + """ + + relationships = list(context.relationships) + gaps = list(context.unresolved_boundaries) + affected_gaps: set[QuestionIdentity] = set() + changed = {context.decode_path(p) for p in context.changed_paths} + if context.deleted_paths: + gaps.append("deleted_dependency_projection_unavailable:contributor-scan") + if changed.intersection(context.decode_path(p) for p in context.declaration_paths): + gaps.append("configuration_dependency_projection_unavailable:contributor-scan") + if context.base_revision is None: + gaps.append("comparison_base_unavailable") + inherited_gap = bool(gaps) + renamed = {context.decode_path(r.base_path): context.decode_path(r.head_path) for r in context.renames} + for identity in questions: + if execution_budget is not None and time.monotonic() >= execution_budget.deadline_monotonic: + gaps.append("change_context_deadline_exhausted") + affected_gaps.update(questions) + break + rows = rows_by_question.get(identity, ()) + local_gaps = [] + if not any(r.get("kind") == "context_summary" for r in rows): + local_gaps.append("context_projection_unavailable:contributor-scan") + elif not any(r.get("kind") == "context_method" for r in rows): + local_gaps.append("context_scope_empty:contributor-scan") + for row in rows: + if row.get("kind") == "context_boundary": + local_gaps.append(str(row.get("reason", "context_unresolved"))) + if row.get("kind") != "context_method": + continue + path = Path(str(row.get("path", ""))) + try: + absolute = path if path.is_absolute() else root / path + path_text = absolute.resolve().relative_to(root.resolve()).as_posix() + if not absolute.is_file(): + raise ValueError("missing context source") + start, end = int(str(row.get("startLine", 0))), int(str(row.get("endLine", 0))) + if start < 1 or end < start: + raise ValueError("missing method extent") + except (ValueError, TypeError, OSError): + local_gaps.append("context_location_unavailable") + continue + evidence = [] + for span in context.spans: + old_path = context.decode_path(span.path) + match_path = renamed.get(old_path, old_path) if span.side == "base" else old_path + if match_path != path_text: + continue + if span.side == "base" or (span.start_line <= end and span.end_line >= start): + evidence.append(f"{span.side}_span:{old_path}:{span.start_line}-{span.end_line}") + for anchor in context.line_correspondences: + if context.decode_path(anchor.head_path) == path_text and start <= anchor.head_start_line <= end: + evidence.append(f"unchanged_line:{anchor.head_start_line};lexical_only") + if not evidence or path_text not in changed | set(renamed.values()): + continue + function = str(row.get("function", "")) or None + source = QuestionIdentity(identity.unit, identity.language, path_text, function, identity.family) + relation = AffectedRelationship(source, identity, str(row.get("relationship", "method")), tuple(evidence)) + if relation not in relationships: + relationships.append(relation) + if local_gaps or inherited_gap: + affected_gaps.add(identity) + gaps.extend(reason + ":" + identity.question_id for reason in local_gaps) + return replace(context, relationships=tuple(relationships), unresolved_boundaries=tuple(dict.fromkeys(gaps))), affected_gaps diff --git a/src/openultrasast/model/scan.py b/src/openultrasast/model/scan.py index f2c99c9..42f9355 100644 --- a/src/openultrasast/model/scan.py +++ b/src/openultrasast/model/scan.py @@ -30,13 +30,22 @@ from typing import Any from ..cpg.backend import CpgResult from .candidates import enumerate_candidates from .config_value import request_params as config_params -from .contracts import DeferredQuestion, ExecutionBudget, FamilyCoverage, QuestionIdentity, QuestionOutcome, RankedQuestion, ScopeDecision +from .contracts import ( + ChangeContext, + DeferredQuestion, + ExecutionBudget, + FamilyCoverage, + QuestionIdentity, + QuestionOutcome, + RankedQuestion, + ScopeDecision, +) from .dominance import request_params as dominance_params from .evidence import TIER_EXCLUDE, Evidence, evidence_from_rows from .ladder import Rung from .layout import with_layout from .pipeline import ModelFinding, scan_region -from .regions import MODULE_SCOPE, ScanRegion +from .regions import MODULE_SCOPE, ScanRegion, affected_context from .specs import ConfigSpec, DominanceSpec, TaintSpec, config_specs, dominance_specs, taint_specs from .taint import request_params as taint_params from .taxonomy import load_families @@ -117,6 +126,7 @@ class ModelScanResult: # could have been found was skipped. Reported so the request count is legible. requests_pruned: int = 0 + change_context: ChangeContext | None = None scope: ScopeDecision | None = None question_outcomes: tuple[QuestionOutcome, ...] = () family_coverage: tuple[FamilyCoverage, ...] = () @@ -139,6 +149,7 @@ def scan_repository( ranking_mode: str | None = None, unit: str = "repository", population_complete: bool = False, + change_context: ChangeContext | None = None, ) -> ModelScanResult: """Run with an optional transaction deadline; graph ownership ends on every exit.""" if ranking_mode not in (None, "static", "evidence"): @@ -164,6 +175,7 @@ def scan_repository( ranking_mode=mode, unit=unit, population_complete=population_complete, + change_context=change_context, execution_budget=execution_budget, owned=owned, ) @@ -197,6 +209,7 @@ def _scan_repository_impl( ranking_mode: str, unit: str, population_complete: bool, + change_context: ChangeContext | None, ) -> ModelScanResult: """Arbitrate a repository's regions under one CPG and one budget.""" limits = budget if budget is not None else ScanBudget() @@ -213,8 +226,11 @@ def _scan_repository_impl( cpg = _build(backend, root, dominant, execution_budget=execution_budget) build_seconds = round(time.monotonic() - build_started, 2) if cpg is None: - scope, _ = _scope_work(regions, {}, limits, unit, ranking_mode, population_complete, failure="cpg_build_failed") + scope, _ = _scope_work( + regions, {}, limits, unit, ranking_mode, population_complete, failure="cpg_build_failed", change_context=change_context + ) return ModelScanResult( + change_context=change_context, scope=scope, family_coverage=_family_coverage(scope, ()), by_rung=_empty_tally(), @@ -273,6 +289,7 @@ def _scan_repository_impl( tiers: list[tuple[str, str, str, int]] = [] per_kind: dict[str, float] = {} evidence_by_pair: dict[tuple[str, str, str], Evidence] = {} + context_rows: dict[QuestionIdentity, list[Mapping[str, object]]] = {} if _within_deadline(execution_budget) and limits.tiering and callable(getattr(cpg, "run_batch", None)): evidence_candidates = ( ordered_regions[: (limits.evidence_regions or len(ordered_regions))] @@ -280,7 +297,14 @@ def _scan_repository_impl( else ordered_regions[: limits.max_regions] ) tier_started = time.monotonic() - evidence_by_pair, failed = _evidence_pass(cpg, _collect(evidence_candidates), hooks, degradations=degradations) + evidence_by_pair, failed = _evidence_pass( + cpg, + _collect(evidence_candidates), + hooks, + degradations=degradations, + context_rows=context_rows if change_context is not None else None, + unit=unit, + ) per_kind["evidence"] = round(time.monotonic() - tier_started, 2) if failed: degradations.append({"stage": "model", "reason": "query_failed", "kind": "evidence", "requests": failed}) @@ -288,6 +312,12 @@ def _scan_repository_impl( if limits.order_by_evidence and evidence_by_pair: ordered_regions = order_by_evidence(ordered_regions, evidence_by_pair) + context_gaps: set[QuestionIdentity] = set() + if change_context is not None: + change_context, context_gaps = affected_context( + change_context, [_identity(unit, r, f) for r in ordered_regions for f in r.families], context_rows, root, execution_budget + ) + # Authoritative selection is made once, from the actual final ranker order. # Stable question IDs are the IDs sent to every execution request below. scope_limits = ( @@ -295,7 +325,16 @@ def _scan_repository_impl( if any(d.get("reason") in {"files_unparsed", "cpg_empty", "cpg_sharded"} for d in degradations) else limits ) - scope, work = _scope_work(ordered_regions, evidence_by_pair, scope_limits, unit, ranking_mode, population_complete) + scope, work = _scope_work( + ordered_regions, + evidence_by_pair, + scope_limits, + unit, + ranking_mode, + population_complete, + change_context=change_context, + context_gaps=context_gaps, + ) outcomes: dict[str, QuestionOutcome] = {} # Phase 2: ONE invocation per query kind. JVM startup dominates a repository scan -- a call per region @@ -429,6 +468,8 @@ def _scan_repository_impl( "deadline_exhausted" if not _within_deadline(execution_budget) else "query_unanswered", _rows_json(rows_by_id[rid]) if answered else None, ) + if question.identity in context_gaps and outcomes[rid].status == "completed": + outcomes[rid] = replace(outcomes[rid], status="unresolved", reason="change_context_incomplete") if graph_gaps and outcomes[rid].status == "completed": outcomes[rid] = replace(outcomes[rid], status="unresolved", reason="graph_incomplete") scope = replace(scope, unresolved_boundaries=tuple(dict.fromkeys((*scope.unresolved_boundaries, *graph_gaps)))) @@ -457,6 +498,7 @@ def _scan_repository_impl( scope=scope, question_outcomes=ordered_outcomes, family_coverage=_family_coverage(scope, ordered_outcomes), + change_context=change_context, ) @@ -477,6 +519,8 @@ def _scope_work( population_complete: bool, *, failure: str | None = None, + change_context: ChangeContext | None = None, + context_gaps: set[QuestionIdentity] | None = None, ) -> tuple[ScopeDecision, list[tuple[str, ScanRegion, ArbiterSpec]]]: """Consume the ranker's final order once; the returned work IS selected scope.""" selected: list[RankedQuestion] = [] @@ -485,6 +529,8 @@ def _scope_work( boundaries: list[str] = [] if population_complete else ["population_not_asserted_complete"] if failure: boundaries.append(failure) + if change_context is not None: + boundaries.extend(change_context.unresolved_boundaries) for index, region in enumerate(regions): for family in region.families: identity = _identity(unit, region, family) @@ -498,6 +544,25 @@ def _scope_work( boundaries.append("evidence_unknown:" + identity.question_id) else: facts += ("taint_evidence_not_applicable",) + if change_context is not None: + facts += ( + "change_sites=" + + _rows_json( + { + "base_revision": change_context.base_revision, + "head_revision": change_context.head_revision, + "changed_paths": change_context.changed_paths, + "deleted_paths": change_context.deleted_paths, + "declaration_paths": change_context.declaration_paths, + "path_encoding": change_context.path_encoding, + } + ), + ) + facts += tuple( + "affected_relationship=" + _rows_json(r.to_payload()) for r in change_context.relationships if r.target == identity + ) + if identity in (context_gaps or set()): + facts += ("change_context_incomplete",) reason = ( "unsupported_family" if spec is None @@ -506,7 +571,11 @@ def _scope_work( "region_budget" if index >= limits.max_regions else "tier_zero" - if limits.prune_tier0 and isinstance(spec, TaintSpec) and vector is not None and vector.tier == TIER_EXCLUDE + if limits.prune_tier0 + and identity not in (context_gaps or set()) + and isinstance(spec, TaintSpec) + and vector is not None + and vector.tier == TIER_EXCLUDE else None ) ) @@ -568,7 +637,13 @@ def _collect(regions: Sequence[ScanRegion]) -> list[tuple[str, ScanRegion, Arbit def _evidence_pass( - cpg: Any, work: Sequence[tuple[str, ScanRegion, ArbiterSpec]], hooks: str, *, degradations: list[Mapping[str, object]] | None = None + cpg: Any, + work: Sequence[tuple[str, ScanRegion, ArbiterSpec]], + hooks: str, + *, + degradations: list[Mapping[str, object]] | None = None, + context_rows: dict[QuestionIdentity, list[Mapping[str, object]]] | None = None, + unit: str = "repository", ) -> tuple[dict[tuple[str, str, str], Evidence], int]: """The evidence vector of every taint pair in ``work``, keyed by (path, function, family). @@ -578,7 +653,10 @@ def _evidence_pass( grouped_taint = _grouped(list(work), hook_callbacks=hooks).get("taint", {}) if not grouped_taint: return {}, 0 - requests = {rid: {**dict(params), "evidenceOnly": "true"} for rid, params in grouped_taint.items()} + requests = { + rid: {**dict(params), "evidenceOnly": "true", **({"contextEvidence": "true"} if context_rows is not None else {})} + for rid, params in grouped_taint.items() + } answered = cpg.run_batch("taint", requests) if answered is None: return {}, len(requests) @@ -601,6 +679,8 @@ def _evidence_pass( if pair is None or rid not in requests or not isinstance(rows, list): continue region, spec = pair + if context_rows is not None: + context_rows[_identity(unit, region, spec.family)] = [r for r in rows if isinstance(r, Mapping)] evidence = evidence_from_rows( rows, entry=_is_entry_point(region), diff --git a/tests/test_model_change_context.py b/tests/test_model_change_context.py new file mode 100644 index 0000000..2d5b7b8 --- /dev/null +++ b/tests/test_model_change_context.py @@ -0,0 +1,171 @@ +"""Change evidence informs exact existing scope, never another inclusion rule.""" + +from dataclasses import replace + +import pytest +from test_model_scope import Backend, region, vector + +from openultrasast.model.contracts import ChangeContext, ChangedSpan, LineCorrespondence +from openultrasast.model.scan import ScanBudget, scan_repository + + +def context(path="source.php"): + return ChangeContext( + "base", + "head", + (path.encode().hex(),), + (), + (ChangedSpan(path.encode().hex(), 2, 2, "base"),), + (), + (), + (), + (), + "filesystem-bytes-hex", + (LineCorrespondence(path.encode().hex(), path.encode().hex(), 3, 3, 2),), + ) + + +def row(path, kind="callee", function="run"): + return {"kind": "context_method", "path": path, "function": function, "startLine": 1, "endLine": 10, "relationship": kind} + + +@pytest.mark.parametrize("language,ext", [("php", "php"), ("javascript", "js")]) +@pytest.mark.parametrize("kind", ["entry", "callee", "field", "hook", "guard"]) +def test_changed_dependency_reaches_unchanged_ranked_question(tmp_path, language, ext, kind): + source, sink = f"source.{ext}", f"sink.{ext}" + (tmp_path / source).write_text("source input\nremoved guard\nunchanged operation\n") + (tmp_path / sink).write_text("unchanged sink(input)\n") + backend = Backend({sink: vector() + [row(source, kind), {"kind": "context_summary"}]}) + result = scan_repository(tmp_path, [region(sink, language)], backend=backend, change_context=context(source), population_complete=True) + assert len(result.scope.selected) == 1 + relationship = result.change_context.relationships[0] + assert relationship.source.path == source + assert relationship.target == result.scope.selected[0].identity + assert relationship.kind == kind + assert any("base_span" in e for e in relationship.evidence) + assert any("affected_relationship=" in e for e in result.scope.selected[0].evidence) + assert len(backend.executed) == 1 + + +def test_unavailable_relationships_cannot_be_completed_negative(tmp_path): + (tmp_path / "sink.php").write_text("readable\n") + result = scan_repository( + tmp_path, + [region("sink.php", "php")], + backend=Backend({"sink.php": vector(False)}), + change_context=context(), + population_complete=True, + ) + assert not any(q.reason == "tier_zero" for q in result.scope.deferred) + assert result.question_outcomes[0].status == "unresolved" + assert any("context_projection_unavailable" in b for b in result.scope.unresolved_boundaries) + + +def test_change_does_not_replace_ranker_selection(tmp_path): + for path in ("source.php", "sink.php"): + (tmp_path / path).write_text("readable\n") + regions = [region("source.php", "php", rank=0.1), region("sink.php", "php", rank=1)] + rows = {p: vector() + [row(p), {"kind": "context_summary"}] for p in ("source.php", "sink.php")} + kwargs = dict(backend=Backend(rows), budget=ScanBudget(max_regions=1), population_complete=True) + baseline = scan_repository(tmp_path, regions, **kwargs) + changed = scan_repository(tmp_path, regions, change_context=context(), **kwargs) + assert [q.identity for q in baseline.scope.selected] == [q.identity for q in changed.scope.selected] + assert [q.score for q in baseline.scope.selected] == [q.score for q in changed.scope.selected] + + +def test_global_boundary_survives_answer(tmp_path): + (tmp_path / "sink.php").write_text("readable\n") + ctx = replace(context(), unresolved_boundaries=("external summary missing",)) + result = scan_repository( + tmp_path, [region("sink.php", "php")], backend=Backend({"sink.php": vector() + [{"kind": "context_summary"}]}), change_context=ctx + ) + assert result.question_outcomes[0].status == "unresolved" + assert "external summary missing" in result.scope.unresolved_boundaries + + +@pytest.mark.parametrize("path", ["../external.php", "/outside/missing.php", "missing.php"]) +def test_missing_or_external_source_cannot_manufacture_relationship(tmp_path, path): + (tmp_path / "sink.php").write_text("readable\n") + result = scan_repository( + tmp_path, + [region("sink.php", "php")], + backend=Backend({"sink.php": vector() + [row(path), {"kind": "context_summary"}]}), + change_context=context(), + ) + assert result.change_context.relationships == () + assert result.question_outcomes[0].status == "unresolved" + + +@pytest.mark.parametrize("field", ["deleted_paths", "declaration_paths"]) +def test_unexported_configuration_and_deleted_dependencies_are_explicit(tmp_path, field): + (tmp_path / "source.php").write_text("readable\n") + ctx = replace(context(), **{field: (b"source.php".hex(),)}) + result = scan_repository( + tmp_path, + [region("source.php", "php")], + backend=Backend({"source.php": vector() + [row("source.php"), {"kind": "context_summary"}]}), + change_context=ctx, + ) + assert result.question_outcomes[0].status == "unresolved" + assert any("dependency_projection_unavailable" in b for b in result.scope.unresolved_boundaries) + + +def test_failed_build_retains_comparison_and_boundary(tmp_path): + (tmp_path / "source.php").write_text("readable\n") + ctx = replace(context(), unresolved_boundaries=("missing base",)) + result = scan_repository(tmp_path, [region("source.php", "php")], backend=Backend(fail=True), change_context=ctx) + assert result.change_context == ctx + assert "missing base" in result.scope.unresolved_boundaries + + +def test_full_paths_do_not_merge_duplicate_basenames(tmp_path): + (tmp_path / "manifest.txt").write_text("input census\n") + for directory in ("a", "b"): + (tmp_path / directory).mkdir() + (tmp_path / directory / "source.php").write_text("readable\n") + result = scan_repository( + tmp_path, + [region("b/source.php", "php")], + backend=Backend({"b/source.php": vector() + [row("b/source.php"), {"kind": "context_summary"}]}), + change_context=context("a/source.php"), + ) + assert result.change_context.relationships == () + + +def test_context_expansion_uses_transaction_deadline(tmp_path): + from openultrasast.model.contracts import ExecutionBudget, QuestionIdentity + from openultrasast.model.regions import affected_context + + (tmp_path / "source.php").write_text("readable\n") + question = QuestionIdentity("unit", "php", "source.php", "run", "injection") + enriched, missing = affected_context( + context(), [question], {question: [row("source.php"), {"kind": "context_summary"}]}, tmp_path, ExecutionBudget(0, 1) + ) + assert missing == {question} + assert enriched.relationships == () + assert "change_context_deadline_exhausted" in enriched.unresolved_boundaries + + +def test_empty_method_projection_is_not_complete_negative(tmp_path): + (tmp_path / "source.php").write_text("readable\n") + result = scan_repository( + tmp_path, + [region("source.php", "php")], + backend=Backend({"source.php": vector(False) + [{"kind": "context_summary"}]}), + change_context=context(), + ) + assert result.scope.selected + assert result.question_outcomes[0].status == "unresolved" + assert any("context_scope_empty" in gap for gap in result.scope.unresolved_boundaries) + + +def test_missing_base_without_adapter_diagnostic_stays_incomplete(tmp_path): + (tmp_path / "source.php").write_text("readable\n") + result = scan_repository( + tmp_path, + [region("source.php", "php")], + backend=Backend({"source.php": vector() + [row("source.php"), {"kind": "context_summary"}]}), + change_context=replace(context(), base_revision=None), + ) + assert result.question_outcomes[0].status == "unresolved" + assert "comparison_base_unavailable" in result.scope.unresolved_boundaries