mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
- test_engine_image_and_rustfs_policy: root Dockerfile was refactored to the
digest-pinned ousast-task-base (carries semantic+s3 deps) + a joern-build stage,
installing the package --no-deps. Updated the stale `COPY benchmarks` (now
--chown=1000) and `.[semantic,s3]` (now --no-deps + joern-cli copy + pinned base)
assertions to the new contract.
- test_image_stages: plane/Dockerfile.engine-task refactored the same way; the
split marker is now `FROM ${ENGINE_REF} AS replay`, base is ousast-task-base
(not ax-task-runner:v0.3.1), and the replay stage no longer installs git (base
provides it). Re-pinned the engine-stage SHA and updated the markers/assertions.
- test_partial_clone_lazy_fetch: passed alone but failed mid-suite — a leaked
GIT_DIR/GIT_OBJECT_DIRECTORY/alternates from an earlier test changed the
missing-blob count. Added an autouse fixture that neutralises inherited git
control vars so every test in the file is self-contained (reproduced + verified).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
498 lines
19 KiB
Python
498 lines
19 KiB
Python
"""Offline extraction: an injected local Git client over a synthetic temporary history."""
|
|
|
|
import importlib
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
e = importlib.import_module("benchmarks.unseen.extract")
|
|
s = importlib.import_module("benchmarks.unseen.source")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _hermetic_git_env(monkeypatch):
|
|
"""Neutralise inherited Git control vars so each test is self-contained.
|
|
|
|
A GIT_DIR / GIT_OBJECT_DIRECTORY / alternates value leaked from an earlier test in the suite redirects
|
|
object lookup: the partial-clone lazy-fetch test then sees a different missing-blob count (it passes alone
|
|
but failed mid-suite). These tests build their own repos under tmp_path and must not read ambient git env.
|
|
"""
|
|
for var in (
|
|
"GIT_DIR",
|
|
"GIT_WORK_TREE",
|
|
"GIT_INDEX_FILE",
|
|
"GIT_OBJECT_DIRECTORY",
|
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
"GIT_COMMON_DIR",
|
|
):
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
|
|
@pytest.mark.parametrize("slow", [False, True])
|
|
def test_process_deadline_restores_alarm(slow):
|
|
original = signal.getsignal(signal.SIGALRM)
|
|
assert signal.getitimer(signal.ITIMER_REAL) == (0, 0)
|
|
if slow:
|
|
with pytest.raises(e.ExtractionTimeout, match="^wallclock_timeout$"), e.process_deadline(0.05):
|
|
time.sleep(0.2)
|
|
else:
|
|
with e.process_deadline(0.2):
|
|
assert signal.getitimer(signal.ITIMER_REAL)[0] > 0
|
|
assert signal.getsignal(signal.SIGALRM) == original
|
|
assert signal.getitimer(signal.ITIMER_REAL) == (0, 0)
|
|
|
|
|
|
@pytest.mark.parametrize("seconds", [None, 0, -1])
|
|
def test_process_deadline_disabled(seconds, monkeypatch):
|
|
monkeypatch.setattr(signal, "signal", lambda *args: pytest.fail("disabled deadline installed a handler"))
|
|
monkeypatch.setattr(signal, "setitimer", lambda *args: pytest.fail("disabled deadline armed a timer"))
|
|
with e.process_deadline(seconds):
|
|
pass
|
|
|
|
|
|
def test_process_deadline_off_main_thread(monkeypatch):
|
|
monkeypatch.setattr(signal, "signal", lambda *args: pytest.fail("thread installed a handler"))
|
|
monkeypatch.setattr(signal, "setitimer", lambda *args: pytest.fail("thread armed a timer"))
|
|
|
|
def work():
|
|
with e.process_deadline(0.01):
|
|
time.sleep(0.02)
|
|
return "finished"
|
|
|
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
assert pool.submit(work).result() == "finished"
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["signal", "setitimer"])
|
|
def test_process_deadline_unsupported_operation(operation, monkeypatch):
|
|
original = signal.getsignal(signal.SIGALRM)
|
|
|
|
def unsupported(*args):
|
|
raise ValueError("unsupported")
|
|
|
|
monkeypatch.setattr(signal, operation, unsupported)
|
|
with e.process_deadline(0.01):
|
|
time.sleep(0.02)
|
|
assert signal.getsignal(signal.SIGALRM) == original
|
|
assert signal.getitimer(signal.ITIMER_REAL) == (0, 0)
|
|
|
|
|
|
def test_process_deadline_does_not_swallow_body_value_error():
|
|
original = signal.getsignal(signal.SIGALRM)
|
|
with pytest.raises(ValueError, match="body failure"), e.process_deadline(0.2):
|
|
raise ValueError("body failure")
|
|
assert signal.getsignal(signal.SIGALRM) == original
|
|
assert signal.getitimer(signal.ITIMER_REAL) == (0, 0)
|
|
|
|
|
|
def test_process_deadline_inside_git_remains_rejection(tmp_path):
|
|
def runner(*args, **kwargs):
|
|
time.sleep(0.2)
|
|
|
|
with pytest.raises(e.ExtractionTimeout, match="^wallclock_timeout$"), e.process_deadline(0.05):
|
|
e.Git(tmp_path, runner=runner).run("log")
|
|
|
|
|
|
@pytest.fixture
|
|
def history(tmp_path):
|
|
calls = []
|
|
|
|
def runner(argv, **kwargs):
|
|
assert not {"clone", "fetch", "push", "pull"} & set(argv)
|
|
calls.append(argv)
|
|
return subprocess.run(argv, **kwargs)
|
|
|
|
git = e.Git(tmp_path, runner=runner)
|
|
git.run("init", "-q")
|
|
git.run("config", "user.name", "Fixture")
|
|
git.run("config", "user.email", "[email protected]")
|
|
|
|
def commit(text, message="ordinary", extra=None):
|
|
(tmp_path / "app.py").write_text(text)
|
|
for path, content in (extra or {}).items():
|
|
(tmp_path / path).write_text(content)
|
|
git.run("add", ".")
|
|
git.run("commit", "-q", "-m", message)
|
|
return git.run("rev-parse", "HEAD").strip()
|
|
|
|
root = commit("def handler(value):\n return value\n")
|
|
git.run("tag", "v1.0")
|
|
intro = commit("def handler(value):\n return eval(value)\n")
|
|
git.run("tag", "v1.1")
|
|
parent = commit("def handler(value):\n return eval( value )\n", "format")
|
|
fix = commit("def handler(value):\n return int(value)\n", "security fix")
|
|
candidate = s.Candidate(
|
|
"fixture/project",
|
|
"https://github.com/fixture/project",
|
|
fix,
|
|
parent,
|
|
"GHSA-test-test-test",
|
|
"pip",
|
|
"injection",
|
|
True,
|
|
"1.1",
|
|
"MIT",
|
|
"permissive",
|
|
parent,
|
|
"LICENSE",
|
|
)
|
|
return git, commit, candidate, root, intro, parent, calls
|
|
|
|
|
|
def test_introducing_with_reformat_walkback(history):
|
|
git, _, candidate, root, intro, _, calls = history
|
|
change = e.introducing(git, candidate)
|
|
assert change["method"] == "introducing"
|
|
assert (change["base"], change["head"]) == (root, intro)
|
|
assert change["function"] == "handler"
|
|
assert change["family"] == "injection" and change["post_cutoff"]
|
|
assert change["files"][0]["head_lines"] == [[2, 2]]
|
|
assert change["bias"] and change["advisory"] == candidate.advisory
|
|
assert any("-w" in args and "-M" in args and "-C" in args for args in calls)
|
|
assert git.bytes_read > 0
|
|
|
|
|
|
def test_bracket_rejection_uses_fallback(history):
|
|
git, _, candidate, _, _, parent, _ = history
|
|
change = e.introducing(git, replace(candidate, first_affected="1.0"))
|
|
assert change["method"] == "last_touch" and change["head"] == parent
|
|
assert change["fallback_reasons"]["version_bracket"] >= 1
|
|
|
|
|
|
def test_size_cap_and_merge_rejection(history):
|
|
git, _, candidate, _, _, _, _ = history
|
|
|
|
class Oversize:
|
|
def __getattr__(self, name):
|
|
return getattr(git, name)
|
|
|
|
def stats(self, base, head):
|
|
return [("app.py", 2001, 0)]
|
|
|
|
assert e.introducing(Oversize(), candidate)["method"] == "last_touch"
|
|
|
|
class Merge(Oversize):
|
|
def parents(self, sha):
|
|
parents = git.parents(sha)
|
|
return parents + ["f" * 40] if sha != candidate.fix else parents
|
|
|
|
with pytest.raises(s.Rejected, match="no_range"):
|
|
e.introducing(Merge(), candidate)
|
|
|
|
|
|
def test_pure_addition_blames_function_declaration(history):
|
|
git, commit, candidate, _, _, _, _ = history
|
|
git.run("checkout", "-q", candidate.parent)
|
|
parent = commit("def handler(value):\n return eval(value)\n\ndef added(value):\n return value\n")
|
|
fix = commit("def handler(value):\n return eval(value)\n\ndef added(value):\n assert value\n return value\n")
|
|
change = e.introducing(git, replace(candidate, fix=fix, parent=parent, first_affected=None, family="access_control"))
|
|
assert change["head"] == parent and change["method"] == "introducing"
|
|
assert change["function"] == "added"
|
|
|
|
|
|
def test_git_failures_are_sanitized_and_not_empty(tmp_path):
|
|
def failed(argv, **kwargs):
|
|
return subprocess.CompletedProcess(argv, 128, b"", b"private repository secret")
|
|
|
|
with pytest.raises(e.InstrumentFailure, match="git_exit_128"):
|
|
e.Git(tmp_path, runner=failed).run("log")
|
|
|
|
|
|
@pytest.mark.parametrize("deadline,timeout", [(None, 180), (240.0, 180), (12.5, 12.5)])
|
|
def test_git_timeout_is_rejection(tmp_path, deadline, timeout):
|
|
def runner(cmd, **kwargs):
|
|
assert kwargs["timeout"] == timeout
|
|
raise subprocess.TimeoutExpired(cmd, kwargs["timeout"])
|
|
|
|
git = e.Git(tmp_path, runner=runner, deadline_seconds=deadline, _now=lambda: 0.0)
|
|
with pytest.raises(e.ExtractionTimeout, match="^extraction_timeout$") as caught:
|
|
git.run("log")
|
|
assert isinstance(caught.value, s.Rejected)
|
|
assert not isinstance(caught.value, e.InstrumentFailure)
|
|
|
|
|
|
def test_git_cumulative_deadline_prevents_launch(tmp_path):
|
|
now, timeouts = [100.0], []
|
|
|
|
def runner(cmd, **kwargs):
|
|
timeouts.append(kwargs["timeout"])
|
|
now[0] += 4.0
|
|
return subprocess.CompletedProcess(cmd, 0, b"read", b"")
|
|
|
|
git = e.Git(tmp_path, runner=runner, deadline_seconds=10.0, _now=lambda: now[0])
|
|
assert git.run("log") == "read"
|
|
assert git.run("log") == "read"
|
|
now[0] += 3.0 # Time between commands counts as well.
|
|
with pytest.raises(e.ExtractionTimeout, match="^extraction_timeout$"):
|
|
git.run("log")
|
|
assert timeouts == [10.0, 6.0]
|
|
|
|
|
|
def test_git_launch_failure_stays_fatal(tmp_path):
|
|
def runner(cmd, **kwargs):
|
|
raise FileNotFoundError("private executable path")
|
|
|
|
with pytest.raises(e.InstrumentFailure, match="^git_launch_failed$"):
|
|
e.Git(tmp_path, runner=runner, deadline_seconds=240.0).run("log")
|
|
|
|
|
|
def test_walkback_is_bounded_and_file_cap_falls_back(history):
|
|
git, _, candidate, _, _, parent, _ = history
|
|
|
|
class FileCap:
|
|
def __getattr__(self, name):
|
|
return getattr(git, name)
|
|
|
|
def stats(self, base, head):
|
|
return [("app.py", 1, 0)] * 51
|
|
|
|
result = e.introducing(FileCap(), candidate)
|
|
assert result["method"] == "last_touch"
|
|
assert result["fallback_reasons"]["size_cap"]
|
|
|
|
class Walk:
|
|
calls = 0
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(git, name)
|
|
|
|
def blame(self, revision, path, line):
|
|
self.calls += 1
|
|
return parent, path, 2
|
|
|
|
def parents(self, revision):
|
|
return [parent] if revision != candidate.fix else [candidate.parent]
|
|
|
|
walk = Walk()
|
|
result = e.introducing(walk, candidate)
|
|
assert walk.calls == 6
|
|
assert result["fallback_reasons"]["walkback_limit"] == 1
|
|
|
|
|
|
def test_root_rejected_and_head_function_resolved(history):
|
|
git, _, candidate, root, _, _, _ = history
|
|
|
|
class Root:
|
|
def __getattr__(self, name):
|
|
return getattr(git, name)
|
|
|
|
def blame(self, revision, path, line):
|
|
return root, path, 1
|
|
|
|
result = e.introducing(Root(), candidate)
|
|
assert result["method"] == "last_touch"
|
|
assert result["fallback_reasons"]["merge_or_root"] == 1
|
|
assert result["function_lines"] == [1, 2]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path,text",
|
|
[
|
|
("a.php", "<?php\nfunction handle($x) {\n return $x;\n}\n"),
|
|
("a.js", "function handle(x) {\n return x;\n}\n"),
|
|
("a.ts", "const handle = (x) => {\n return x;\n}\n"),
|
|
("A.java", "class A {\n public String handle(String x) {\n return x;\n }\n}\n"),
|
|
],
|
|
)
|
|
def test_supported_function_boundaries(path, text):
|
|
ranges = e.functions(path, text)
|
|
assert len(ranges) == 1 and ranges[0][0] == "handle"
|
|
assert ranges[0][2] < len(text.splitlines()) + 1
|
|
|
|
|
|
def test_ambiguous_parent_is_not_proof_of_absence(history):
|
|
git, commit, candidate, _, _, _, _ = history
|
|
duplicate = (
|
|
"class A:\n def handler(self, value):\n return eval(value)\n\n"
|
|
"class B:\n def handler(self, value):\n return value\n"
|
|
)
|
|
commit(duplicate)
|
|
parent = commit("class A:\n def handler(self, value):\n return eval( value )\n")
|
|
fix = commit("class A:\n def handler(self, value):\n return int(value)\n")
|
|
selected = replace(candidate, fix=fix, parent=parent, first_affected=None)
|
|
|
|
class BlameLast:
|
|
def __getattr__(self, name):
|
|
return getattr(git, name)
|
|
|
|
def blame(self, revision, path, line):
|
|
return parent, path, 3
|
|
|
|
result = e.introducing(BlameLast(), selected)
|
|
assert result["method"] == "last_touch"
|
|
assert result["fallback_reasons"]["ambiguous_parent"] == 1
|
|
|
|
|
|
def ordinary(index, **overrides):
|
|
row = {
|
|
"head": f"{index:040x}",
|
|
"parents": ["f" * 40],
|
|
"timestamp": index * 100,
|
|
"message": "update query token session valid",
|
|
"author": "Human",
|
|
"files": [{"path": "app.py", "added": 5, "removed": 0, "head_lines": [[2, 6]], "functions": ["ordinary"], "generated": False}],
|
|
}
|
|
row.update(overrides)
|
|
return row
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"message",
|
|
[
|
|
"security fix",
|
|
"CVE-2026",
|
|
"GHSA-test",
|
|
"vulnerability",
|
|
"injection fix",
|
|
"XSS",
|
|
"CSRF",
|
|
"SSRF",
|
|
"exploit",
|
|
"sanitize",
|
|
"escaping",
|
|
"Revert changes",
|
|
"This reverts commit abc",
|
|
"bump package",
|
|
"upgrade package from 1 to 2",
|
|
],
|
|
)
|
|
def test_ordinary_message_exclusions(message):
|
|
assert e.ordinary_rejection(ordinary(1, message=message), set(), set()) is not None
|
|
|
|
|
|
def test_narrow_filter_and_every_other_exclusion():
|
|
assert e.ordinary_rejection(ordinary(1), set(), set()) is None
|
|
assert e.ordinary_rejection(ordinary(1, parents=["a", "b"]), set(), set()) == "merge_or_root"
|
|
assert e.ordinary_rejection(ordinary(1, author="dependabot[bot]"), set(), set()) == "dependency"
|
|
assert e.ordinary_rejection(ordinary(1, author="renovate"), set(), set()) == "dependency"
|
|
assert e.ordinary_rejection(ordinary(1), {f"{1:040x}"}, set()) == "advisory_fix"
|
|
assert e.ordinary_rejection(ordinary(1), set(), {("app.py", "ordinary")}) == "vulnerable_function"
|
|
row = ordinary(1)
|
|
row["files"][0]["added"] = 1001
|
|
assert e.ordinary_rejection(row, set(), set()) == "size_cap"
|
|
for path, reason in [
|
|
("README.md", "no_source"),
|
|
("package.json", "manifest_only"),
|
|
("package-lock.json", "manifest_only"),
|
|
("vendor/a.py", "generated"),
|
|
("node_modules/a.js", "generated"),
|
|
("dist/a.js", "generated"),
|
|
("app.min.js", "generated"),
|
|
]:
|
|
row = ordinary(1)
|
|
row["files"][0]["path"] = path
|
|
assert e.ordinary_rejection(row, set(), set()) == reason
|
|
row = ordinary(1)
|
|
row["files"][0]["generated"] = True
|
|
assert e.ordinary_rejection(row, set(), set()) == "generated"
|
|
|
|
|
|
def test_draw_seed_proportional_cells_floor_and_thirds():
|
|
rows = [ordinary(i) for i in range(120)]
|
|
for i, row in enumerate(rows):
|
|
row["files"][0]["added"] = [5, 20, 100, 500][i % 4]
|
|
drawn, counts = e.draw_ordinary(rows, set(), set(), seed=19)
|
|
assert len(drawn) == 12 and len({r["head"] for r in drawn}) == 12
|
|
assert {r["time_third"] for r in drawn} == {0, 1, 2}
|
|
assert len({(r["time_third"], r["size_bucket"]) for r in drawn}) == 12
|
|
assert drawn == e.draw_ordinary(list(reversed(rows)), set(), set(), seed=19)[0]
|
|
assert counts["eligible"] == 120
|
|
with pytest.raises(s.Rejected, match="ordinary_floor"):
|
|
e.draw_ordinary(rows[:39], set(), set(), seed=19)
|
|
# Thirds come from the history's time span, not rank among eligible changes.
|
|
with pytest.raises(s.Rejected, match="time_thirds"):
|
|
e.draw_ordinary([ordinary(i) for i in range(40)] + [ordinary(10000, message="security")], set(), set(), seed=1)
|
|
|
|
|
|
def test_git_ordinary_collection_generated_attributes_and_headers(history):
|
|
git, commit, _, _, _, _, _ = history
|
|
commit("# Generated by fixture\nx = 2\n", extra={".gitattributes": "other.py linguist-generated=true\n", "other.py": "x = 1\n"})
|
|
rows = e.ordinary_history(git, "HEAD")
|
|
assert rows and rows[0]["files"]
|
|
files = {f["path"]: f for f in rows[0]["files"]}
|
|
assert files["app.py"]["generated"] and files["other.py"]["generated"]
|
|
assert files["app.py"]["head_lines"]
|
|
|
|
|
|
def test_attribute_unset_and_mixed_generated_source(history):
|
|
git, commit, _, _, _, _, _ = history
|
|
sha = commit("x = 1\n", extra={".gitattributes": "*.py linguist-generated\napp.py -linguist-generated\n"})
|
|
assert not e.generated(git, sha, "app.py", "x = 1\n")
|
|
row = ordinary(1)
|
|
row["files"].append({**row["files"][0], "path": "dist/b.js", "generated": True})
|
|
assert e.ordinary_rejection(row, set(), set()) is None
|
|
|
|
|
|
def test_unbalanced_cells_remain_proportional_and_seed_changes_draw():
|
|
rows = [ordinary(i) for i in range(120)]
|
|
for i, row in enumerate(rows):
|
|
row["files"][0]["added"] = 5 if i % 4 else 100
|
|
draw, _ = e.draw_ordinary(rows, set(), set(), seed=1)
|
|
assert len([r for r in draw if r["size_bucket"] == 0]) == 9
|
|
assert draw != e.draw_ordinary(rows, set(), set(), seed=2)[0]
|
|
|
|
|
|
def test_disk_bytes_skips_files_that_vanish_mid_walk(tmp_path):
|
|
# git churns .git/objects/pack (transient *.rev) during on-demand blob fetches,
|
|
# so a path rglob just listed can be gone by the time disk_bytes stat()s it.
|
|
git = e.Git(tmp_path, runner=subprocess.run)
|
|
(tmp_path / "a").write_bytes(b"x" * 10)
|
|
(tmp_path / "b").write_bytes(b"y" * 20)
|
|
real = [p for p in tmp_path.rglob("*")]
|
|
|
|
class Vanished:
|
|
def is_file(self):
|
|
return True
|
|
|
|
def is_symlink(self):
|
|
return False
|
|
|
|
def stat(self):
|
|
raise FileNotFoundError(2, "No such file or directory")
|
|
|
|
git.path = type("P", (), {"rglob": lambda self, pattern: iter([*real, Vanished()])})()
|
|
assert git.disk_bytes() == 30
|
|
|
|
|
|
def test_partial_clone_lazy_fetch_preserves_extraction_content(history, tmp_path):
|
|
"""Real promisor packs over file:// only: count lazy fetches via Git's trace."""
|
|
import json
|
|
import os
|
|
|
|
git, commit, candidate, _, _, _, _ = history
|
|
for revision in range(12):
|
|
commit(
|
|
f"def handler(value):\n return int(value) + {revision}\n",
|
|
extra={f"file{revision * 5 + i}.py": f"x = {revision * 5 + i}\n" for i in range(5)},
|
|
)
|
|
git.run("config", "uploadpack.allowFilter", "true")
|
|
git.run("config", "uploadpack.allowAnySHA1InWant", "true")
|
|
baseline = [e.introducing(git, candidate), e.ordinary_history(git, "HEAD")]
|
|
path = tmp_path / "lazy"
|
|
trace = tmp_path / "lazy.trace"
|
|
# Local file transport only, no network. No checkout: all blobs start missing.
|
|
subprocess.run(
|
|
["git", "clone", "--quiet", "--filter=blob:none", "--no-checkout", tmp_path.as_uri(), str(path)],
|
|
check=True,
|
|
capture_output=True,
|
|
env={**os.environ, "GIT_ALLOW_PROTOCOL": "file"},
|
|
)
|
|
|
|
def runner(argv, **kwargs):
|
|
return subprocess.run(argv, **kwargs, env={**os.environ, "GIT_TRACE": str(trace), "GIT_ALLOW_PROTOCOL": "file"})
|
|
|
|
partial = e.Git(path, runner=runner)
|
|
missing = partial.run("rev-list", "--objects", "--missing=print", "HEAD")
|
|
assert sum(line.startswith("?") for line in missing.splitlines()) >= 60
|
|
result = [e.introducing(partial, candidate), e.ordinary_history(partial, "HEAD")]
|
|
assert partial.files_read > 0 and partial.bytes_read > 0
|
|
assert json.dumps(result, sort_keys=True).encode() == json.dumps(baseline, sort_keys=True).encode()
|
|
assert sum("built-in: git fetch " in line for line in trace.read_text().splitlines()) >= 12
|