mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
The mechanism already existed. A question whose evidence vector says no sink of its family is in reach cannot produce a flow, and tier-zero pruning defers it. That was switched off for the whole scan whenever any of twelve degradation reasons appeared, and a multi-language repository always carries two of them, so the measured plugin arbitrated every question it could ask. Third instance this week of one blast radius, and the invariant written into the design on Monday named it. A prune blocker that names a partition now withholds pruning from that partition questions and from nobody else. cross_partition_semantics_unresolved leaves the blocker set entirely, for the reason it left the integrity gaps: a PHP family sink is PHP, so a second language cannot be why its vector saw none. Measured on Paid Memberships Pro. At 25 regions arbitration falls from 127 questions to 34 with 93 recorded deferred as tier_zero, and 19 findings before and after, identical family by family. At 500 it falls from 2,526 to 406 with 2,120 deferred. It is not sufficient, and that is the useful half. The 500-region run still fails in the same way at the same cost, because 406 questions at roughly one and a half seconds each still exceed the 300-second per-batch ceiling. This lever moved the volume; the ceiling is a separate limit. The census now records what a scan skipped and why, per family, so a cheaper scan and a narrower one stop looking identical: a family whose every question was pruned reports no sink of this family in reach rather than region offered, no question asked. 1,420 tests pass, eighteen skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
170 lines
8.8 KiB
Python
170 lines
8.8 KiB
Python
"""Which declared vulnerability families actually establish findings on real code?
|
|
|
|
Seven families are declared. Two are proven end to end: injection on NodeGoat and VAmPI, and access
|
|
control on VAmPI once its change context could complete. The other five have never been checked for
|
|
whether they produce a witness on anything, and a family that is declared but silent is advertised
|
|
coverage the tool does not have.
|
|
|
|
This runs the production discovery and scan over a whole checkout and reports, per family, how many
|
|
regions were asked, how many questions completed, and what was found with the witness and rung that
|
|
established it. It makes no novelty or admission claim: there is no comparison here, only detection.
|
|
|
|
A family reporting nothing is recorded as nothing. That is the point of asking.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import collections
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from openultrasast.cpg.backend import JoernBackend
|
|
from openultrasast.mapping import analyze_entry_points, php_hook_callbacks
|
|
from openultrasast.model.contracts import ExecutionBudget
|
|
from openultrasast.model.regions import regions_for
|
|
from openultrasast.model.scan import ScanBudget, scan_repository
|
|
from openultrasast.model.shipped import declared_sources
|
|
from openultrasast.preprocess import build_file_target, enumerate_source_files
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--out", type=Path, required=True)
|
|
parser.add_argument("--root", type=Path, required=True)
|
|
parser.add_argument("--name", required=True)
|
|
parser.add_argument("--deadline", type=float, default=1800.0)
|
|
parser.add_argument("--max-regions", type=int, default=500)
|
|
args = parser.parse_args()
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
record: dict[str, Any] = {
|
|
"schema_version": 1,
|
|
"experiment": "family-detection-census-v1",
|
|
"subject": args.name,
|
|
"root": str(args.root),
|
|
"claim_scope": "Detection only. No comparison, no novelty, no admission, no capability enabled.",
|
|
}
|
|
target = args.out / f"{args.name}.json"
|
|
|
|
def save() -> None:
|
|
target.write_text(json.dumps(record, indent=2, default=str) + "\n")
|
|
|
|
files = enumerate_source_files(args.root)
|
|
targets = [build_file_target(args.root, path) for path in files]
|
|
opened = sum(path.stat().st_size for path in files if path.is_file())
|
|
regions = regions_for(analyze_entry_points(args.root, targets), targets, shipped=declared_sources(args.root))
|
|
record["inputs"] = {"files": len(files), "bytes": opened, "regions": len(regions)}
|
|
record["regions_by_family"] = dict(collections.Counter(f for r in regions for f in r.families))
|
|
record["languages"] = dict(collections.Counter(r.language for r in regions))
|
|
save()
|
|
if not files or not opened:
|
|
record["error"] = "no readable source"
|
|
save()
|
|
return 2
|
|
|
|
backend = JoernBackend(execution_budget=ExecutionBudget(time.monotonic() + args.deadline, 5.0))
|
|
hooks = php_hook_callbacks(args.root, targets) if any(r.language == "php" for r in regions) else {}
|
|
started = time.monotonic()
|
|
result = scan_repository(
|
|
args.root,
|
|
regions[: args.max_regions],
|
|
backend=backend,
|
|
budget=ScanBudget(max_model_calls=0, max_regions=args.max_regions, order_by_evidence=True),
|
|
execution_budget=ExecutionBudget(time.monotonic() + args.deadline, 5.0),
|
|
ranking_mode="evidence",
|
|
unit="repository",
|
|
population_complete=True,
|
|
)
|
|
record["scan"] = {
|
|
"seconds": round(time.monotonic() - started, 1),
|
|
"build_seconds": result.build_seconds,
|
|
"query_seconds": result.query_seconds,
|
|
"hook_table_hooks": len(hooks),
|
|
"degradations": [str(d.get("reason")) for d in result.degradations],
|
|
# The reason alone cannot be acted on. A partition census that reports files missing carries WHICH
|
|
# files, and without them the gap is a label rather than a lead.
|
|
"degradation_detail": [
|
|
{k: (v[:20] if isinstance(v, list) else v) for k, v in d.items() if k != "stage"} for d in result.degradations
|
|
],
|
|
"questions_total": len(result.question_outcomes),
|
|
"questions_completed": sum(1 for q in result.question_outcomes if q.status == "completed"),
|
|
"findings_total": len(result.findings),
|
|
# Why the outcomes say what they say, counted rather than inferred: a family can find something while
|
|
# every one of its questions is recorded unresolved, and the two numbers then disagree in public.
|
|
"outcome_status": dict(collections.Counter(q.status for q in result.question_outcomes)),
|
|
# WHY an outcome is not completed. Counting only the statuses left three separate causes looking
|
|
# identical, and each one had to be found by reading code rather than by reading the record.
|
|
"outcome_reason": dict(collections.Counter(f"{q.status}:{q.reason}" for q in result.question_outcomes)),
|
|
# A question the scan chose not to arbitrate is recorded as skipped, with why. Without this a cheaper
|
|
# scan and a narrower one look identical, and the whole point of skipping is that it is not the same.
|
|
"deferred": dict(collections.Counter(item.reason for item in (result.scope.deferred if result.scope else ()))),
|
|
}
|
|
|
|
# A family whose every question was pruned asked nothing, and "nothing to ask" is a different statement
|
|
# from "never offered". Counting the skips per family is what keeps those apart in the verdict below.
|
|
deferred_by_family: dict[str, collections.Counter[str]] = collections.defaultdict(collections.Counter)
|
|
for item in result.scope.deferred if result.scope else ():
|
|
deferred_by_family[item.identity.family][item.reason] += 1
|
|
asked = collections.Counter(q.identity.family for q in result.question_outcomes)
|
|
completed = collections.Counter(q.identity.family for q in result.question_outcomes if q.status == "completed")
|
|
found: dict[str, list[dict[str, Any]]] = collections.defaultdict(list)
|
|
for finding in result.findings:
|
|
found[finding.family].append(
|
|
{
|
|
"site": finding.site,
|
|
"rung": finding.rung.value,
|
|
"witness": (finding.witness or "")[:200],
|
|
"reached_from": finding.reached_from,
|
|
}
|
|
)
|
|
# Families with regions but no question must appear, or the census hides its own biggest finding: on
|
|
# VAmPI four declared families were offered 25 regions each and asked nothing, and building this list
|
|
# from the questions alone made them invisible rather than negative.
|
|
offered = record["regions_by_family"]
|
|
families = sorted(set(offered) | set(asked) | set(found))
|
|
record["families"] = {
|
|
family: {
|
|
"regions_offering": offered.get(family, 0),
|
|
"questions_asked": asked.get(family, 0),
|
|
"questions_completed": completed.get(family, 0),
|
|
"findings": len(found.get(family, [])),
|
|
"rungs": dict(collections.Counter(item["rung"] for item in found.get(family, []))),
|
|
"examples": found.get(family, [])[:10],
|
|
# The distinction that matters: a family that answered and found nothing is a different
|
|
# statement from one that could not answer, and neither is "clean".
|
|
#
|
|
# Findings are checked FIRST, and that order is the whole point. It used to ask about completion
|
|
# before findings, so a family that established 18 findings on a PHP plugin while no question
|
|
# outcome was marked completed reported "asked but no question completed" -- and a census whose
|
|
# job is to say what each family finds reported nothing for a repository that had found a real
|
|
# SQL injection. A question-outcome count is a statement about bookkeeping; a finding is the
|
|
# thing being censused, and it cannot be hidden behind the other.
|
|
"deferred": dict(deferred_by_family.get(family, {})),
|
|
"verdict": (
|
|
"establishes findings"
|
|
if found.get(family)
|
|
else "no sink of this family in reach"
|
|
if not asked.get(family) and deferred_by_family.get(family, {}).get("tier_zero")
|
|
else "region offered, no question asked"
|
|
if not asked.get(family)
|
|
else "asked but no question completed"
|
|
if not completed.get(family)
|
|
else "answered, nothing found"
|
|
),
|
|
}
|
|
for family in families
|
|
}
|
|
record["summary"] = dict(collections.Counter(entry["verdict"] for entry in record["families"].values()))
|
|
save()
|
|
cleanup = getattr(result, "cleanup", None)
|
|
if callable(cleanup):
|
|
cleanup()
|
|
print(json.dumps({"subject": args.name, "summary": record["summary"]}), flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|