feat(pre-push-safety-net): connect change context to ranked questions

This commit is contained in:
Marius Ciepluch
2026-09-13 10:41:32 +02:00
parent 546db24b8e
commit ebad83009e
11 changed files with 1570 additions and 17 deletions
@@ -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.
+4 -3
View File
@@ -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",
+3 -2
View File
@@ -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.
@@ -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."
]
}
+12
View File
@@ -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
+120
View File
@@ -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"<?php\nrequire_once 'helper.php';\nfunction handler($req) {\n if (!authorized($req)) return;\n helper($req);\n}\n",
b" if (!authorized($req)) return;\n",
"helper.php",
b"<?php\nfunction helper($value) { eval($value); }\n",
),
(
"javascript",
"api.js",
b"import { helper } from './helper.js';\nexport function handler(req) {\n"
b" if (!authorized(req)) return;\n helper(req);\n}\n",
b" if (!authorized(req)) return;\n",
"helper.js",
b"export function helper(value) { eval(value); }\n",
),
):
repo = Path(temporary) / language
repo.mkdir()
def git(*args: str, root: Path = repo) -> 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", "[email protected]")
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()
+64
View File
@@ -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()
+49 -3
View File
@@ -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("<operator>") && !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")
): _*
)
}
+81 -1
View File
@@ -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
+88 -8
View File
@@ -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),
+171
View File
@@ -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