mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-17 12:09:27 +00:00
dynamic_external_or_depth_context_unresolved was applied as though it invalidated everything about a question. It does not. Bounded reachability can hide further flows; it cannot invalidate a flow that was traced, and it cannot remove a call from a structurally scoped enumeration. At scan level the boundary is now recorded against its owning question without demoting that question's answer. An unavailable context projection, an empty context scope and an unreadable method extent are unknown ownership and still demote. In comparison it no longer blocks the head's own claim, since the finding rests on a path that was actually traced, and no longer blocks an absence read off the enumerated inventory. It still blocks every claim that rests on a flow not having been traced, including the anchored source-connection branch, which now reports base_reachability_bounded rather than silently passing. The query declares the basis of its enumeration. A file- or nesting-scoped question walks calls structurally, so an unresolved destination cannot remove one from scope. Following the call graph makes the reachable set the scope, so the same unresolved destination can shrink what was enumerated; that inventory cannot establish absence while reachability is bounded, and says so. One prior control asserted a question's own reachability gap blocks its own claim. It is updated with its reason and split, so an own projection gap is still proven to block while an own reachability gap no longer does. 1,362 tests passed, nine skipped. Rollout stays NO-GO. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
272 lines
14 KiB
Python
272 lines
14 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
|
|
|
|
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
|
|
# 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))
|
|
|
|
|
|
AMBIGUOUS_CORRESPONDENCE = "ambiguous_line_correspondence"
|
|
# Reachability from or through the scoped methods was bounded. It limits which further flows
|
|
# could be seen; it does not make this question's own answer or context ownership unknown, so
|
|
# it is recorded as a boundary without marking the question's context incomplete. The later
|
|
# comparison consults it for exactly the claims that rest on a flow not having been traced.
|
|
REACHABILITY_BOUNDED = "dynamic_external_or_depth_context_unresolved"
|
|
|
|
|
|
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")
|
|
# Line-correspondence ambiguity is not a context gap: it says which base lines a head
|
|
# line could map to, which only the later per-operation comparison consumes. Inheriting
|
|
# it here made one file with repeated unchanged lines invalidate every question in the
|
|
# repository. The mapping itself is still withheld, so an operation that needs an
|
|
# ambiguous anchor remains uncomparable.
|
|
inherited_gap = any(not gap.endswith(":" + AMBIGUOUS_CORRESPONDENCE) for gap in 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")
|
|
recorded_only: list[str] = []
|
|
for row in rows:
|
|
if row.get("kind") == "context_boundary":
|
|
reason = str(row.get("reason", "context_unresolved"))
|
|
(recorded_only if reason.startswith(REACHABILITY_BOUNDED) else local_gaps).append(reason)
|
|
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, *recorded_only))
|
|
return replace(context, relationships=tuple(relationships), unresolved_boundaries=tuple(dict.fromkeys(gaps))), affected_gaps
|