mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
NodeGoat models 18 of 175 distinct call names, covering 12 percent of 1,185 calls. That share is not a score: most of what a program calls is not security-relevant, and a high number would mean the tables were too loose. It is a baseline to move deliberately. The top product candidate is res.render, 23 calls with five in a method that reads untrusted input. Task 14.3 reasoned its way to that same gap by hand and declined to model it as a flow sink; the instrument found it in one run, which is the validation that matters. It also surfaced Math.random in the user data-access module, and weak randomness is a weakness class this ontology has no family for at all -- so no family census could have found it, because the family list is itself the blind spot. That is the kind of gap only a coverage instrument finds. It had to be taught what is not an API. The first run ranked _tmp_3.push and __ecma.Array.factory first, which is how the frontend lowers a destructuring and an array literal, and a Cypress suite and a Gruntfile held four of the next five. Both are separated now, because noise at the top of a worklist is how a worklist stops being read. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
148 lines
6.8 KiB
Python
148 lines
6.8 KiB
Python
"""What a repository calls that the ontology does not model (contributor-scan 7.1).
|
|
|
|
Five of seven families established nothing in the 2026-09 family census, and every one of them failed for an
|
|
ontology reason rather than an analysis one: the flow engine was ready and had nothing to aim at. Each gap
|
|
was found by investigating one family at a time, over days. This asks the question once.
|
|
|
|
What it produces is a CANDIDATE LIST. A name here becomes a fact only when someone resolves it to a library
|
|
API, states the weakness class and records provenance -- the ontology describes libraries, frameworks and
|
|
language constructs, never repositories, and adding a name because a subject called it is how a
|
|
general-purpose tool becomes a fixture of its own benchmark.
|
|
"""
|
|
|
|
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.model.contracts import ExecutionBudget
|
|
from openultrasast.model.layout import is_test_path
|
|
from openultrasast.model.specs import config_specs, dominance_specs, taint_specs
|
|
from openultrasast.preprocess import enumerate_source_files
|
|
|
|
# Build tooling a repository runs but does not ship. Named here rather than guessed from a path, because a
|
|
# file called Gruntfile is a build script wherever it sits.
|
|
_BUILD_SCRIPTS = frozenset({"Gruntfile.js", "gulpfile.js", "webpack.config.js", "rollup.config.js", "karma.conf.js"})
|
|
|
|
|
|
def modelled_tokens(language: str) -> dict[str, tuple[str, ...]]:
|
|
"""Every token any fact table names for this language, by the role it plays.
|
|
|
|
Read from the specs rather than from the TOML, so the instrument measures what the analyser actually
|
|
consults: a fact the loader drops is not part of the ontology however neatly it is written.
|
|
"""
|
|
roles: dict[str, set[str]] = collections.defaultdict(set)
|
|
for family, spec in taint_specs(language=language).items():
|
|
roles[f"sink:{family}"].update(spec.sinks)
|
|
roles["source"].update(spec.sources)
|
|
roles["sanitizer"].update(spec.sanitizers)
|
|
roles["sanitizer"].update(spec.safe_shape_sinks)
|
|
for spec in dominance_specs(language=language).values():
|
|
roles["operation"].update(spec.operations)
|
|
roles["discharger"].update(spec.dischargers)
|
|
for spec in config_specs(language=language).values():
|
|
roles["setting"].update(spec.settings)
|
|
return {role: tuple(sorted(tokens)) for role, tokens in roles.items()}
|
|
|
|
|
|
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("--language", required=True)
|
|
parser.add_argument("--deadline", type=float, default=1800.0)
|
|
parser.add_argument("--top", type=int, default=60, help="how many unmodelled candidates to record")
|
|
args = parser.parse_args()
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
|
|
roles = modelled_tokens(args.language)
|
|
every = sorted({token for tokens in roles.values() for token in tokens})
|
|
record: dict[str, Any] = {
|
|
"schema_version": 1,
|
|
"experiment": "ontology-coverage-v1",
|
|
"subject": args.name,
|
|
"language": args.language,
|
|
"root": str(args.root),
|
|
"claim_scope": (
|
|
"A candidate list, not an ontology. A name here is a lead to a library API; it becomes a fact "
|
|
"only with a weakness class and provenance."
|
|
),
|
|
"ontology_size": {role: len(tokens) for role, tokens in sorted(roles.items())},
|
|
}
|
|
target = args.out / f"{args.name}-{args.language}.json"
|
|
|
|
def save() -> None:
|
|
target.write_text(json.dumps(record, indent=2, default=str) + "\n")
|
|
|
|
files = enumerate_source_files(args.root)
|
|
record["inputs"] = {"files": len(files), "bytes": sum(p.stat().st_size for p in files if p.is_file())}
|
|
save()
|
|
if not files:
|
|
record["error"] = "no readable source"
|
|
save()
|
|
return 2
|
|
|
|
started = time.monotonic()
|
|
budget = ExecutionBudget(started + args.deadline, 5.0)
|
|
backend = JoernBackend(execution_budget=budget)
|
|
graph = backend.build(args.root, language=args.language, execution_budget=budget)
|
|
if graph is None:
|
|
record["error"] = f"graph build failed: {backend.last_failure}"
|
|
save()
|
|
return 3
|
|
|
|
rows = backend.query(
|
|
graph.cpg_path,
|
|
"vocabulary",
|
|
{"modelled": ",".join(every), "sources": ",".join(roles.get("source", ()))},
|
|
)
|
|
record["seconds"] = round(time.monotonic() - started, 1)
|
|
if not isinstance(rows, list):
|
|
record["error"] = "the vocabulary query did not answer"
|
|
save()
|
|
return 4
|
|
|
|
known = [r for r in rows if isinstance(r, dict) and r.get("modelled")]
|
|
unknown = [r for r in rows if isinstance(r, dict) and not r.get("modelled")]
|
|
calls_total = sum(int(r.get("count", 0)) for r in rows if isinstance(r, dict))
|
|
record["coverage"] = {
|
|
"distinct_call_names": len(rows),
|
|
"calls_total": calls_total,
|
|
"modelled_names": len(known),
|
|
"modelled_calls": sum(int(r.get("count", 0)) for r in known),
|
|
# The share of CALLS, not of names: one name called four hundred times matters more than four
|
|
# hundred called once, and a name count would flatter the ontology.
|
|
"modelled_call_share": round(sum(int(r.get("count", 0)) for r in known) / calls_total, 3) if calls_total else 0.0,
|
|
}
|
|
record["modelled_seen"] = [{k: r[k] for k in ("name", "count") if k in r} for r in known[: args.top]]
|
|
wanted = ("name", "spelling", "count", "nearSource", "file", "line")
|
|
|
|
# A test helper and a build script are not product API. The layout facts already know which paths those
|
|
# are, and on the first measured subject a Gruntfile and a Cypress suite held four of the five most
|
|
# frequent candidates -- ranked first, they would have buried the two real leads beneath them.
|
|
def product(row: dict[str, Any]) -> bool:
|
|
where = str(row.get("file") or "")
|
|
return bool(where) and not is_test_path(where) and Path(where).name not in _BUILD_SCRIPTS
|
|
|
|
ranked = [{k: r[k] for k in wanted if k in r} for r in unknown]
|
|
record["candidates"] = [r for r in ranked if product(r)][: args.top]
|
|
record["candidates_in_test_or_build"] = [r for r in ranked if not product(r)][:20]
|
|
record["candidates_near_a_source"] = [r for r in record["candidates"] if int(r.get("nearSource", 0)) > 0][:20]
|
|
save()
|
|
cleanup = getattr(graph, "cleanup", None)
|
|
if callable(cleanup):
|
|
cleanup()
|
|
backend.close_session()
|
|
print(json.dumps({"subject": args.name, "language": args.language, "coverage": record["coverage"]}), flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|