mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-19 04:59:28 +00:00
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:
co-authored by
Claude Opus 5
parent
1ce7e9db9b
commit
9cce5a3181
@@ -53,12 +53,34 @@ class SanitizerFact:
|
||||
literal_format_arg: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DispatchFact:
|
||||
"""A framework that connects a producer to a consumer through a STRING key rather than a call.
|
||||
|
||||
WordPress is the example that forced this, but nothing here is about WordPress. The shape is general --
|
||||
a registry keyed by a name, written as a literal at both ends -- and it is how Django signals, jQuery
|
||||
events, Symfony's event dispatcher and every plugin system of this kind route data. No CPG frontend can
|
||||
draw the edge, because neither end names the other.
|
||||
|
||||
``register`` names the calls that put a callback INTO the registry, ``apply`` the calls that take a value
|
||||
back OUT of it. Both belong in a fact table for the same reason sinks do: a query that hardcodes
|
||||
`apply_filters` is a query that only works on one framework, and the next one needs a code change rather
|
||||
than a row.
|
||||
"""
|
||||
|
||||
id: str
|
||||
register: tuple[str, ...]
|
||||
apply: tuple[str, ...]
|
||||
language: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticFacts:
|
||||
version: str
|
||||
sources: tuple[SourceFact, ...]
|
||||
sinks: tuple[SinkFact, ...]
|
||||
sanitizers: tuple[SanitizerFact, ...]
|
||||
dispatches: tuple[DispatchFact, ...] = ()
|
||||
|
||||
def for_language(self, language: str) -> SemanticFacts:
|
||||
key = _LANGUAGE_ALIASES.get(language, language)
|
||||
@@ -67,6 +89,7 @@ class SemanticFacts:
|
||||
sources=tuple(item for item in self.sources if item.language == key),
|
||||
sinks=tuple(item for item in self.sinks if item.language == key),
|
||||
sanitizers=tuple(item for item in self.sanitizers if item.language == key),
|
||||
dispatches=tuple(item for item in self.dispatches if item.language == key),
|
||||
)
|
||||
|
||||
|
||||
@@ -77,6 +100,7 @@ def load_facts(directory: Path | None = None) -> SemanticFacts:
|
||||
sources: list[SourceFact] = []
|
||||
sinks: list[SinkFact] = []
|
||||
sanitizers: list[SanitizerFact] = []
|
||||
dispatches: list[DispatchFact] = []
|
||||
versions: list[str] = []
|
||||
found = False
|
||||
for path in sorted(root.glob("*.toml")):
|
||||
@@ -90,9 +114,33 @@ def load_facts(directory: Path | None = None) -> SemanticFacts:
|
||||
sources.extend(_sources(payload.get("source"), language, path))
|
||||
sinks.extend(_sinks(payload.get("sink"), language, path))
|
||||
sanitizers.extend(_sanitizers(payload.get("sanitizer"), language, path))
|
||||
dispatches.extend(_dispatches(payload.get("dispatch"), language, path))
|
||||
if not found:
|
||||
raise FactLoadError(f"no semantic fact files in {root}")
|
||||
return SemanticFacts(version=versions[0] if versions else "1", sources=tuple(sources), sinks=tuple(sinks), sanitizers=tuple(sanitizers))
|
||||
return SemanticFacts(
|
||||
version=versions[0] if versions else "1",
|
||||
sources=tuple(sources),
|
||||
sinks=tuple(sinks),
|
||||
sanitizers=tuple(sanitizers),
|
||||
dispatches=tuple(dispatches),
|
||||
)
|
||||
|
||||
|
||||
def _dispatches(value: object, language: str, path: Path) -> list[DispatchFact]:
|
||||
facts: list[DispatchFact] = []
|
||||
for item in _items(value):
|
||||
identifier = str(item.get("id", "")).strip()
|
||||
if not identifier:
|
||||
raise FactLoadError(f"dispatch without an id in {path}")
|
||||
facts.append(
|
||||
DispatchFact(
|
||||
id=identifier,
|
||||
register=_strings(item.get("register"), "register", path),
|
||||
apply=_strings(item.get("apply"), "apply", path),
|
||||
language=language,
|
||||
)
|
||||
)
|
||||
return facts
|
||||
|
||||
|
||||
def _items(value: object) -> list[dict[str, object]]:
|
||||
|
||||
Reference in New Issue
Block a user