From 7bb7912e4e504f0fa49d0b3880638dcd7c8fe7fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 11:53:27 +0000 Subject: [PATCH 1/3] =?UTF-8?q?audit(xaml):=20Phase-2=20join=20=E2=80=94?= =?UTF-8?q?=20XAML203=20view-subscription=20leak=20(build-free)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the XAML<->C# Phase-2 join from the design note. Links the two fact sources by the deterministic XAML naming convention — NOT the .g.cs build artifact — so it stays build-free (Linux CI, non-compiling solutions). - xaml_join.py: join(xaml-facts, OwnIR) -> XAML203. A view whose x:Class component has an OwnIR subscription flagged released=false, wired from a load-lifecycle handler (Loaded/Initialized/DataContextChanged), is the closed-view-retained leak; anchored at the XAML site, names the C# subscription. released=false is authoritative (the engine already checked for -= across the class), so no XAML Unloaded heuristic. SARIF emit + CLI + 8-check selftest. - xaml_facts.py: also capture x:Name (named_elements) — the join's element->field link. - own-check.sh: add --emit-facts to persist the OwnIR facts; owncheck.py writes own-check.facts.json so the join has a real input in an SDK run. - run_static.py: run the join when xaml-facts.json + own-check.facts.json are both present; fold xaml-join.sarif into the pipeline (+ integration selftest). - taxonomy: XAML203 -> category 2 (subscription leak, P1). - design note + README: document the convention-as-link decision, the first rule, and .g.cs/BAML as deferred build-tier ground-truth (not the mechanism). Binding-path-hotness (XAML200/204, needs the DataContext type) is the next increment, deliberately not guessed here. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015zoG4YNCUf7RA5r8GAXMmb --- .github/workflows/ci.yml | 1 + audit/README.md | 16 +- audit/static/run_static.py | 43 +++++ audit/static/taxonomy/categories.yml | 6 + audit/static/tools/owncheck.py | 8 +- audit/static/tools/xaml_facts.py | 15 +- audit/static/tools/xaml_join.py | 271 +++++++++++++++++++++++++++ docs/notes/xaml-analyzer-design.md | 20 ++ scripts/own-check.sh | 16 +- 9 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 audit/static/tools/xaml_join.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2320f3e..4db59c4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,7 @@ jobs: python audit/aggregate/report.py --selftest python audit/static/tools/xaml_check.py --selftest python audit/static/tools/xaml_facts.py --selftest + python audit/static/tools/xaml_join.py --selftest python audit/static/run_static.py --selftest python audit/runtime/ingest.py --selftest diff --git a/audit/README.md b/audit/README.md index 60b1b503..13b42600 100644 --- a/audit/README.md +++ b/audit/README.md @@ -49,6 +49,7 @@ audit/ codeql.sh # build-free runner: CodeQL build-mode=none, security-and-quality xaml_check.py # build-free runner: markup-only XAML perf/lifetime pass (stdlib XML, no SDK) xaml_facts.py # XAML facts extractor (resource graph + binding facts) -> xaml-facts.json (Phase-2 seam) + xaml_join.py # XAML<->C# Phase-2 join: xaml-facts.json + OwnIR -> XAML203 link findings (build-free) roslyn_pack.ps1 # build-required runner (local Windows): NetAnalyzers/Roslynator/... infersharp.sh # build-required runner: Infer# over built binaries inject/ # OwnAudit.Directory.Build.props/.targets (analyzer injection, gated) @@ -112,6 +113,7 @@ python audit/aggregate/score.py --selftest python audit/aggregate/report.py --selftest python audit/static/tools/xaml_check.py --selftest # XAML rules + line preservation + SARIF round-trip python audit/static/tools/xaml_facts.py --selftest # XAML facts: binding parser + resource graph +python audit/static/tools/xaml_join.py --selftest # XAML<->C# join: XAML203 view-subscription leak python audit/static/run_static.py --selftest # full pipeline end-to-end on fixtures ``` @@ -136,10 +138,16 @@ python audit/static/run_static.py --selftest # full pipeline end-to-end on fix covered, not NO-TOOL. Design + the full rule catalogue, phasing, and the Phase-2 binding-path join: [`../docs/notes/xaml-analyzer-design.md`](../docs/notes/xaml-analyzer-design.md). - **XAML Phase-2 seam — done:** `static/tools/xaml_facts.py` emits `xaml-facts.json` (resource graph - + binding facts: parsed binding paths / converters / handlers + the file's `x:Class`) from the same - parsed tree, in an OwnIR-parallel envelope. This is the structured input the binding-path join reads - next to the `OwnSharp.Extractor` OwnIR facts. The join itself (Roslyn-linked XAML2xx) and Phase 3 - (runtime correlation) remain deferred. + + binding facts: parsed binding paths / converters / handlers + `x:Class` + `x:Name`) from the same + parsed tree, in an OwnIR-parallel envelope. +- **XAML Phase-2 join (first slice) — done:** `static/tools/xaml_join.py` links `xaml-facts.json` to the + OwnIR facts own-check now persists (`--emit-facts` → `own-check.facts.json`) by the deterministic XAML + naming convention (`x:Class`→type, handler→method) — **build-free, no `.g.cs`/build needed**. First + rule **XAML203** (view subscribes from a load-lifecycle handler but the OwnIR verdict is + `released=false` → closed view retained), anchored at the XAML site and naming the C# subscription. + `run_static.py` runs the join whenever both fact sources are present and folds its SARIF into the + pipeline. Binding-path-hotness rules (XAML200/204, need the DataContext type) and an optional `.g.cs` + ground-truth cross-check are documented build-tier follow-ups. Phase 3 (runtime correlation) deferred. - **Runtime (Phase 2) — started:** the runtime→pipeline bridge (`runtime/ingest.py`, CI-gated), the leak-harness scenario schema + one scenario, runtime rule mappings in the taxonomy (categories 2/3/4/11), and the C# leak-harness skeleton. See diff --git a/audit/static/run_static.py b/audit/static/run_static.py index ed3bfada..658a2fc7 100755 --- a/audit/static/run_static.py +++ b/audit/static/run_static.py @@ -37,6 +37,7 @@ from report import render_html, render_json, render_markdown, render_sarif # noqa: E402 from score import score # noqa: E402 from xaml_check import run_xaml_check # noqa: E402 +from xaml_join import run_join # noqa: E402 try: from oracle_compare import parse_sarif @@ -133,6 +134,19 @@ def run(target: str, profile: dict[str, Any], out_dir: Path, target_name: str = if st["available"] and st["sarif"]: sarif_inputs.append(("xaml", st["sarif"])) + # XAML Phase-2 join (docs/notes/xaml-analyzer-design.md → "Phase 2 mechanics"): + # link xaml-facts.json to the OwnIR facts own-check emitted (--emit-facts) and + # fold the XAML2xx link findings into the same pipeline. Build-free convention + # join; runs only when both fact sources are present (so it no-ops without an SDK + # for own-check, exactly like the other opportunistic pickups below). + xaml_facts = out_dir / "xaml-facts.json" + ownir_facts = out_dir / "own-check.facts.json" + if xaml_facts.exists() and ownir_facts.exists(): + st = run_join(xaml_facts, ownir_facts, out_dir) + tiers.append(st) + if st["available"] and st["sarif"]: + sarif_inputs.append(("xaml-join", st["sarif"])) + # Pick up any build-required SARIFs already dropped here by the Windows runners. # Roslyn writes ONE SARIF PER PROJECT under roslyn/ (see the injected props's # $(MSBuildProjectName).sarif), so glob the directory; Infer# writes a single file. @@ -335,6 +349,35 @@ def check(ok: bool, msg: str) -> None: # total derives from the call count check(res3["totals"]["candidates"] >= 1, "a XAML107 markup finding must flow through to a scored cluster") + # The XAML Phase-2 join must wire in when both fact sources are present: a view + # with a Loaded handler + an OwnIR component with an unreleased subscription -> + # a XAML203 cluster, on Linux with no SDK (own-check's facts are pre-placed here + # the way the Windows/SDK run would drop them). + with tempfile.TemporaryDirectory() as td4: + out4 = Path(td4) / "out" + out4.mkdir(parents=True) + src4 = Path(td4) / "src" / "Views" + src4.mkdir(parents=True) + (src4 / "CustomerView.xaml").write_text( + '\n', + encoding="utf-8") + # own-check's OwnIR facts (what --emit-facts would drop): CustomerView has an + # unreleased subscription. + (out4 / "own-check.facts.json").write_text(json.dumps({ + "ownir_version": 0, "module": "App", "components": [ + {"name": "CustomerView", "file": "Views/CustomerView.xaml.cs", + "subscriptions": [{"event": "_bus.Changed", "handler": "OnChanged", + "line": 21, "released": False}]}]}), encoding="utf-8") + profile4 = {"name": "t", "severity_floor": "warning", + "tiers": {"build_free": ["xaml"]}} + res4 = run(str(Path(td4) / "src"), profile4, out4, target_name="t/p") + check(any(t["tool"] == "xaml-join" and t["available"] for t in res4["tiers"]), + "xaml-join tier must run when xaml-facts.json + own-check.facts.json exist") + check(res4["totals"]["clusters"] >= 1, + "a XAML203 join finding must flow through to a scored cluster") + fails = [c for c in checks if c] for f in fails: print(f"RUN_STATIC SELFTEST FAIL: {f}") diff --git a/audit/static/taxonomy/categories.yml b/audit/static/taxonomy/categories.yml index 878a7d21..514da3aa 100644 --- a/audit/static/taxonomy/categories.yml +++ b/audit/static/taxonomy/categories.yml @@ -36,6 +36,12 @@ rules: OWN014: {category: 2, name: region-escape} # escape to a longer-lived region # (vm->App, SystemEvents) — NOT a # generic subscription-leak + # XAML Phase-2 join: a view (x:Class) that subscribes from a load-lifecycle handler + # but never releases (OwnIR released=false). Categorized as a subscription leak so + # it scores as the P1 leak it is; the join already stitches the markup site to the + # C# subscription it names (the leak lives in the .xaml.cs, a different basename, so + # it does not spatially cluster with the own-check OWN001 on that file). + "XAML203": {category: 2, name: xaml-subscription-leak} # ── Category 5: INPC correctness ───────────────────────────────────────────── "INPC0*": {category: 5, name: inpc-correctness} # PropertyChangedAnalyzers diff --git a/audit/static/tools/owncheck.py b/audit/static/tools/owncheck.py index 8836b547..637f91b5 100755 --- a/audit/static/tools/owncheck.py +++ b/audit/static/tools/owncheck.py @@ -39,6 +39,7 @@ def run_own_check(target: str, out_dir: Path, severity: str = "warning", unavailable toolchain.""" out_dir.mkdir(parents=True, exist_ok=True) sarif_path = out_dir / "own-check.sarif" + facts_path = out_dir / "own-check.facts.json" status: dict[str, Any] = {"tool": "own-check", "tier": "build-free", "available": False, "sarif": None, "reason": ""} @@ -49,7 +50,10 @@ def run_own_check(target: str, out_dir: Path, severity: str = "warning", status["reason"] = "dotnet SDK not on PATH (needed by the C# fact extractor)" return status - cmd = [str(OWN_CHECK_SH), "--format", "sarif", "--severity", severity, "--", target] + # Persist the OwnIR facts too (--emit-facts): the XAML Phase-2 join consumes them + # alongside xaml-facts.json. Harmless if unused. + cmd = [str(OWN_CHECK_SH), "--format", "sarif", "--severity", severity, + "--emit-facts", str(facts_path), "--", target] if root is not None: cmd[1:1] = ["--root", str(root)] try: @@ -70,6 +74,8 @@ def run_own_check(target: str, out_dir: Path, severity: str = "warning", except json.JSONDecodeError: n = 0 status.update(available=True, sarif=str(sarif_path), findings=n) + if facts_path.exists(): + status["facts"] = str(facts_path) return status diff --git a/audit/static/tools/xaml_facts.py b/audit/static/tools/xaml_facts.py index 6c31aaf0..770ebcc5 100644 --- a/audit/static/tools/xaml_facts.py +++ b/audit/static/tools/xaml_facts.py @@ -205,9 +205,17 @@ def document_facts(root: Node, rel_path: str) -> dict[str, Any]: merged: list[dict[str, Any]] = [] bindings: list[dict[str, Any]] = [] handlers: list[dict[str, Any]] = [] + named: list[dict[str, Any]] = [] converters: set[str] = set() for n in root.walk(): + # x:Name / Name -> a generated field in the code-behind partial: a named + # element the code-behind can reference / subscribe / retain. The Phase-2 + # join uses these to link XAML elements to code-behind symbols. + if not n.is_property_element() and n.type_name() not in ("Binding", "TemplateBinding"): + nm = n.attr("Name") + if nm: + named.append({"name": nm.strip(), "type": n.type_name(), "line": n.line}) if n.local() == "MergedDictionaries" and n.is_property_element(): for c in n.children: src = c.attr("Source") @@ -249,6 +257,7 @@ def document_facts(root: Node, rel_path: str) -> dict[str, Any]: "merged_dictionaries": merged, "bindings": bindings, "event_handlers": handlers, + "named_elements": named, "converters_used": sorted(converters), } @@ -268,6 +277,7 @@ def total(field: str) -> int: "merged_dictionaries": total("merged_dictionaries"), "bindings": total("bindings"), "event_handlers": total("event_handlers"), + "named_elements": total("named_elements"), }, } @@ -357,7 +367,7 @@ def check(ok: bool, msg: str) -> None: ' \n' ' \n' - '