mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
feat(pre-push-safety-net): connect change context to ranked questions
This commit is contained in:
@@ -95,7 +95,8 @@
|
||||
evidenceS: String,
|
||||
fileS: String,
|
||||
depthS: String,
|
||||
boundedS: String
|
||||
boundedS: String,
|
||||
contextS: String = ""
|
||||
): List[ujson.Obj] = {
|
||||
|
||||
// Word-boundary matching, never substring. `resolveUrl` contains `resolve`, so a bare-substring sanitizer
|
||||
@@ -692,7 +693,51 @@
|
||||
"cleansedOnCall" -> sink.argument.ast.l.exists(node => sanitizesHere(node))
|
||||
)
|
||||
}
|
||||
return summary :: perSink
|
||||
// Export locations already used by this query, for change attribution only. No new
|
||||
// source/sink/guard inference and no change to the evidence vector or its weights.
|
||||
val contextRows = if (contextS != "true") Nil else {
|
||||
def location(m: io.shiftleft.codepropertygraph.generated.nodes.Method, kind: String): ujson.Obj =
|
||||
ujson.Obj("kind" -> "context_method", "path" -> m.filename, "function" -> m.name,
|
||||
"startLine" -> m.lineNumber.getOrElse(-1), "endLine" -> m.lineNumberEnd.getOrElse(-1),
|
||||
"relationship" -> kind)
|
||||
val scoped = cpg.method.filterNot(_.isExternal).filter(m => inScope(m)).l
|
||||
val methods = scoped.map(m => location(m, if (labeledMethods.exists(_.id == m.id)) "entry" else "callee"))
|
||||
// Field carrying is deliberately same-file in the existing abstraction. Retain
|
||||
// its file context conservatively, never claim every method carries the value.
|
||||
val fieldFiles = if (fieldCarriedKind.isEmpty) Set.empty[String] else
|
||||
cpg.call.nameExact(FIELD_ACCESS).filter(c => inScope(c.method)).l.map(_.method.filename).toSet
|
||||
val fields = fieldFiles.toList.flatMap(f => methodsIn(f).filterNot(_.isExternal).map(m => location(m, "field")))
|
||||
val callbacks = if (hookApply.isEmpty) Nil else cpg.call.filter(c => hookApply.contains(c.name))
|
||||
.filter(c => inScope(c.method)).l.flatMap { call =>
|
||||
val hook = call.argument.l.headOption.map(a => unquote(a.code)).getOrElse("")
|
||||
hookTable.getOrElse(hook, Nil).flatMap(name => cpg.method.nameExact(name).filterNot(_.isExternal).l)
|
||||
}.distinct
|
||||
val hooks = callbacks.map(m => location(m, "hook"))
|
||||
// Export sanitizer locations as guard context, not proof of discharge. Removed
|
||||
// guards are retained through base spans and unchanged-line correspondence.
|
||||
val guards = scoped.filter(_.ast.l.exists(sanitizesHere)).map(m => location(m, "guard"))
|
||||
// Reachability remains bounded. External/dynamic calls and calls outside the
|
||||
// current reachability set cannot establish a complete negative for a change.
|
||||
// A modeled argument does not summarize its consumer: unknown(req.body),
|
||||
// unknown(eval(...)) and unknown("escape") all retain an external boundary.
|
||||
// Only the directly invoked fact-named operation can use this exemption.
|
||||
def directlyModeled(c: io.shiftleft.codepropertygraph.generated.nodes.Call): Boolean =
|
||||
(sinkNames ++ sanitizerNames ++ sourcePatterns).exists { raw =>
|
||||
val name = raw.stripSuffix("(")
|
||||
c.name == name || c.code.startsWith(name + "(")
|
||||
}
|
||||
val missing = scoped.exists(m => m.ast.isCall.l.exists { c =>
|
||||
!c.name.startsWith("<operator>") && !directlyModeled(c) && {
|
||||
val destinations = c.callee.l
|
||||
destinations.isEmpty || destinations.exists(d => d.isExternal || !inScope(d))
|
||||
}
|
||||
})
|
||||
val boundaries = if (scoped.isEmpty) List(ujson.Obj("kind" -> "context_boundary",
|
||||
"reason" -> "context_scope_empty:contributor-scan")) else if (missing) List(ujson.Obj("kind" -> "context_boundary",
|
||||
"reason" -> "dynamic_external_or_depth_context_unresolved:contributor-scan")) else Nil
|
||||
ujson.Obj("kind" -> "context_summary") :: (methods ++ fields ++ hooks ++ guards ++ boundaries)
|
||||
}
|
||||
return summary :: (perSink ++ contextRows)
|
||||
}
|
||||
|
||||
val rows = sinkCalls.l.flatMap { sink =>
|
||||
@@ -797,7 +842,8 @@
|
||||
evidence,
|
||||
field("file"),
|
||||
field("callDepth"),
|
||||
field("boundedSinks")
|
||||
field("boundedSinks"),
|
||||
field("contextEvidence")
|
||||
): _*
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,9 +18,12 @@ Two restraints are deliberate:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
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
|
||||
@@ -174,3 +177,80 @@ def _families(language: str, *, has_handler: bool) -> tuple[str, ...]:
|
||||
if has_handler:
|
||||
families |= set(dominance_specs(language=language))
|
||||
return tuple(sorted(families))
|
||||
|
||||
|
||||
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")
|
||||
inherited_gap = bool(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")
|
||||
for row in rows:
|
||||
if row.get("kind") == "context_boundary":
|
||||
local_gaps.append(str(row.get("reason", "context_unresolved")))
|
||||
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)
|
||||
return replace(context, relationships=tuple(relationships), unresolved_boundaries=tuple(dict.fromkeys(gaps))), affected_gaps
|
||||
|
||||
@@ -30,13 +30,22 @@ from typing import Any
|
||||
from ..cpg.backend import CpgResult
|
||||
from .candidates import enumerate_candidates
|
||||
from .config_value import request_params as config_params
|
||||
from .contracts import DeferredQuestion, ExecutionBudget, FamilyCoverage, QuestionIdentity, QuestionOutcome, RankedQuestion, ScopeDecision
|
||||
from .contracts import (
|
||||
ChangeContext,
|
||||
DeferredQuestion,
|
||||
ExecutionBudget,
|
||||
FamilyCoverage,
|
||||
QuestionIdentity,
|
||||
QuestionOutcome,
|
||||
RankedQuestion,
|
||||
ScopeDecision,
|
||||
)
|
||||
from .dominance import request_params as dominance_params
|
||||
from .evidence import TIER_EXCLUDE, Evidence, evidence_from_rows
|
||||
from .ladder import Rung
|
||||
from .layout import with_layout
|
||||
from .pipeline import ModelFinding, scan_region
|
||||
from .regions import MODULE_SCOPE, ScanRegion
|
||||
from .regions import MODULE_SCOPE, ScanRegion, affected_context
|
||||
from .specs import ConfigSpec, DominanceSpec, TaintSpec, config_specs, dominance_specs, taint_specs
|
||||
from .taint import request_params as taint_params
|
||||
from .taxonomy import load_families
|
||||
@@ -117,6 +126,7 @@ class ModelScanResult:
|
||||
# could have been found was skipped. Reported so the request count is legible.
|
||||
requests_pruned: int = 0
|
||||
|
||||
change_context: ChangeContext | None = None
|
||||
scope: ScopeDecision | None = None
|
||||
question_outcomes: tuple[QuestionOutcome, ...] = ()
|
||||
family_coverage: tuple[FamilyCoverage, ...] = ()
|
||||
@@ -139,6 +149,7 @@ def scan_repository(
|
||||
ranking_mode: str | None = None,
|
||||
unit: str = "repository",
|
||||
population_complete: bool = False,
|
||||
change_context: ChangeContext | None = None,
|
||||
) -> ModelScanResult:
|
||||
"""Run with an optional transaction deadline; graph ownership ends on every exit."""
|
||||
if ranking_mode not in (None, "static", "evidence"):
|
||||
@@ -164,6 +175,7 @@ def scan_repository(
|
||||
ranking_mode=mode,
|
||||
unit=unit,
|
||||
population_complete=population_complete,
|
||||
change_context=change_context,
|
||||
execution_budget=execution_budget,
|
||||
owned=owned,
|
||||
)
|
||||
@@ -197,6 +209,7 @@ def _scan_repository_impl(
|
||||
ranking_mode: str,
|
||||
unit: str,
|
||||
population_complete: bool,
|
||||
change_context: ChangeContext | None,
|
||||
) -> ModelScanResult:
|
||||
"""Arbitrate a repository's regions under one CPG and one budget."""
|
||||
limits = budget if budget is not None else ScanBudget()
|
||||
@@ -213,8 +226,11 @@ def _scan_repository_impl(
|
||||
cpg = _build(backend, root, dominant, execution_budget=execution_budget)
|
||||
build_seconds = round(time.monotonic() - build_started, 2)
|
||||
if cpg is None:
|
||||
scope, _ = _scope_work(regions, {}, limits, unit, ranking_mode, population_complete, failure="cpg_build_failed")
|
||||
scope, _ = _scope_work(
|
||||
regions, {}, limits, unit, ranking_mode, population_complete, failure="cpg_build_failed", change_context=change_context
|
||||
)
|
||||
return ModelScanResult(
|
||||
change_context=change_context,
|
||||
scope=scope,
|
||||
family_coverage=_family_coverage(scope, ()),
|
||||
by_rung=_empty_tally(),
|
||||
@@ -273,6 +289,7 @@ def _scan_repository_impl(
|
||||
tiers: list[tuple[str, str, str, int]] = []
|
||||
per_kind: dict[str, float] = {}
|
||||
evidence_by_pair: dict[tuple[str, str, str], Evidence] = {}
|
||||
context_rows: dict[QuestionIdentity, list[Mapping[str, object]]] = {}
|
||||
if _within_deadline(execution_budget) and limits.tiering and callable(getattr(cpg, "run_batch", None)):
|
||||
evidence_candidates = (
|
||||
ordered_regions[: (limits.evidence_regions or len(ordered_regions))]
|
||||
@@ -280,7 +297,14 @@ def _scan_repository_impl(
|
||||
else ordered_regions[: limits.max_regions]
|
||||
)
|
||||
tier_started = time.monotonic()
|
||||
evidence_by_pair, failed = _evidence_pass(cpg, _collect(evidence_candidates), hooks, degradations=degradations)
|
||||
evidence_by_pair, failed = _evidence_pass(
|
||||
cpg,
|
||||
_collect(evidence_candidates),
|
||||
hooks,
|
||||
degradations=degradations,
|
||||
context_rows=context_rows if change_context is not None else None,
|
||||
unit=unit,
|
||||
)
|
||||
per_kind["evidence"] = round(time.monotonic() - tier_started, 2)
|
||||
if failed:
|
||||
degradations.append({"stage": "model", "reason": "query_failed", "kind": "evidence", "requests": failed})
|
||||
@@ -288,6 +312,12 @@ def _scan_repository_impl(
|
||||
if limits.order_by_evidence and evidence_by_pair:
|
||||
ordered_regions = order_by_evidence(ordered_regions, evidence_by_pair)
|
||||
|
||||
context_gaps: set[QuestionIdentity] = set()
|
||||
if change_context is not None:
|
||||
change_context, context_gaps = affected_context(
|
||||
change_context, [_identity(unit, r, f) for r in ordered_regions for f in r.families], context_rows, root, execution_budget
|
||||
)
|
||||
|
||||
# Authoritative selection is made once, from the actual final ranker order.
|
||||
# Stable question IDs are the IDs sent to every execution request below.
|
||||
scope_limits = (
|
||||
@@ -295,7 +325,16 @@ def _scan_repository_impl(
|
||||
if any(d.get("reason") in {"files_unparsed", "cpg_empty", "cpg_sharded"} for d in degradations)
|
||||
else limits
|
||||
)
|
||||
scope, work = _scope_work(ordered_regions, evidence_by_pair, scope_limits, unit, ranking_mode, population_complete)
|
||||
scope, work = _scope_work(
|
||||
ordered_regions,
|
||||
evidence_by_pair,
|
||||
scope_limits,
|
||||
unit,
|
||||
ranking_mode,
|
||||
population_complete,
|
||||
change_context=change_context,
|
||||
context_gaps=context_gaps,
|
||||
)
|
||||
outcomes: dict[str, QuestionOutcome] = {}
|
||||
|
||||
# Phase 2: ONE invocation per query kind. JVM startup dominates a repository scan -- a call per region
|
||||
@@ -429,6 +468,8 @@ def _scan_repository_impl(
|
||||
"deadline_exhausted" if not _within_deadline(execution_budget) else "query_unanswered",
|
||||
_rows_json(rows_by_id[rid]) if answered else None,
|
||||
)
|
||||
if question.identity in context_gaps and outcomes[rid].status == "completed":
|
||||
outcomes[rid] = replace(outcomes[rid], status="unresolved", reason="change_context_incomplete")
|
||||
if graph_gaps and outcomes[rid].status == "completed":
|
||||
outcomes[rid] = replace(outcomes[rid], status="unresolved", reason="graph_incomplete")
|
||||
scope = replace(scope, unresolved_boundaries=tuple(dict.fromkeys((*scope.unresolved_boundaries, *graph_gaps))))
|
||||
@@ -457,6 +498,7 @@ def _scan_repository_impl(
|
||||
scope=scope,
|
||||
question_outcomes=ordered_outcomes,
|
||||
family_coverage=_family_coverage(scope, ordered_outcomes),
|
||||
change_context=change_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -477,6 +519,8 @@ def _scope_work(
|
||||
population_complete: bool,
|
||||
*,
|
||||
failure: str | None = None,
|
||||
change_context: ChangeContext | None = None,
|
||||
context_gaps: set[QuestionIdentity] | None = None,
|
||||
) -> tuple[ScopeDecision, list[tuple[str, ScanRegion, ArbiterSpec]]]:
|
||||
"""Consume the ranker's final order once; the returned work IS selected scope."""
|
||||
selected: list[RankedQuestion] = []
|
||||
@@ -485,6 +529,8 @@ def _scope_work(
|
||||
boundaries: list[str] = [] if population_complete else ["population_not_asserted_complete"]
|
||||
if failure:
|
||||
boundaries.append(failure)
|
||||
if change_context is not None:
|
||||
boundaries.extend(change_context.unresolved_boundaries)
|
||||
for index, region in enumerate(regions):
|
||||
for family in region.families:
|
||||
identity = _identity(unit, region, family)
|
||||
@@ -498,6 +544,25 @@ def _scope_work(
|
||||
boundaries.append("evidence_unknown:" + identity.question_id)
|
||||
else:
|
||||
facts += ("taint_evidence_not_applicable",)
|
||||
if change_context is not None:
|
||||
facts += (
|
||||
"change_sites="
|
||||
+ _rows_json(
|
||||
{
|
||||
"base_revision": change_context.base_revision,
|
||||
"head_revision": change_context.head_revision,
|
||||
"changed_paths": change_context.changed_paths,
|
||||
"deleted_paths": change_context.deleted_paths,
|
||||
"declaration_paths": change_context.declaration_paths,
|
||||
"path_encoding": change_context.path_encoding,
|
||||
}
|
||||
),
|
||||
)
|
||||
facts += tuple(
|
||||
"affected_relationship=" + _rows_json(r.to_payload()) for r in change_context.relationships if r.target == identity
|
||||
)
|
||||
if identity in (context_gaps or set()):
|
||||
facts += ("change_context_incomplete",)
|
||||
reason = (
|
||||
"unsupported_family"
|
||||
if spec is None
|
||||
@@ -506,7 +571,11 @@ def _scope_work(
|
||||
"region_budget"
|
||||
if index >= limits.max_regions
|
||||
else "tier_zero"
|
||||
if limits.prune_tier0 and isinstance(spec, TaintSpec) and vector is not None and vector.tier == TIER_EXCLUDE
|
||||
if limits.prune_tier0
|
||||
and identity not in (context_gaps or set())
|
||||
and isinstance(spec, TaintSpec)
|
||||
and vector is not None
|
||||
and vector.tier == TIER_EXCLUDE
|
||||
else None
|
||||
)
|
||||
)
|
||||
@@ -568,7 +637,13 @@ def _collect(regions: Sequence[ScanRegion]) -> list[tuple[str, ScanRegion, Arbit
|
||||
|
||||
|
||||
def _evidence_pass(
|
||||
cpg: Any, work: Sequence[tuple[str, ScanRegion, ArbiterSpec]], hooks: str, *, degradations: list[Mapping[str, object]] | None = None
|
||||
cpg: Any,
|
||||
work: Sequence[tuple[str, ScanRegion, ArbiterSpec]],
|
||||
hooks: str,
|
||||
*,
|
||||
degradations: list[Mapping[str, object]] | None = None,
|
||||
context_rows: dict[QuestionIdentity, list[Mapping[str, object]]] | None = None,
|
||||
unit: str = "repository",
|
||||
) -> tuple[dict[tuple[str, str, str], Evidence], int]:
|
||||
"""The evidence vector of every taint pair in ``work``, keyed by (path, function, family).
|
||||
|
||||
@@ -578,7 +653,10 @@ def _evidence_pass(
|
||||
grouped_taint = _grouped(list(work), hook_callbacks=hooks).get("taint", {})
|
||||
if not grouped_taint:
|
||||
return {}, 0
|
||||
requests = {rid: {**dict(params), "evidenceOnly": "true"} for rid, params in grouped_taint.items()}
|
||||
requests = {
|
||||
rid: {**dict(params), "evidenceOnly": "true", **({"contextEvidence": "true"} if context_rows is not None else {})}
|
||||
for rid, params in grouped_taint.items()
|
||||
}
|
||||
answered = cpg.run_batch("taint", requests)
|
||||
if answered is None:
|
||||
return {}, len(requests)
|
||||
@@ -601,6 +679,8 @@ def _evidence_pass(
|
||||
if pair is None or rid not in requests or not isinstance(rows, list):
|
||||
continue
|
||||
region, spec = pair
|
||||
if context_rows is not None:
|
||||
context_rows[_identity(unit, region, spec.family)] = [r for r in rows if isinstance(r, Mapping)]
|
||||
evidence = evidence_from_rows(
|
||||
rows,
|
||||
entry=_is_entry_point(region),
|
||||
|
||||
Reference in New Issue
Block a user