bench(train): class-targeted sourcing, --family, from the public OSV dumps

sources --family <family> keeps only advisories whose CWE the taxonomy maps to that family, writes
sources_<family>.json beside the training set, and excludes the pilot, every repository the
benchmarks already name, the strict used set and every earlier batch; --prefilter-only runs the
dump pass without GitHub. Implemented by Codex (gpt-6-astra), gated here: names normalize rejects
exclude nothing instead of aborting, and the strict used set is in the exclusions.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-13 21:46:45 +02:00
co-authored by Claude Fable 5.1
parent 499f6c4474
commit 3d08d692ff
3 changed files with 340 additions and 38 deletions
+32
View File
@@ -192,3 +192,35 @@ python $B candidates benchmarks/valid/train/verification.json benchmarks/valid/t
The verifier ran before the overlap check, so 137 of the 553 calls went to locations that were dropped later The verifier ran before the overlap check, so 137 of the 553 calls went to locations that were dropped later
(134 by the overlap check, 3 by the merge). A fresh run should call `overlap` before `clone`. (134 by the overlap check, 3 by the merge). A fresh run should call `overlap` before `clone`.
## Grow one family
To source another access-control batch from the existing ecosystem dumps:
```sh
.venv/bin/python -m benchmarks.valid.train.sourcing sources \
--family access_control --osv-dir /path/to/dumps --target 120
```
The directory must contain `PyPI.zip`, `npm.zip`, `Maven.zip` and `Packagist.zip`. The family comes from
`taxonomy.family_of_cwe`; the command prints its CWE ids at startup. Family runs use only OSV records,
keep the existing single-family rule, and exclude the strict used set, the pilot's accepted repositories,
every repository named elsewhere in the development tree, and repositories in `sources.json` and
`sources_batch*.json` in both the work and training directories. These exclusions happen before GitHub checks.
The target defaults to 120 accepted advisories for a family run, or 75 without `--family`. Acceptance still
requires the existing GitHub repository, commit-parent and licence checks. Family output goes to
`benchmarks/valid/train/sources_access_control.json` and `sources_access_control_log.json`; the earlier
source files are unchanged. Repeating a family run replaces that family's output. `--family` and `--extend`
are mutually exclusive. Cloning, catalog building and verification remain separate maintainer steps.
For a network-free dump inspection, add `--prefilter-only`. It reads the same dumps and exclusions, prints
per-ecosystem record, rejection and prefilter counts, and writes an empty source list with `accepted: 0`
and `github_checks_run: 0` in the log. Prefilter passes are not accepted advisories. Missing exclusion files
or unreadable dumps fail the run rather than silently weakening the exclusions.
`overlap --family access_control <batch>` also writes only that family's source file; a batch can be an
absolute path to the family output. `merge --family access_control --catalogs ... --verifications ...`
uses that family's sources and writes `catalog_access_control.toml` and `verification_access_control.json`.
Both commands keep their drop logs in the work directory with the family in the filename. Use the same
`--family` throughout to preserve the original training batch.
+116 -38
View File
@@ -179,28 +179,64 @@ class ZipOSV:
return self.records.get(identifier, {}) return self.records.get(identifier, {})
def family_exclusions() -> dict[str, set[str]]:
"""Collect strict, pilot, tree and earlier batch exclusions before GitHub checks."""
from benchmarks.unseen.eligibility import normalize
def normalized(names) -> set[str]:
out = set()
for name in names:
try:
out.add(normalize(name))
except ValueError:
continue # a link fragment that is not a repository name excludes nothing
return out
groups = {
"pilot": normalized(pilot_accepted()),
"named_elsewhere": normalized(named_elsewhere()),
"used_strict": normalized(json.loads((WORK / "used_strict.json").read_text())) if (WORK / "used_strict.json").is_file() else set(),
"already_sourced": set(),
}
for directory in (WORK, HERE):
paths = {directory / "sources.json", *directory.glob("sources_batch*.json")}
for path in sorted(paths):
if not path.is_file() or path.stem.endswith("_log"):
continue
groups["already_sourced"].update(normalize(row["repository"]) for row in json.loads(path.read_text()))
return groups
def cmd_sources(args) -> int: def cmd_sources(args) -> int:
from benchmarks.unseen import source from benchmarks.unseen import source
from benchmarks.unseen.eligibility import RepositoryNotFound, normalize from benchmarks.unseen.eligibility import RepositoryNotFound, normalize
from openultrasast.model.taxonomy import load_families from openultrasast.model.taxonomy import load_families
taxonomy = load_families() taxonomy = load_families()
accepted = pilot_accepted() family = args.family
pilot = pilot_sources() if family:
log(f"family {family}: {', '.join(sorted(taxonomy.by_id(family).cwes))}")
accepted = {normalize(name) for name in pilot_accepted()}
pilot = [] if family else pilot_sources()
pilot_advisories = {row["advisory"] for row in pilot} pilot_advisories = {row["advisory"] for row in pilot}
pilot_repos = {normalize(row["repository"]) for row in pilot} pilot_repos = {normalize(row["repository"]) for row in pilot}
public = pool_public() public = [] if family else pool_public()
pool_repos = {normalize(row["repository"]) for row in public} pool_repos = {normalize(row["repository"]) for row in public}
used = set(json.loads((WORK / "used.json").read_text())) used = set() if family else set(json.loads((WORK / "used.json").read_text()))
excluded_repos = accepted | pilot_repos | pool_repos | used excluded_repos = accepted | pilot_repos | pool_repos | used
log( exclusion_groups = family_exclusions() if family else {"repository": excluded_repos}
f"exclusion set: pilot accepted {len(accepted)}, pilot sourced {len(pilot_repos)}, pool public {len(pool_repos)}, used {len(used)} -> {len(excluded_repos)} names" # noqa: E501 excluded_repos |= set().union(*exclusion_groups.values())
) if family:
log(f"exclusion set: { {reason: len(names) for reason, names in exclusion_groups.items()} } -> {len(excluded_repos)} names")
else:
log(
f"exclusion set: pilot accepted {len(accepted)}, pilot sourced {len(pilot_repos)}, pool public {len(pool_repos)}, used {len(used)} -> {len(excluded_repos)} names" # noqa: E501
)
osv_api = source.OSV() osv_api = source.OSV()
rows: list[dict] = [] rows: list[dict] = []
counts: Counter = Counter() counts: Counter = Counter()
if args.extend: if args.extend and not family:
# Second batch: only OSV, continuing the same seeded order past every repository already sourced. # Second batch: only OSV, continuing the same seeded order past every repository already sourced.
earlier = json.loads((WORK / "sources.json").read_text()) earlier = json.loads((WORK / "sources.json").read_text())
excluded_repos |= {normalize(row["repository"]) for row in earlier} excluded_repos |= {normalize(row["repository"]) for row in earlier}
@@ -247,11 +283,11 @@ def cmd_sources(args) -> int:
pre: dict[str, dict] = {} pre: dict[str, dict] = {}
records: dict[str, dict] = {} records: dict[str, dict] = {}
for eco_name, eco in OSV_ECOSYSTEMS.items(): for eco_name, eco in OSV_ECOSYSTEMS.items():
archive = zipfile.ZipFile(Path(args.osv_dir) / f"{eco_name}.zip") archive_path = Path(args.osv_dir) / f"{eco_name}.zip"
names = [n for n in archive.namelist() if n.startswith("GHSA-") and n.endswith(".json")] counts[f"osv:{eco}:ghsa_records"] = 0
counts[f"osv:{eco}:ghsa_records"] += len(names) counts[f"osv:{eco}:prefilter_pass"] = 0
for name in names: for record in dump_records(archive_path):
record = json.loads(archive.read(name)) counts[f"osv:{eco}:ghsa_records"] += 1
row = github_row(record) row = github_row(record)
try: try:
if date.fromisoformat(row["published_at"][:10]) < date(2019, 1, 1): if date.fromisoformat(row["published_at"][:10]) < date(2019, 1, 1):
@@ -263,12 +299,15 @@ def cmd_sources(args) -> int:
families = {f.id for c in row["cwes"] if (f := taxonomy.family_of_cwe(c["cwe_id"])) and f.id in source.FAMILIES} families = {f.id for c in row["cwes"] if (f := taxonomy.family_of_cwe(c["cwe_id"])) and f.id in source.FAMILIES}
if len(families) != 1: if len(families) != 1:
raise source.Rejected("family") raise source.Rejected("family")
if family and families != {family}:
raise source.Rejected("family_mismatch")
links = source.fix_links(row) links = source.fix_links(row)
if len(links) != 1: if len(links) != 1:
raise source.Rejected("fix_links") raise source.Rejected("fix_links")
((repo, _),) = links ((repo, _),) = links
if repo in excluded_repos: if repo in excluded_repos:
raise source.Rejected("excluded_repository") reason = next(reason for reason, names in exclusion_groups.items() if repo in names)
raise source.Rejected(f"excluded_{reason}")
except source.Rejected as exc: except source.Rejected as exc:
counts[f"osv:{eco}:{exc}"] += 1 counts[f"osv:{eco}:{exc}"] += 1
continue continue
@@ -278,16 +317,24 @@ def cmd_sources(args) -> int:
current = pre.get(repo) current = pre.get(repo)
if current is None or row["published_at"] > current["published_at"]: if current is None or row["published_at"] > current["published_at"]:
pre[repo] = {**row, "_eco": eco} pre[repo] = {**row, "_eco": eco}
log(json.dumps({k: v for k, v in sorted(counts.items()) if k.startswith(f"osv:{eco}:")}))
log(f"OSV prefilter: {len(pre)} repositories with a candidate advisory") log(f"OSV prefilter: {len(pre)} repositories with a candidate advisory")
counts["osv:prefilter_repositories"] = len(pre) counts["osv:prefilter_repositories"] = len(pre)
token = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True, check=True).stdout.strip() github = None
github = source.GitHub(token) if not args.prefilter_only:
token = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True, check=True).stdout.strip()
github = source.GitHub(token)
else:
log("PREFILTER ONLY: GitHub checks did not run; no advisories are accepted.")
osv_stub = ZipOSV(records) osv_stub = ZipOSV(records)
ordered = source.ordered_advisories(list(pre.values()), args.seed) ordered = source.ordered_advisories(list(pre.values()), args.seed)
target = args.target target = args.target if args.target is not None else (120 if family else 75)
counts["target"] = target
counts["github_checks_run"] = int(github is not None)
counts["accepted"] = 0
picked = 0 picked = 0
for row in ordered: for row in ordered if github is not None else []:
if picked >= target: if picked >= target:
break break
try: try:
@@ -328,24 +375,35 @@ def cmd_sources(args) -> int:
excluded_repos.add(cand.repository) excluded_repos.add(cand.repository)
picked += 1 picked += 1
counts["osv"] += 1 counts["osv"] += 1
counts["accepted"] += 1
log(f"osv pick {picked}/{target}: {cand.repository} {cand.advisory} {cand.ecosystem} {cand.family} {cand.license_spdx}") log(f"osv pick {picked}/{target}: {cand.repository} {cand.advisory} {cand.ecosystem} {cand.family} {cand.license_spdx}")
counts["github_api_calls"] = github.calls counts["github_api_calls"] = github.calls if github is not None else 0
counts["osv_api_calls"] = osv_api.calls counts["osv_api_calls"] = osv_api.calls
# the candidate list must not contain a held-out repository, whatever the origin # the candidate list must not contain a held-out repository, whatever the origin
assert not {row["repository"] for row in rows} & accepted assert not {row["repository"] for row in rows} & accepted
stem = "sources_extend" if args.extend else "sources" stem = f"sources_{family}" if family else ("sources_extend" if args.extend else "sources")
(WORK / f"{stem}.json").write_text(json.dumps(rows, indent=1, ensure_ascii=False) + "\n") output = HERE if family else WORK
(WORK / f"{stem}_log.json").write_text(json.dumps(dict(sorted(counts.items())), indent=2) + "\n") (output / f"{stem}.json").write_text(json.dumps(rows, indent=1, ensure_ascii=False) + "\n")
log( (output / f"{stem}_log.json").write_text(json.dumps(dict(sorted(counts.items())), indent=2) + "\n")
json.dumps( log(json.dumps(dict(sorted(counts.items()))))
{k: v for k, v in counts.items() if not k.startswith("osv:") or "check" in k or "pass" in k or "repositories" in k}, log(f"{stem}.json: {len(rows)} advisories, {len({r['repository'] for r in rows})} repositories")
sort_keys=True,
)
)
log(f"sources.json: {len(rows)} advisories, {len({r['repository'] for r in rows})} repositories")
return 0 return 0
def dump_records(path: Path):
"""Read GHSA records from a dump and report the bytes read from the first record."""
with zipfile.ZipFile(path) as archive:
first = True
for name in archive.namelist():
if not name.startswith("GHSA-") or not name.endswith(".json"):
continue
data = archive.read(name)
if first:
log(f"read {path}: {name}, {len(data)} bytes")
first = False
yield json.loads(data)
# ----------------------------------------------------------------------------- clone # ----------------------------------------------------------------------------- clone
@@ -688,18 +746,26 @@ def cmd_overlap(args) -> int:
from openultrasast.learn.labels import repo_name from openultrasast.learn.labels import repo_name
elsewhere = named_elsewhere() elsewhere = named_elsewhere()
if args.family:
for reason, names in family_exclusions().items():
for name in names:
elsewhere.setdefault(name, set()).add(reason)
rows = [row for name in args.batches for row in json.loads((WORK / name).read_text())] rows = [row for name in args.batches for row in json.loads((WORK / name).read_text())]
kept, dropped = [], [] kept, dropped = [], []
for row in rows: for row in rows:
if args.family and row["family"] != args.family:
continue
where = sorted(elsewhere.get(repo_name(row["repository"]), ())) where = sorted(elsewhere.get(repo_name(row["repository"]), ()))
if where: if where:
dropped.append({"repository": row["repository"], "advisory": row["advisory"], "origin": row["origin"], "named_in": where}) dropped.append({"repository": row["repository"], "advisory": row["advisory"], "origin": row["origin"], "named_in": where})
else: else:
kept.append(row) kept.append(row)
assert not {repo_name(r["repository"]) for r in kept} & pilot_accepted() assert not {repo_name(r["repository"]) for r in kept} & pilot_accepted()
(WORK / "overlap_dropped.json").write_text(json.dumps(dropped, indent=1) + "\n") suffix = f"_{args.family}" if args.family else ""
(WORK / "sources.json").write_text(json.dumps(kept, indent=1, ensure_ascii=False) + "\n") (WORK / f"overlap{suffix}_dropped.json").write_text(json.dumps(dropped, indent=1) + "\n")
(HERE / "sources.json").write_text(json.dumps(kept, indent=1, ensure_ascii=False) + "\n") if not args.family:
(WORK / "sources.json").write_text(json.dumps(kept, indent=1, ensure_ascii=False) + "\n")
(HERE / f"sources{suffix}.json").write_text(json.dumps(kept, indent=1, ensure_ascii=False) + "\n")
log( log(
f"overlap: {len(rows)} sources, {len(dropped)} dropped (named elsewhere), {len(kept)} kept; by origin dropped {dict(Counter(d['origin'] for d in dropped))}" # noqa: E501 f"overlap: {len(rows)} sources, {len(dropped)} dropped (named elsewhere), {len(kept)} kept; by origin dropped {dict(Counter(d['origin'] for d in dropped))}" # noqa: E501
) )
@@ -711,7 +777,8 @@ def cmd_merge(args) -> int:
from benchmarks.valid.catalog import write_catalog from benchmarks.valid.catalog import write_catalog
from benchmarks.valid.verify import write_records from benchmarks.valid.verify import write_records
advisories = {row["advisory"] for row in json.loads((HERE / "sources.json").read_text())} suffix = f"_{args.family}" if args.family else ""
advisories = {row["advisory"] for row in json.loads((HERE / f"sources{suffix}.json").read_text())}
cases = [c for name in args.catalogs for c in tomllib.loads((WORK / name).read_text()).get("case", []) if c["advisory"] in advisories] cases = [c for name in args.catalogs for c in tomllib.loads((WORK / name).read_text()).get("case", []) if c["advisory"] in advisories]
# A site whose code or advisory text links a repository the tree already names (a pair-corpus or reserved # A site whose code or advisory text links a repository the tree already names (a pair-corpus or reserved
# repository in a code comment, say) would put that name back into benchmarks/: drop the site, record why. # repository in a code comment, say) would put that name back into benchmarks/: drop the site, record why.
@@ -725,11 +792,11 @@ def cmd_merge(args) -> int:
if hits: if hits:
cases.remove(case) cases.remove(case)
dropped.append({"id": case["id"], "repo": case["repo"], "names": hits}) dropped.append({"id": case["id"], "repo": case["repo"], "names": hits})
(WORK / "merge_dropped_sites.json").write_text(json.dumps(dropped, indent=1) + "\n") (WORK / f"merge{suffix}_dropped_sites.json").write_text(json.dumps(dropped, indent=1) + "\n")
ids = {c["id"] for c in cases} ids = {c["id"] for c in cases}
records = [r for name in args.verifications for r in json.loads((WORK / name).read_text())["cases"] if r["id"] in ids] records = [r for name in args.verifications for r in json.loads((WORK / name).read_text())["cases"] if r["id"] in ids]
write_catalog(HERE / "catalog.toml", cases) write_catalog(HERE / f"catalog{suffix}.toml", cases)
write_records(HERE / "verification.json", records) write_records(HERE / f"verification{suffix}.json", records)
log(f"merge: {len(cases)} sites, {len(records)} verification records, {len(dropped)} sites dropped for naming a used repository") log(f"merge: {len(cases)} sites, {len(records)} verification records, {len(dropped)} sites dropped for naming a used repository")
return 0 return 0
@@ -783,14 +850,21 @@ def cmd_precheck(args) -> int:
def main(argv=None) -> int: def main(argv=None) -> int:
from benchmarks.unseen.source import FAMILIES
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("used") sub.add_parser("used")
p = sub.add_parser("sources") p = sub.add_parser("sources")
p.add_argument("--osv-dir", required=True) p.add_argument("--osv-dir", required=True)
p.add_argument("--target", type=int, default=75, help="OSV advisories to accept after the GitHub checks") p.add_argument("--target", type=int, help="OSV advisories to accept after GitHub checks (default: 120 with --family, otherwise 75)")
p.add_argument("--seed", type=int, default=1) p.add_argument("--seed", type=int, default=1)
p.add_argument("--extend", action="store_true", help="second OSV batch into sources_extend.json, excluding sources.json") mode = p.add_mutually_exclusive_group()
mode.add_argument("--extend", action="store_true", help="second OSV batch into sources_extend.json, excluding sources.json")
mode.add_argument("--family", choices=sorted(FAMILIES), help="source one family into sources_<family>.json in the training directory")
p.add_argument(
"--prefilter-only", action="store_true", help="with --family, read dumps and exclusions without GitHub checks or accepts"
)
p = sub.add_parser("clone") p = sub.add_parser("clone")
p.add_argument("--sources", help="sources file to clone (default sources.json)") p.add_argument("--sources", help="sources file to clone (default sources.json)")
p = sub.add_parser("filter") p = sub.add_parser("filter")
@@ -807,8 +881,10 @@ def main(argv=None) -> int:
p.add_argument("catalog") p.add_argument("catalog")
p.add_argument("--out", required=True) p.add_argument("--out", required=True)
p = sub.add_parser("overlap") p = sub.add_parser("overlap")
p.add_argument("--family", choices=sorted(FAMILIES))
p.add_argument("batches", nargs="+", help="source batch files in the work directory") p.add_argument("batches", nargs="+", help="source batch files in the work directory")
p = sub.add_parser("merge") p = sub.add_parser("merge")
p.add_argument("--family", choices=sorted(FAMILIES))
p.add_argument("--catalogs", nargs="+", required=True) p.add_argument("--catalogs", nargs="+", required=True)
p.add_argument("--verifications", nargs="+", required=True) p.add_argument("--verifications", nargs="+", required=True)
p = sub.add_parser("precheck") p = sub.add_parser("precheck")
@@ -816,6 +892,8 @@ def main(argv=None) -> int:
p.add_argument("catalog") p.add_argument("catalog")
p.add_argument("--repos", required=True) p.add_argument("--repos", required=True)
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.command == "sources" and args.prefilter_only and not args.family:
parser.error("--prefilter-only requires --family")
return { return {
"used": cmd_used, "used": cmd_used,
"sources": cmd_sources, "sources": cmd_sources,
+192
View File
@@ -0,0 +1,192 @@
"""Offline tests for family-specific training sources."""
import importlib
import json
import socket
import subprocess
import sys
import zipfile
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sourcing = importlib.import_module("benchmarks.valid.train.sourcing")
source = importlib.import_module("benchmarks.unseen.source")
@pytest.fixture(autouse=True)
def offline(monkeypatch):
def forbidden(*args, **kwargs):
pytest.fail("unexpected network or subprocess call")
monkeypatch.setattr(socket, "create_connection", forbidden)
monkeypatch.setattr(socket.socket, "connect", forbidden)
monkeypatch.setattr(subprocess, "run", forbidden)
@pytest.fixture
def environment(tmp_path, monkeypatch):
root = tmp_path / "root"
here = root / "benchmarks/valid/train"
work = tmp_path / "work"
pilot = root / "benchmarks/pilot"
dumps = tmp_path / "dumps"
for directory in (here, work, pilot, dumps):
directory.mkdir(parents=True)
for name, value in (("ROOT", root), ("HERE", here), ("WORK", work), ("PILOT", pilot)):
monkeypatch.setattr(sourcing, name, value)
(work / "used_strict.json").write_text(json.dumps(["fixture/used"]))
(pilot / "accepted_repos.json").write_text("[]")
(here / "sources.json").write_text('[{"repository": "fixture/earlier"}]\n')
(work / "sources.json").write_text("[]\n")
def record(name, cwes):
return {
"id": f"GHSA-{name}",
"published": "2025-01-01T00:00:00Z",
"database_specific": {"github_reviewed": True, "cwe_ids": cwes},
"affected": [{"package": {"ecosystem": "PyPI", "name": name}}],
"references": [{"url": f"https://github.com/fixture/{name}/commit/" + "a" * 40}],
}
records = [record("allowed", ["CWE-862"]), record("injection", ["CWE-89"]), record("no-cwe", []), record("used", ["CWE-862"])]
def write_dumps(rows):
for ecosystem in sourcing.OSV_ECOSYSTEMS:
with zipfile.ZipFile(dumps / f"{ecosystem}.zip", "w") as archive:
for row in rows if ecosystem == "PyPI" else []:
archive.writestr(row["id"] + ".json", json.dumps(row))
write_dumps(records)
return here, work, pilot, dumps, records, record, write_dumps
@pytest.fixture
def github(monkeypatch):
class GitHub:
calls = 0
repositories = []
def __init__(self, token):
assert token == "fixture-token"
def get_repo(self, name):
self.calls += 1
self.repositories.append(name)
return {"private": False, "size": 100, "clone_url": f"https://github.com/{name}.git", "full_name": name}
def get_commit(self, name, sha):
self.calls += 1
return {"sha": sha, "parents": [{"sha": "b" * 40}]}
def get_license(self, name, ref):
self.calls += 1
return {"spdx_id": "MIT", "path": "LICENSE"}
def token(argv, **kwargs):
assert argv == ["gh", "auth", "token"]
return subprocess.CompletedProcess(argv, 0, stdout="fixture-token\n")
monkeypatch.setattr(subprocess, "run", token)
monkeypatch.setattr(source, "GitHub", GitHub)
return GitHub
def run_sources(dumps, *extra):
return sourcing.main(["sources", "--family", "access_control", "--osv-dir", str(dumps), *extra])
def test_family_filters_zip_and_preserves_sources(environment, github, capsys):
here, work, _, dumps, *_ = environment
original = (here / "sources.json").read_bytes()
work_original = (work / "sources.json").read_bytes()
assert run_sources(dumps) == 0
rows = json.loads((here / "sources_access_control.json").read_text())
assert [(row["repository"], row["family"], row["cwe"]) for row in rows] == [("fixture/allowed", "access_control", "CWE-862")]
assert github.repositories == ["fixture/allowed"]
assert (here / "sources.json").read_bytes() == original
assert (work / "sources.json").read_bytes() == work_original
counts = json.loads((here / "sources_access_control_log.json").read_text())
assert counts["osv:pip:ghsa_records"] == 4
assert counts["osv:pip:prefilter_pass"] == counts["accepted"] == 1
assert counts["osv:pip:family"] == counts["osv:pip:family_mismatch"] == counts["osv:pip:excluded_named_elsewhere"] == 1
assert counts["target"] == 120
assert counts["github_api_calls"] == 3
output = capsys.readouterr().out
assert "bytes" in output and '"osv:pip:ghsa_records": 4' in output
def test_unknown_family_fails_before_io(tmp_path, capsys):
with pytest.raises(SystemExit) as error:
sourcing.main(["sources", "--family", "not-a-family", "--osv-dir", str(tmp_path)])
assert error.value.code == 2
assert "invalid choice" in capsys.readouterr().err
assert not list(tmp_path.iterdir())
def test_prefilter_only_never_accepts_or_calls_github(environment, capsys):
here, _, _, dumps, *_ = environment
assert run_sources(dumps, "--prefilter-only", "--target", "120") == 0
assert json.loads((here / "sources_access_control.json").read_text()) == []
counts = json.loads((here / "sources_access_control_log.json").read_text())
assert counts["osv:pip:prefilter_pass"] == 1
assert counts["accepted"] == counts["github_api_calls"] == counts["osv_api_calls"] == counts["github_checks_run"] == 0
assert "GitHub checks did not run" in capsys.readouterr().out
def test_all_exclusions_apply_before_github(environment, github):
here, work, pilot, dumps, records, record, write_dumps = environment
(pilot / "accepted_repos.json").write_text('["FIXTURE/Held-out"]')
(pilot / "named.md").write_text("https://github.com/fixture/named\n")
(work / "sources.json").write_text('[{"repository": "fixture/work-source"}]')
(work / "sources_batch1.json").write_text('[{"repository": "FIXTURE/Batch"}]')
(work / "sources_batch1_log.json").write_text('{"accepted": 1}')
records += [record(name, ["CWE-862"]) for name in ("held-out", "named", "earlier", "work-source", "batch")]
records.append(record("mixed", ["CWE-862", "CWE-89"]))
write_dumps(records)
assert run_sources(dumps) == 0
assert github.repositories == ["fixture/allowed"]
counts = json.loads((here / "sources_access_control_log.json").read_text())
assert counts["osv:pip:excluded_pilot"] == 1
assert counts["osv:pip:excluded_named_elsewhere"] == 2
assert counts["osv:pip:excluded_already_sourced"] == 3
assert counts["osv:pip:family"] == 2
def test_target_counts_accepts_after_github_rejection(environment, github, monkeypatch):
here, _, _, dumps, records, record, write_dumps = environment
records.extend(record(f"extra-{i}", ["CWE-862"]) for i in range(3))
write_dumps(records)
attempts = []
def license_record(self, name, ref):
attempts.append(name)
return {"spdx_id": "NONE" if len(attempts) == 1 else "MIT", "path": "LICENSE"}
monkeypatch.setattr(github, "get_license", license_record)
assert run_sources(dumps, "--target", "2") == 0
assert len(attempts) == 3
counts = json.loads((here / "sources_access_control_log.json").read_text())
assert counts["accepted"] == counts["target"] == 2
assert counts["osv:check:licence_not_permissive"] == 1
def test_family_overlap_and_merge_keep_batches_separate(environment):
here, work, _, _, *_ = environment
original = (here / "sources.json").read_bytes()
batch = [{"repository": "fixture/new", "advisory": "GHSA-new", "origin": "osv", "family": "access_control"}]
(work / "new.json").write_text(json.dumps(batch))
assert sourcing.main(["overlap", "--family", "access_control", "new.json"]) == 0
assert json.loads((here / "sources_access_control.json").read_text()) == batch
(work / "named_elsewhere.json").write_text("{}")
(work / "catalog.toml").write_text("case = []\n")
(work / "verification.json").write_text('{"cases": []}')
(here / "catalog.toml").write_text("original catalog")
(here / "verification.json").write_text("original verification")
assert sourcing.main(["merge", "--family", "access_control", "--catalogs", "catalog.toml", "--verifications", "verification.json"]) == 0
assert (here / "catalog_access_control.toml").is_file()
assert (here / "verification_access_control.json").is_file()
assert (here / "sources.json").read_bytes() == original
assert (here / "catalog.toml").read_text() == "original catalog"
assert (here / "verification.json").read_text() == "original verification"