mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-17 12:09:27 +00:00
contributor-scan 2.8. VAmPI's last two false positives were register_user
and login_user, both declared open by its OpenAPI spec: the arbiter was
reporting a missing guard on endpoints whose contract says there is
nothing to miss.
**The fix keys on provenance, not on the value, and that distinction is
the whole task.** The mapper says "public" in two very different
situations. An OpenAPI operation with no `security` block is DECLARED
open, because the spec is a complete contract. A Flask handler with no
`@login_required` is "public" only because nothing was found -- which is
precisely the bug this family exists to report. Suppressing on the value
alone would have silenced the detector on its primary case, so
EntryPointRecord now carries `access_declared` and only a declaration
removes the obligation.
WordPress supplied the second source of declared access on the same day --
wp_ajax_nopriv_ for a logged-out handler, wp_ajax_ for a logged-in one,
permission_callback => __return_true for an open REST route. A rule resting
on one framework's convention would be a special case; two make it a shape.
vampi entailed 6 -> 4
access-control precision 2 of 4 -> 2 of 2
known vulnerabilities 3 of 3 still entailed
access_control offered to 6 of 14 handlers, exactly the six the spec
declares authenticated
**And the guard that decides whether it is a real improvement:** the
access-control pair corpus, which nobody tuned this against, is unmoved --
pair_correct 3 and leak 1 across all nine, pair_correct 2 and leak 0.0%
across the seven from projects unrelated to VAmPI, identical to task 2.6.
The precision was not bought by silencing the family. Pair excerpts carry
no route manifest and no hook, so nothing in them is declared open and the
rule correctly does not fire.
Gates byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
177 lines
9.0 KiB
Python
177 lines
9.0 KiB
Python
"""Where to look, and what to look for there (contributor-scan, Req 2).
|
|
|
|
Pair scoring could rely on a labelled function: the corpus declares which function carries the bug and which
|
|
family it belongs to. A contributor has neither — *which code is risky* is the question they are asking. So a
|
|
repository scan derives its regions from the code itself: entry points where the mapper found them, whole
|
|
files where it did not, and each region carries only the families its shape and language actually admit.
|
|
|
|
Two restraints are deliberate:
|
|
|
|
* **The rank comes from what ``EntryPointRecord`` already carries**, not from a new signal invented here. The
|
|
mapper has already decided whether a handler is public or local-only and whether it sits at a trust
|
|
boundary; a second opinion computed from the same evidence would be a way of disagreeing with ourselves.
|
|
* **A family is offered only where its arbiter could answer.** An obligation needs a reachable operation, so
|
|
``access_control`` is offered where there is a handler and withheld from a library file that has none.
|
|
Asking every family of every region is the unbounded version of this, and at repository scale the budget is
|
|
the thing being spent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass, replace
|
|
|
|
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
|
|
# most, so it is looked at first when the budget is finite.
|
|
_ACCESS_RANK: dict[str, float] = {
|
|
"public": 1.0,
|
|
"authenticated": 0.8,
|
|
"role-restricted": 0.6,
|
|
"contract-only/callback": 0.4,
|
|
"review-required": 0.3,
|
|
"local-only": 0.2,
|
|
}
|
|
|
|
# Languages whose facts the model layer carries. Anything else yields no region rather than an empty scan
|
|
# that looks like a clean bill of health.
|
|
_LANGUAGES = {"python", "javascript", "typescript", "java", "c", "c_cpp", "php"}
|
|
_NORMALISE = {"typescript": "javascript", "c_cpp": "c"}
|
|
|
|
# What a CPG calls a file's top-level code. A module body is a method like any other to the engine, and
|
|
# naming it here is what lets a region ask about the code that is not inside any function.
|
|
MODULE_SCOPE = "<module>"
|
|
|
|
# What the mapper emits for `if __name__ == '__main__':` -- a marker for a module-level entry point, not
|
|
# the name of a function. Sent to the engine as a function name it matched nothing, so a repository's
|
|
# module-level settings were unaskable: VAmPI's `host='0.0.0.0'` went unreported.
|
|
_MODULE_MARKERS = frozenset({"__main__"})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScanRegion:
|
|
"""A place to arbitrate, and the families worth arbitrating there."""
|
|
|
|
path: str
|
|
function: str | None # None for a file-level fallback: there is no labelled function on a scan path
|
|
language: str
|
|
families: tuple[str, ...]
|
|
rank: float
|
|
source: str # "entry_point" | "module_scope" | "file_fallback"
|
|
# Does the project's own build declaration name this file? True when it declares nothing at all --
|
|
# silence is not exclusion, and no repository is penalised for a build system we cannot read.
|
|
shipped: bool = True
|
|
|
|
|
|
def regions_for(entries: Sequence[object], targets: Sequence[object], *, shipped: frozenset[str] | None = None) -> tuple[ScanRegion, ...]:
|
|
"""Regions for a repository, strongest first.
|
|
|
|
A file with an entry point yields regions for its handlers only — a fallback region beside them would
|
|
scan the same code twice and spend the budget on duplicates.
|
|
"""
|
|
by_path: dict[str, str] = {}
|
|
for target in targets:
|
|
language = _language_of(target)
|
|
if language:
|
|
by_path[str(getattr(target, "path", ""))] = language
|
|
|
|
# One region per (path, function). The mapper can emit several entry points for a single file -- on a
|
|
# ten-line Flask file it produced three, one named and two nameless -- and turning each into a region
|
|
# scanned the same code repeatedly, multiplying Joern invocations for no new coverage. Where several
|
|
# entries agree on a region, the highest access rank wins, because that is the one worth looking at first.
|
|
best: dict[tuple[str, str | None], ScanRegion] = {}
|
|
for entry in entries:
|
|
path = str(getattr(entry, "path", ""))
|
|
language = by_path.get(path)
|
|
if language is None:
|
|
continue
|
|
# A handler its own contract declares OPEN carries no authorization obligation: there is nothing
|
|
# for a guard to be missing from. The declaration is what makes this sound -- an OpenAPI operation
|
|
# with no `security` block, a `wp_ajax_nopriv_` hook, a REST `permission_callback => __return_true`.
|
|
# An INFERRED "public" is the opposite case and must keep the family, because for a decorator
|
|
# framework it means only that no `@login_required` was found, which is the bug itself.
|
|
declared_open = bool(getattr(entry, "access_declared", False)) and str(getattr(entry, "access_level", "")) == "public"
|
|
families = _families(language, has_handler=not declared_open)
|
|
if not families:
|
|
continue
|
|
function = getattr(entry, "function_name", None) or None
|
|
module_body = function in _MODULE_MARKERS
|
|
if module_body:
|
|
# A module body is not a handler, whatever the mapper called it, so it is offered the families a
|
|
# file gets rather than the families a handler gets. Carrying `access_control` here entailed a
|
|
# missing authorization check on `if __name__ == '__main__':`.
|
|
function, families = MODULE_SCOPE, _families(language, has_handler=False)
|
|
if not families:
|
|
continue
|
|
rank = _ACCESS_RANK.get(str(getattr(entry, "access_level", "")), 0.5)
|
|
key = (path, function)
|
|
current = best.get(key)
|
|
if current is None or rank > current.rank:
|
|
best[key] = ScanRegion(
|
|
path=path,
|
|
function=function,
|
|
language=language,
|
|
families=families,
|
|
rank=rank,
|
|
source="module_scope" if module_body else "entry_point",
|
|
)
|
|
|
|
# A nameless entry point IS the file. Where a named region already covers that file it adds nothing but
|
|
# a second full-file pass, so it is dropped -- unless the file has no named region at all.
|
|
named_paths = {path for path, function in best if function is not None}
|
|
regions = [region for (path, function), region in best.items() if function is not None or path not in named_paths]
|
|
|
|
# Every file whose regions are all function-scoped still has code that is in no function: the settings, the
|
|
# route registrations, the globals. Those regions cannot see it -- their queries filter by function name --
|
|
# and there is no file-level region beside them, because one would duplicate every function's findings.
|
|
# So the module body gets its own region, which partitions the file rather than overlapping it.
|
|
#
|
|
# This was cheap to add only after batching: before it, a region was a JVM.
|
|
scoped = {region.path for region in regions if region.function is not None}
|
|
have_module = {region.path for region in regions if region.function == MODULE_SCOPE}
|
|
for path in sorted(scoped - have_module):
|
|
language = by_path.get(path)
|
|
if language is None:
|
|
continue
|
|
families = _families(language, has_handler=False)
|
|
if families:
|
|
regions.append(
|
|
ScanRegion(path=path, function=MODULE_SCOPE, language=language, families=families, rank=0.15, source="module_scope")
|
|
)
|
|
|
|
covered = {region.path for region in regions}
|
|
|
|
for path, language in by_path.items():
|
|
if path in covered:
|
|
continue
|
|
families = _families(language, has_handler=False)
|
|
if not families:
|
|
continue
|
|
regions.append(ScanRegion(path=path, function=None, language=language, families=families, rank=0.1, source="file_fallback"))
|
|
|
|
# A total order: rank descending, then path and function, so two runs of one repository agree.
|
|
if shipped is not None:
|
|
regions = [replace(region, shipped=region.path in shipped) for region in regions]
|
|
|
|
# Shipped first, then rank. A project's own build declaration outranks any score computed here: libpng's
|
|
# twenty-five entry points were all `main()` in example programs and test tools, and they took the
|
|
# interprocedural analysis with them.
|
|
return tuple(sorted(regions, key=lambda r: (not r.shipped, -r.rank, r.path, r.function or "")))
|
|
|
|
|
|
def _language_of(target: object) -> str | None:
|
|
language = str(getattr(target, "language", "") or "")
|
|
if language not in _LANGUAGES:
|
|
return None
|
|
return _NORMALISE.get(language, language)
|
|
|
|
|
|
def _families(language: str, *, has_handler: bool) -> tuple[str, ...]:
|
|
"""The families this language has facts for, minus those whose arbiter needs a handler and has none."""
|
|
families = set(taint_specs(language=language))
|
|
families |= set(config_specs(language=language))
|
|
if has_handler:
|
|
families |= set(dominance_specs(language=language))
|
|
return tuple(sorted(families))
|