feat(cpg): give the files a frontend refuses a CPG of their own

A dropped file is dropped from the ONLY graph there is, so every question
about it has no answer -- which is how a 637-file plugin became a scan of
nothing. But those files build perfectly well alone: the failure is
per-invocation, not per-file. So a file the main build refuses is excluded
from it, given a second CPG, and both are queried with rows concatenated per
request id.

    pmpro two-file slice   before: cpg_empty, 0 findings
                           after:  18 entailed incl. CVE-2023-23488 at :936,
                                   reported with cpg_sharded

What this does NOT buy is a flow that crosses the shard boundary: a source in
the excluded file whose sink is in the rest of the tree is invisible to every
question asked, and no merging of results recovers it. That is why the split
is REPORTED rather than quietly served as if it were whole.

The excluded-file tree is symlinks at the original relative paths. A region
asks about `classes/class.memberorder.php` and the queries match a filename by
suffix, so a flattened copy would cheerfully answer about a file nobody asked
about.

Reports gain "What could not be analysed", rendering cpg_sharded, cpg_empty,
files_unparsed, query_failed and budget_exhausted in the reader's terms. A
degradation is a fact about coverage, not a warning to file away: silence in
the coverage table means "no fact table covered this", and silence about an
empty or split graph means something else entirely.

Also removes the WordPress vocabulary from the query, which was the other half
of this request. `apply_filters` was a hardcoded Set in taint.sc and
`add_action|add_filter|add_shortcode` a hardcoded alternation in mapping.py.
Both are now a `[[dispatch]]` fact:

    [[dispatch]]
    id = "wordpress_hooks"
    register = ["add_action", "add_filter", "add_shortcode"]
    apply = ["apply_filters", ..., "do_action", ...]

The shape is general -- a registry keyed by a string literal at both ends, as
Django signals, jQuery events and Symfony's dispatcher all are -- so a second
framework is a row in a TOML rather than an edit to a query. `dispatch_apply`
is empty for every language that declares none, and the hook half then does
nothing at all rather than looking for names that mean nothing there.

848 tests pass; gates byte-identical. wpstatistics 3/2 with the CVE at :225 on
the vulnerable side only, mwwpform 1/0, pmpro 18/1, VAmPI 4 unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-10 07:32:20 +02:00
co-authored by Claude Opus 5
parent 1ce7e9db9b
commit 9cce5a3181
13 changed files with 411 additions and 25 deletions
+51 -3
View File
@@ -23,6 +23,7 @@ def write_markdown_report(
obligations: Mapping[str, Mapping[str, object]] | None = None,
obligations_summary: Mapping[str, object] | None = None,
coverage: Sequence[Mapping[str, object]] | None = None,
degradations: Sequence[Mapping[str, object]] | None = None,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
verification_by_id = _verification_by_id(verifications or [])
@@ -87,8 +88,8 @@ def write_markdown_report(
cited_obligations[finding.finding_id] = info_o
_append_obligation_lines(lines, info_o, (mechanisms or {}).get(str(info_o.get("known_fix") or "")))
lines.extend(["", finding.rationale, ""])
if coverage:
_append_coverage(lines, coverage)
if coverage or degradations:
_append_coverage(lines, coverage or (), degradations or ())
if patterned:
_append_pattern_matches(lines, patterned)
if cited:
@@ -389,7 +390,7 @@ def _crowded_pattern_rules(findings: Sequence[StaticFinding]) -> list[StaticFind
return [finding for finding in findings if _is_pattern_only(finding) and counts.get(_rule_key(finding), 0) > _PATTERN_DETAIL_LIMIT]
def _append_coverage(lines: list[str], coverage: Sequence[Mapping[str, object]]) -> None:
def _append_coverage(lines: list[str], coverage: Sequence[Mapping[str, object]], degradations: Sequence[Mapping[str, object]] = ()) -> None:
"""What was looked for, and what could not be decided (Req 5.6).
A scan that reports nothing has established that nothing it MODELS was found. That is not the same as
@@ -418,6 +419,53 @@ def _append_coverage(lines: list[str], coverage: Sequence[Mapping[str, object]])
for row in stated:
lines.extend([f"**`{row.get('family')}` ({row.get('language')})** — {row.get('limits')}", ""])
_append_not_analysed(lines, degradations)
# A degradation is a fact about COVERAGE, not a warning to be filed away. Each of these says the scan
# answered a smaller question than the one it was asked, and a reader who is not told cannot know that the
# silence in the table above is narrower than it looks.
_COVERAGE_DEGRADATIONS: dict[str, str] = {
"cpg_empty": "The code graph came back with no methods in it, so **nothing above was analysed at all**.",
"cpg_build_failed": "The code graph could not be built, so nothing above was analysed.",
"cpg_sharded": (
"The graph had to be built in {shards} parts, because the frontend could not parse the whole tree at "
"once. Each part was queried, but **a flow whose source is in one part and whose sink is in another "
"is not visible to any question asked here**."
),
"files_unparsed": "{count} file(s) could not be parsed and are absent from the graph: {files}.",
"query_failed": "A `{kind}` query failed for {requests} region(s), so those regions were not decided.",
"budget_exhausted": "The model-call budget ran out with {regions_unasked} region(s) still unasked.",
"region_failed": "The region `{path}` could not be arbitrated.",
}
def _append_not_analysed(lines: list[str], degradations: Sequence[Mapping[str, object]]) -> None:
"""The part of the repository the scan did NOT get to, said plainly.
Silence in the coverage table means "no fact table covered this". Silence here would mean something
else entirely -- that the graph was empty, or split, or that the query never ran -- and the reader has
no way to tell those apart from the findings alone.
"""
rendered: list[str] = []
for item in degradations:
template = _COVERAGE_DEGRADATIONS.get(str(item.get("reason") or ""))
if template is None:
continue
fields = dict(item)
files = fields.get("files")
if isinstance(files, (list, tuple)):
fields["files"] = ", ".join(f"`{name}`" for name in files)
try:
rendered.append(template.format(**fields))
except (KeyError, IndexError): # a degradation shaped differently than its template expects
rendered.append(f"`{item.get('reason')}`")
if not rendered:
return
lines.extend(["### What could not be analysed", ""])
lines.extend(f"- {line}" for line in rendered)
lines.append("")
def _is_pattern_only(finding: StaticFinding) -> bool:
"""Was this proposed with nothing corroborating it?