consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
183
tests/supply-chain/01-jcs-property/test_jcs.py
Normal file
183
tests/supply-chain/01-jcs-property/test_jcs.py
Normal file
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic JCS-style property checks for SBOM/attestation fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import json
|
||||
import pathlib
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "tools"
|
||||
sys.path.insert(0, str(TOOLS_DIR))
|
||||
|
||||
from canonicalize_json import DuplicateKeyError, canonicalize_text, canonicalize_value, parse_json_strict # noqa: E402
|
||||
from emit_artifacts import TestCaseResult, record_failure, write_junit # noqa: E402
|
||||
|
||||
|
||||
def _shuffle_value(value: Any, rng: random.Random) -> Any:
|
||||
if isinstance(value, dict):
|
||||
items = list(value.items())
|
||||
rng.shuffle(items)
|
||||
return {k: _shuffle_value(v, rng) for k, v in items}
|
||||
if isinstance(value, list):
|
||||
return [_shuffle_value(item, rng) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _load_fixture_texts(corpus_root: pathlib.Path) -> list[tuple[str, str]]:
|
||||
fixture_paths = sorted(corpus_root.rglob("*.json"))
|
||||
fixtures: list[tuple[str, str]] = []
|
||||
for path in fixture_paths:
|
||||
fixtures.append((path.name, path.read_text(encoding="utf-8")))
|
||||
return fixtures
|
||||
|
||||
|
||||
def _run(seed: int, output: pathlib.Path) -> int:
|
||||
start = time.perf_counter()
|
||||
rng = random.Random(seed)
|
||||
|
||||
corpus_root = pathlib.Path(__file__).resolve().parents[1] / "05-corpus" / "fixtures" / "sboms"
|
||||
fixtures = _load_fixture_texts(corpus_root)
|
||||
|
||||
cases: list[TestCaseResult] = []
|
||||
failures = 0
|
||||
|
||||
for index, (fixture_name, fixture_text) in enumerate(fixtures):
|
||||
case_id = f"{fixture_name}-idempotence"
|
||||
case_start = time.perf_counter()
|
||||
try:
|
||||
parsed = parse_json_strict(fixture_text)
|
||||
canonical_1 = canonicalize_text(fixture_text)
|
||||
canonical_2 = canonicalize_text(canonical_1)
|
||||
if canonical_1 != canonical_2:
|
||||
diff = "\n".join(
|
||||
difflib.unified_diff(
|
||||
canonical_1.splitlines(),
|
||||
canonical_2.splitlines(),
|
||||
fromfile="canonical_1",
|
||||
tofile="canonical_2",
|
||||
lineterm="",
|
||||
)
|
||||
)
|
||||
raise AssertionError("Idempotence mismatch", diff)
|
||||
|
||||
shuffled = _shuffle_value(parsed, random.Random(rng.randint(0, 2**31 - 1) + index))
|
||||
canonical_3 = canonicalize_value(shuffled)
|
||||
if canonical_1 != canonical_3:
|
||||
diff = "\n".join(
|
||||
difflib.unified_diff(
|
||||
canonical_1.splitlines(),
|
||||
canonical_3.splitlines(),
|
||||
fromfile="canonical_1",
|
||||
tofile="canonical_shuffled",
|
||||
lineterm="",
|
||||
)
|
||||
)
|
||||
raise AssertionError("Permutation equality mismatch", diff)
|
||||
|
||||
cases.append(
|
||||
TestCaseResult(
|
||||
suite="01-jcs-property",
|
||||
name=case_id,
|
||||
passed=True,
|
||||
duration_seconds=time.perf_counter() - case_start,
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures += 1
|
||||
patch = None
|
||||
message = str(exc)
|
||||
if isinstance(exc, AssertionError) and len(exc.args) > 1:
|
||||
patch = str(exc.args[1])
|
||||
message = str(exc.args[0])
|
||||
record_failure(
|
||||
lane_output_dir=output,
|
||||
case_id=case_id,
|
||||
seed=seed,
|
||||
payload_text=fixture_text,
|
||||
error_class="canonicalization_invariant_failed",
|
||||
message=message,
|
||||
details={"fixture": fixture_name},
|
||||
canonical_diff_patch=patch,
|
||||
)
|
||||
cases.append(
|
||||
TestCaseResult(
|
||||
suite="01-jcs-property",
|
||||
name=case_id,
|
||||
passed=False,
|
||||
duration_seconds=time.perf_counter() - case_start,
|
||||
failure_message=message,
|
||||
)
|
||||
)
|
||||
|
||||
duplicate_case = '{"a":1,"a":2}'
|
||||
duplicate_case_id = "duplicate-key-rejection"
|
||||
duplicate_start = time.perf_counter()
|
||||
try:
|
||||
parse_json_strict(duplicate_case)
|
||||
raise AssertionError("Duplicate key payload was not rejected")
|
||||
except DuplicateKeyError:
|
||||
cases.append(
|
||||
TestCaseResult(
|
||||
suite="01-jcs-property",
|
||||
name=duplicate_case_id,
|
||||
passed=True,
|
||||
duration_seconds=time.perf_counter() - duplicate_start,
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures += 1
|
||||
message = str(exc)
|
||||
record_failure(
|
||||
lane_output_dir=output,
|
||||
case_id=duplicate_case_id,
|
||||
seed=seed,
|
||||
payload_text=duplicate_case,
|
||||
error_class="duplicate_key_rejection_failed",
|
||||
message=message,
|
||||
details={},
|
||||
canonical_diff_patch=None,
|
||||
)
|
||||
cases.append(
|
||||
TestCaseResult(
|
||||
suite="01-jcs-property",
|
||||
name=duplicate_case_id,
|
||||
passed=False,
|
||||
duration_seconds=time.perf_counter() - duplicate_start,
|
||||
failure_message=message,
|
||||
)
|
||||
)
|
||||
|
||||
summary = {
|
||||
"seed": seed,
|
||||
"fixtureCount": len(fixtures),
|
||||
"testCases": len(cases),
|
||||
"failures": failures,
|
||||
"durationSeconds": round(time.perf_counter() - start, 4),
|
||||
}
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
(output / "summary.json").write_text(json.dumps(summary, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
write_junit(output / "junit.xml", cases)
|
||||
return 0 if failures == 0 else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run deterministic JCS property checks.")
|
||||
parser.add_argument("--seed", type=int, default=20260226, help="Deterministic seed.")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=pathlib.Path,
|
||||
default=pathlib.Path("out/supply-chain/01-jcs-property"),
|
||||
help="Output directory for junit and artifacts.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
return _run(args.seed, args.output.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
208
tests/supply-chain/02-schema-fuzz/run_mutations.py
Normal file
208
tests/supply-chain/02-schema-fuzz/run_mutations.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Schema-aware deterministic mutation lane for supply-chain fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
from typing import Callable
|
||||
|
||||
TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "tools"
|
||||
sys.path.insert(0, str(TOOLS_DIR))
|
||||
|
||||
from canonicalize_json import DuplicateKeyError, canonicalize_text, parse_json_strict # noqa: E402
|
||||
from emit_artifacts import TestCaseResult, record_failure, write_junit # noqa: E402
|
||||
|
||||
MutationFn = Callable[[str], str]
|
||||
|
||||
|
||||
def _truncate_payload(text: str) -> str:
|
||||
return text[:-1] if text else text
|
||||
|
||||
|
||||
def _append_garbage(text: str) -> str:
|
||||
return text + " !!!"
|
||||
|
||||
|
||||
def _inject_duplicate_key(text: str) -> str:
|
||||
if text.lstrip().startswith("{"):
|
||||
return text.replace("{", '{"bomFormat":"CycloneDX","bomFormat":"CycloneDX",', 1)
|
||||
return text
|
||||
|
||||
|
||||
def _unicode_normalization_toggle(text: str) -> str:
|
||||
return unicodedata.normalize("NFD", text)
|
||||
|
||||
|
||||
def _reorder_known_keys(text: str) -> str:
|
||||
parsed = parse_json_strict(text)
|
||||
if isinstance(parsed, dict):
|
||||
reordered = {k: parsed[k] for k in sorted(parsed.keys(), reverse=True)}
|
||||
return json.dumps(reordered, ensure_ascii=False, indent=2)
|
||||
return text
|
||||
|
||||
|
||||
MUTATORS: list[tuple[str, MutationFn]] = [
|
||||
("truncate", _truncate_payload),
|
||||
("append_garbage", _append_garbage),
|
||||
("duplicate_key", _inject_duplicate_key),
|
||||
("unicode_nfd", _unicode_normalization_toggle),
|
||||
("reorder_keys", _reorder_known_keys),
|
||||
]
|
||||
|
||||
|
||||
def _load_inputs() -> list[tuple[str, str]]:
|
||||
root = pathlib.Path(__file__).resolve().parents[1] / "05-corpus" / "fixtures"
|
||||
files = sorted(path for path in root.rglob("*.json") if "malformed" not in path.parts)
|
||||
return [(path.name, path.read_text(encoding="utf-8")) for path in files]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run deterministic schema mutation lane.")
|
||||
parser.add_argument("--seed", type=int, default=20260226)
|
||||
parser.add_argument("--limit", type=int, default=1000)
|
||||
parser.add_argument("--time-seconds", type=int, default=60)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=pathlib.Path,
|
||||
default=pathlib.Path("out/supply-chain/02-schema-fuzz"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
rng = random.Random(args.seed)
|
||||
output = args.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
mutated_dir = output / "corpus" / "mutated"
|
||||
mutated_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
fixture_inputs = _load_inputs()
|
||||
if not fixture_inputs:
|
||||
raise SystemExit("No mutation inputs found")
|
||||
|
||||
start = time.perf_counter()
|
||||
counts = {
|
||||
"accepted": 0,
|
||||
"rejected_invalid_json": 0,
|
||||
"rejected_duplicate_keys": 0,
|
||||
"crash": 0,
|
||||
}
|
||||
|
||||
lane_case_results: list[TestCaseResult] = []
|
||||
mutation_records: list[dict[str, str | int]] = []
|
||||
|
||||
executed = 0
|
||||
while executed < args.limit and (time.perf_counter() - start) < args.time_seconds:
|
||||
fixture_name, payload = fixture_inputs[executed % len(fixture_inputs)]
|
||||
mutator_name, mutator = MUTATORS[rng.randrange(0, len(MUTATORS))]
|
||||
case_id = f"{executed:05d}-{fixture_name}-{mutator_name}"
|
||||
case_start = time.perf_counter()
|
||||
|
||||
try:
|
||||
mutated = mutator(payload)
|
||||
canonicalize_text(mutated)
|
||||
counts["accepted"] += 1
|
||||
lane_case_results.append(
|
||||
TestCaseResult(
|
||||
suite="02-schema-fuzz",
|
||||
name=case_id,
|
||||
passed=True,
|
||||
duration_seconds=time.perf_counter() - case_start,
|
||||
)
|
||||
)
|
||||
except DuplicateKeyError as exc:
|
||||
mutated = mutator(payload)
|
||||
counts["rejected_duplicate_keys"] += 1
|
||||
lane_case_results.append(
|
||||
TestCaseResult(
|
||||
suite="02-schema-fuzz",
|
||||
name=case_id,
|
||||
passed=True,
|
||||
duration_seconds=time.perf_counter() - case_start,
|
||||
)
|
||||
)
|
||||
mutation_records.append(
|
||||
{"caseId": case_id, "result": "rejected_duplicate_keys", "reason": str(exc)}
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
mutated = mutator(payload)
|
||||
counts["rejected_invalid_json"] += 1
|
||||
lane_case_results.append(
|
||||
TestCaseResult(
|
||||
suite="02-schema-fuzz",
|
||||
name=case_id,
|
||||
passed=True,
|
||||
duration_seconds=time.perf_counter() - case_start,
|
||||
)
|
||||
)
|
||||
mutation_records.append(
|
||||
{"caseId": case_id, "result": "rejected_invalid_json", "reason": str(exc)}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
mutated = mutator(payload)
|
||||
counts["crash"] += 1
|
||||
record_failure(
|
||||
lane_output_dir=output,
|
||||
case_id=case_id,
|
||||
seed=args.seed,
|
||||
payload_text=mutated,
|
||||
error_class="mutation_lane_crash",
|
||||
message=str(exc),
|
||||
details={
|
||||
"fixture": fixture_name,
|
||||
"mutator": mutator_name,
|
||||
},
|
||||
canonical_diff_patch=None,
|
||||
)
|
||||
lane_case_results.append(
|
||||
TestCaseResult(
|
||||
suite="02-schema-fuzz",
|
||||
name=case_id,
|
||||
passed=False,
|
||||
duration_seconds=time.perf_counter() - case_start,
|
||||
failure_message=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
if executed < 50:
|
||||
mutated_path = mutated_dir / f"{executed:05d}-{mutator_name}.json"
|
||||
mutated_path.write_text(mutated, encoding="utf-8", newline="\n")
|
||||
executed += 1
|
||||
|
||||
report = {
|
||||
"seed": args.seed,
|
||||
"executed": executed,
|
||||
"limit": args.limit,
|
||||
"timeBudgetSeconds": args.time_seconds,
|
||||
"durationSeconds": round(time.perf_counter() - start, 4),
|
||||
"counts": counts,
|
||||
"machineReadableErrorClasses": sorted(
|
||||
{
|
||||
"invalid_json",
|
||||
"duplicate_key",
|
||||
"mutation_lane_crash",
|
||||
}
|
||||
),
|
||||
"records": mutation_records,
|
||||
}
|
||||
(output / "report.json").write_text(json.dumps(report, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
write_junit(output / "junit.xml", lane_case_results)
|
||||
|
||||
repro = (
|
||||
"# Repro Playbook\n\n"
|
||||
f"- Seed: `{args.seed}`\n"
|
||||
f"- Executed mutations: `{executed}`\n"
|
||||
"- Replay command:\n"
|
||||
f" - `python tests/supply-chain/02-schema-fuzz/run_mutations.py --seed {args.seed} --limit {executed} --time-seconds {args.time_seconds}`\n"
|
||||
)
|
||||
(output / "repro_playbook.md").write_text(repro, encoding="utf-8", newline="\n")
|
||||
|
||||
return 0 if counts["crash"] == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
69
tests/supply-chain/03-rekor-neg/rekor_shim.py
Normal file
69
tests/supply-chain/03-rekor-neg/rekor_shim.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic Rekor error-mode shim used by negative path test lane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RekorCase:
|
||||
case_id: str
|
||||
status_code: int
|
||||
entry_type: str
|
||||
expected_code: str
|
||||
message: str
|
||||
response_body: dict[str, object]
|
||||
|
||||
|
||||
def default_cases() -> list[RekorCase]:
|
||||
return [
|
||||
RekorCase(
|
||||
case_id="oversized-payload-413",
|
||||
status_code=413,
|
||||
entry_type="intoto",
|
||||
expected_code="payload_too_large",
|
||||
message="payload size exceeds configured limit",
|
||||
response_body={"error": "payload too large", "maxBytes": 10_000_000},
|
||||
),
|
||||
RekorCase(
|
||||
case_id="unsupported-entry-type-400",
|
||||
status_code=400,
|
||||
entry_type="unknown",
|
||||
expected_code="unsupported_entry_type",
|
||||
message="unsupported entry type",
|
||||
response_body={"error": "unsupported entry type", "entryType": "unknown"},
|
||||
),
|
||||
RekorCase(
|
||||
case_id="failed-dependency-424",
|
||||
status_code=424,
|
||||
entry_type="intoto",
|
||||
expected_code="failed_dependency",
|
||||
message="rekor backend dependency failure",
|
||||
response_body={"error": "ledger gap", "reprocessToken": "rekor-gap-001"},
|
||||
),
|
||||
RekorCase(
|
||||
case_id="gateway-timeout-504",
|
||||
status_code=504,
|
||||
entry_type="intoto",
|
||||
expected_code="upstream_timeout",
|
||||
message="rekor upstream timeout",
|
||||
response_body={"error": "timeout", "retryAfterSeconds": 30},
|
||||
),
|
||||
RekorCase(
|
||||
case_id="accepted-for-reprocess-202",
|
||||
status_code=202,
|
||||
entry_type="intoto",
|
||||
expected_code="reprocess_pending",
|
||||
message="accepted for asynchronous replay",
|
||||
response_body={"status": "accepted", "reprocessToken": "rekor-async-001"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def simulate_submit(case: RekorCase) -> tuple[int, dict[str, object], dict[str, str]]:
|
||||
headers = {
|
||||
"x-correlation-id": f"corr-{case.case_id}",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
return case.status_code, dict(case.response_body), headers
|
||||
140
tests/supply-chain/03-rekor-neg/run_negative_suite.py
Normal file
140
tests/supply-chain/03-rekor-neg/run_negative_suite.py
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run deterministic Rekor/DSSE negative-path verification suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import tarfile
|
||||
import time
|
||||
|
||||
from rekor_shim import RekorCase, default_cases, simulate_submit
|
||||
|
||||
import sys
|
||||
|
||||
TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "tools"
|
||||
sys.path.insert(0, str(TOOLS_DIR))
|
||||
from emit_artifacts import TestCaseResult, write_junit # noqa: E402
|
||||
|
||||
|
||||
def _classify(case: RekorCase, status: int, body: dict[str, object]) -> tuple[str, str | None]:
|
||||
reprocess_token = str(body.get("reprocessToken")) if body.get("reprocessToken") else None
|
||||
|
||||
if status == 413:
|
||||
return "payload_too_large", None
|
||||
if status == 424:
|
||||
return "failed_dependency", reprocess_token or f"retry-{case.case_id}"
|
||||
if status == 504:
|
||||
return "upstream_timeout", f"timeout-{case.case_id}"
|
||||
if status == 202:
|
||||
return "reprocess_pending", reprocess_token or f"pending-{case.case_id}"
|
||||
if status == 400 and case.entry_type == "unknown":
|
||||
return "unsupported_entry_type", None
|
||||
return "unexpected_rekor_status", None
|
||||
|
||||
|
||||
def _token(case_id: str) -> str:
|
||||
return hashlib.sha256(case_id.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _write_tar(source_dir: pathlib.Path, tar_path: pathlib.Path) -> None:
|
||||
tar_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tarfile.open(tar_path, "w:gz") as archive:
|
||||
for file in sorted(path for path in source_dir.rglob("*") if path.is_file()):
|
||||
archive.add(file, arcname=file.relative_to(source_dir).as_posix())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run Rekor negative path suite.")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=pathlib.Path,
|
||||
default=pathlib.Path("out/supply-chain/03-rekor-neg"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
start = time.perf_counter()
|
||||
output = args.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
diagnostics_root = output / "diagnostics"
|
||||
diagnostics_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cases = default_cases()
|
||||
junit_cases: list[TestCaseResult] = []
|
||||
report_cases: list[dict[str, object]] = []
|
||||
failures = 0
|
||||
|
||||
for case in cases:
|
||||
case_start = time.perf_counter()
|
||||
status, body, headers = simulate_submit(case)
|
||||
code, reprocess = _classify(case, status, body)
|
||||
expected = case.expected_code
|
||||
passed = code == expected
|
||||
if not passed:
|
||||
failures += 1
|
||||
|
||||
case_token = reprocess or _token(case.case_id)
|
||||
diagnostic = {
|
||||
"caseId": case.case_id,
|
||||
"upstream": {
|
||||
"statusCode": status,
|
||||
"body": body,
|
||||
"headers": headers,
|
||||
},
|
||||
"machineReadableErrorClass": code,
|
||||
"expectedErrorClass": expected,
|
||||
"reprocessToken": case_token,
|
||||
}
|
||||
|
||||
case_dir = diagnostics_root / case.case_id
|
||||
case_dir.mkdir(parents=True, exist_ok=True)
|
||||
(case_dir / "diagnostic_blob.json").write_text(
|
||||
json.dumps(diagnostic, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report_cases.append(
|
||||
{
|
||||
"caseId": case.case_id,
|
||||
"statusCode": status,
|
||||
"entryType": case.entry_type,
|
||||
"machineReadableErrorClass": code,
|
||||
"expectedErrorClass": expected,
|
||||
"reprocessToken": case_token,
|
||||
"passed": passed,
|
||||
}
|
||||
)
|
||||
junit_cases.append(
|
||||
TestCaseResult(
|
||||
suite="03-rekor-neg",
|
||||
name=case.case_id,
|
||||
passed=passed,
|
||||
duration_seconds=time.perf_counter() - case_start,
|
||||
failure_message=None if passed else f"expected={expected} actual={code}",
|
||||
)
|
||||
)
|
||||
|
||||
_write_tar(diagnostics_root, output / "rekor_negative_cases.tar.gz")
|
||||
report = {
|
||||
"durationSeconds": round(time.perf_counter() - start, 4),
|
||||
"failures": failures,
|
||||
"cases": report_cases,
|
||||
"machineReadableErrorClasses": sorted(
|
||||
{
|
||||
"payload_too_large",
|
||||
"unsupported_entry_type",
|
||||
"failed_dependency",
|
||||
"upstream_timeout",
|
||||
"reprocess_pending",
|
||||
}
|
||||
),
|
||||
}
|
||||
(output / "report.json").write_text(json.dumps(report, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
write_junit(output / "junit.xml", junit_cases)
|
||||
return 0 if failures == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
164
tests/supply-chain/04-big-dsse-referrers/run_big_cases.py
Normal file
164
tests/supply-chain/04-big-dsse-referrers/run_big_cases.py
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Large DSSE payload and OCI referrer edge-case deterministic suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import tarfile
|
||||
import time
|
||||
|
||||
import sys
|
||||
|
||||
TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "tools"
|
||||
sys.path.insert(0, str(TOOLS_DIR))
|
||||
from emit_artifacts import TestCaseResult, write_junit # noqa: E402
|
||||
|
||||
MAX_ACCEPTED_BYTES = 50 * 1024 * 1024
|
||||
|
||||
|
||||
def _reprocess_token(case_id: str) -> str:
|
||||
return hashlib.sha256(case_id.encode("utf-8")).hexdigest()[:20]
|
||||
|
||||
|
||||
def _evaluate_big_payload(case_id: str, payload_size_bytes: int) -> dict[str, object]:
|
||||
if payload_size_bytes > MAX_ACCEPTED_BYTES:
|
||||
return {
|
||||
"caseId": case_id,
|
||||
"result": "rejected",
|
||||
"machineReadableErrorClass": "payload_too_large",
|
||||
"state": "unknown_state",
|
||||
"reprocessToken": _reprocess_token(case_id),
|
||||
}
|
||||
return {
|
||||
"caseId": case_id,
|
||||
"result": "accepted",
|
||||
"machineReadableErrorClass": "none",
|
||||
"state": "verified",
|
||||
"reprocessToken": None,
|
||||
}
|
||||
|
||||
|
||||
def _evaluate_referrer_case(case_id: str, issue: str) -> dict[str, object]:
|
||||
mapping = {
|
||||
"dangling": "missing_subject",
|
||||
"invalid_media_type": "invalid_media_type",
|
||||
"cycle": "referrer_cycle_detected",
|
||||
"missing_symbol_bundle": "missing_symbol_bundle",
|
||||
}
|
||||
error_class = mapping[issue]
|
||||
return {
|
||||
"caseId": case_id,
|
||||
"result": "rejected",
|
||||
"machineReadableErrorClass": error_class,
|
||||
"state": "unknown_state",
|
||||
"reprocessToken": _reprocess_token(case_id),
|
||||
}
|
||||
|
||||
|
||||
def _write_tar(source_dir: pathlib.Path, tar_path: pathlib.Path) -> None:
|
||||
tar_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tarfile.open(tar_path, "w:gz") as archive:
|
||||
for file in sorted(path for path in source_dir.rglob("*") if path.is_file()):
|
||||
archive.add(file, arcname=file.relative_to(source_dir).as_posix())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run deterministic large DSSE/referrer suite.")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=pathlib.Path,
|
||||
default=pathlib.Path("out/supply-chain/04-big-dsse-referrers"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
output = args.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
case_root = output / "cases"
|
||||
case_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
big_payload_cases = [
|
||||
("dsse-100mb", 100 * 1024 * 1024),
|
||||
("dsse-250mb", 250 * 1024 * 1024),
|
||||
("dsse-1gb", 1024 * 1024 * 1024),
|
||||
]
|
||||
referrer_cases = [
|
||||
("referrer-dangling", "dangling"),
|
||||
("referrer-invalid-media-type", "invalid_media_type"),
|
||||
("referrer-cycle", "cycle"),
|
||||
("referrer-missing-symbol-bundle", "missing_symbol_bundle"),
|
||||
]
|
||||
|
||||
results: list[dict[str, object]] = []
|
||||
junit_cases: list[TestCaseResult] = []
|
||||
failures = 0
|
||||
|
||||
for case_id, size_bytes in big_payload_cases:
|
||||
case_start = time.perf_counter()
|
||||
result = _evaluate_big_payload(case_id, size_bytes)
|
||||
passed = result["result"] == "rejected" and result["state"] == "unknown_state"
|
||||
if not passed:
|
||||
failures += 1
|
||||
(case_root / f"{case_id}.json").write_text(
|
||||
json.dumps(result, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
results.append(result)
|
||||
junit_cases.append(
|
||||
TestCaseResult(
|
||||
suite="04-big-dsse-referrers",
|
||||
name=case_id,
|
||||
passed=passed,
|
||||
duration_seconds=time.perf_counter() - case_start,
|
||||
failure_message=None if passed else "payload case was not gracefully rejected",
|
||||
)
|
||||
)
|
||||
|
||||
for case_id, issue in referrer_cases:
|
||||
case_start = time.perf_counter()
|
||||
result = _evaluate_referrer_case(case_id, issue)
|
||||
passed = result["result"] == "rejected" and result["state"] == "unknown_state"
|
||||
if not passed:
|
||||
failures += 1
|
||||
(case_root / f"{case_id}.json").write_text(
|
||||
json.dumps(result, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
results.append(result)
|
||||
junit_cases.append(
|
||||
TestCaseResult(
|
||||
suite="04-big-dsse-referrers",
|
||||
name=case_id,
|
||||
passed=passed,
|
||||
duration_seconds=time.perf_counter() - case_start,
|
||||
failure_message=None if passed else "referrer case was not gracefully rejected",
|
||||
)
|
||||
)
|
||||
|
||||
_write_tar(case_root, output / "big_dsse_payloads.tar.gz")
|
||||
|
||||
report = {
|
||||
"durationSeconds": round(time.perf_counter() - start, 4),
|
||||
"failures": failures,
|
||||
"results": results,
|
||||
"machineReadableErrorClasses": sorted(
|
||||
{
|
||||
"payload_too_large",
|
||||
"missing_subject",
|
||||
"invalid_media_type",
|
||||
"referrer_cycle_detected",
|
||||
"missing_symbol_bundle",
|
||||
}
|
||||
),
|
||||
}
|
||||
(output / "report.json").write_text(json.dumps(report, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
write_junit(output / "junit.xml", junit_cases)
|
||||
return 0 if failures == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
25
tests/supply-chain/05-corpus/README.md
Normal file
25
tests/supply-chain/05-corpus/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Supply-Chain Fuzz Corpus
|
||||
|
||||
This corpus is the deterministic seed set for `tests/supply-chain`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `fixtures/sboms/`: CycloneDX-like SBOM samples used for JCS and mutation lanes.
|
||||
- `fixtures/attestations/`: DSSE envelope examples.
|
||||
- `fixtures/vex/`: OpenVEX-like samples.
|
||||
- `fixtures/malformed/`: intentionally malformed JSON payloads.
|
||||
|
||||
## Update Procedure (Deterministic)
|
||||
|
||||
1. Add new fixture files under the correct `fixtures/*` directory.
|
||||
2. Keep file names stable and monotonic (`*-001`, `*-002`, ...).
|
||||
3. Regenerate archive manifest with:
|
||||
- `python tests/supply-chain/05-corpus/build_corpus_archive.py --output out/supply-chain/05-corpus`
|
||||
4. Run suite smoke profile:
|
||||
- `python tests/supply-chain/run_suite.py --profile smoke --seed 20260226`
|
||||
5. If a crash is fixed, add the minimized repro fixture before merge.
|
||||
|
||||
## Notes
|
||||
|
||||
- No network I/O is required to consume this corpus.
|
||||
- All lane scripts use fixed seed defaults to keep replay deterministic.
|
||||
86
tests/supply-chain/05-corpus/build_corpus_archive.py
Normal file
86
tests/supply-chain/05-corpus/build_corpus_archive.py
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create deterministic archive metadata for supply-chain fuzz corpus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import tarfile
|
||||
|
||||
EPOCH = 1_706_300_800 # 2024-01-01T00:00:00Z
|
||||
|
||||
|
||||
def _deterministic_tarinfo(name: str, data: bytes) -> tarfile.TarInfo:
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
info.mtime = EPOCH
|
||||
info.mode = 0o644
|
||||
info.uid = 0
|
||||
info.gid = 0
|
||||
info.uname = "root"
|
||||
info.gname = "root"
|
||||
return info
|
||||
|
||||
|
||||
def _sha256(path: pathlib.Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(64 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Build deterministic fuzz corpus archive.")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=pathlib.Path,
|
||||
default=pathlib.Path("out/supply-chain/05-corpus"),
|
||||
help="Output directory for archive and manifest.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parent
|
||||
fixtures_root = root / "fixtures"
|
||||
output = args.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
archive_path = output / "fuzz-corpus-v1.tar.gz"
|
||||
manifest_path = output / "fuzz-corpus-v1.manifest.json"
|
||||
|
||||
files = sorted(path for path in fixtures_root.rglob("*") if path.is_file())
|
||||
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
for path in files:
|
||||
rel = path.relative_to(root).as_posix()
|
||||
data = path.read_bytes()
|
||||
info = _deterministic_tarinfo(rel, data)
|
||||
with path.open("rb") as handle:
|
||||
archive.addfile(info, fileobj=handle)
|
||||
|
||||
manifest = {
|
||||
"archive": archive_path.name,
|
||||
"fileCount": len(files),
|
||||
"files": [
|
||||
{
|
||||
"path": path.relative_to(root).as_posix(),
|
||||
"sha256": _sha256(path),
|
||||
"sizeBytes": path.stat().st_size,
|
||||
}
|
||||
for path in files
|
||||
],
|
||||
}
|
||||
|
||||
with manifest_path.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(manifest, handle, sort_keys=True, indent=2)
|
||||
handle.write("\n")
|
||||
|
||||
print(f"archive={archive_path}")
|
||||
print(f"manifest={manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"payloadType": "application/vnd.in-toto+json",
|
||||
"payload": "eyJzdGF0ZW1lbnRUeXBlIjoiaW4tdG90byIsInN1YmplY3QiOlt7Im5hbWUiOiJzZXJ2aWNlLWEiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWFhYSJ9fV19",
|
||||
"signatures": [
|
||||
{
|
||||
"keyid": "test-ed25519",
|
||||
"sig": "MEUCIQDc3Hg4uY7jL5IyGmW1P2JX1i2fQf3m9QhXw5q5Xg9fSgIgY6c1v3Shy8qv8p9w2u5iX9p8VQb4Hf0yJ4j9QhN4Vtw="
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.5",
|
||||
"specVersion": "1.4"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.5",
|
||||
"serialNumber": "urn:uuid:00000000-0000-0000-0000-000000000003",
|
||||
"metadata": {
|
||||
"timestamp": "2026-02-22T00:00:00Z",
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "service-c",
|
||||
"version": "5.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{ "type": "library", "name": "a", "version": "1.0.0", "purl": "pkg:generic/a@1.0.0" },
|
||||
{ "type": "library", "name": "b", "version": "1.0.0", "purl": "pkg:generic/b@1.0.0" },
|
||||
{ "type": "library", "name": "c", "version": "1.0.0", "purl": "pkg:generic/c@1.0.0" },
|
||||
{ "type": "library", "name": "d", "version": "1.0.0", "purl": "pkg:generic/d@1.0.0" },
|
||||
{ "type": "library", "name": "e", "version": "1.0.0", "purl": "pkg:generic/e@1.0.0" }
|
||||
],
|
||||
"dependencies": [
|
||||
{ "ref": "pkg:generic/a@1.0.0", "dependsOn": [ "pkg:generic/b@1.0.0", "pkg:generic/c@1.0.0" ] },
|
||||
{ "ref": "pkg:generic/b@1.0.0", "dependsOn": [ "pkg:generic/d@1.0.0" ] },
|
||||
{ "ref": "pkg:generic/c@1.0.0", "dependsOn": [ "pkg:generic/d@1.0.0", "pkg:generic/e@1.0.0" ] },
|
||||
{ "ref": "pkg:generic/d@1.0.0", "dependsOn": [] },
|
||||
{ "ref": "pkg:generic/e@1.0.0", "dependsOn": [] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.5",
|
||||
"serialNumber": "urn:uuid:00000000-0000-0000-0000-000000000002",
|
||||
"metadata": {
|
||||
"timestamp": "2026-02-22T00:00:00Z",
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "service-b",
|
||||
"version": "2.4.1"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "nginx",
|
||||
"version": "1.25.4",
|
||||
"purl": "pkg:generic/nginx@1.25.4"
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"name": "zlib",
|
||||
"version": "1.3.1",
|
||||
"purl": "pkg:generic/zlib@1.3.1"
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"name": "libxml2",
|
||||
"version": "2.12.7",
|
||||
"purl": "pkg:generic/libxml2@2.12.7"
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{
|
||||
"ref": "pkg:generic/nginx@1.25.4",
|
||||
"dependsOn": [
|
||||
"pkg:generic/zlib@1.3.1",
|
||||
"pkg:generic/libxml2@2.12.7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ref": "pkg:generic/zlib@1.3.1",
|
||||
"dependsOn": []
|
||||
},
|
||||
{
|
||||
"ref": "pkg:generic/libxml2@2.12.7",
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.5",
|
||||
"serialNumber": "urn:uuid:00000000-0000-0000-0000-000000000001",
|
||||
"metadata": {
|
||||
"timestamp": "2026-02-22T00:00:00Z",
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "service-a",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "openssl",
|
||||
"version": "3.0.2",
|
||||
"purl": "pkg:generic/openssl@3.0.2"
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{
|
||||
"ref": "pkg:generic/openssl@3.0.2",
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
}
|
||||
16
tests/supply-chain/05-corpus/fixtures/vex/vex-001.json
Normal file
16
tests/supply-chain/05-corpus/fixtures/vex/vex-001.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"@context": "https://openvex.dev/ns/v0.2.0",
|
||||
"author": "stella-ops",
|
||||
"timestamp": "2026-02-22T00:00:00Z",
|
||||
"version": 1,
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": "CVE-2026-1111",
|
||||
"products": [
|
||||
"pkg:oci/service-a@1.0.0"
|
||||
],
|
||||
"status": "under_investigation",
|
||||
"justification": "component_not_present"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
tests/supply-chain/Makefile
Normal file
12
tests/supply-chain/Makefile
Normal file
@@ -0,0 +1,12 @@
|
||||
.PHONY: test smoke nightly corpus
|
||||
|
||||
test: smoke
|
||||
|
||||
smoke:
|
||||
python tests/supply-chain/run_suite.py --profile smoke --seed 20260226
|
||||
|
||||
nightly:
|
||||
python tests/supply-chain/run_suite.py --profile nightly --seed 20260226
|
||||
|
||||
corpus:
|
||||
python tests/supply-chain/05-corpus/build_corpus_archive.py --output out/supply-chain/05-corpus
|
||||
47
tests/supply-chain/README.md
Normal file
47
tests/supply-chain/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Supply-Chain Hardening Suite
|
||||
|
||||
Deterministic, offline-safe hardening lanes for canonicalization, mutation fuzzing, Rekor negative paths, and large DSSE/referrer rejection behavior.
|
||||
|
||||
## Lanes
|
||||
|
||||
- `01-jcs-property`: canonicalization idempotence/permutation checks + duplicate-key rejection.
|
||||
- `02-schema-fuzz`: bounded mutation lane with deterministic seed and crash artifact emission.
|
||||
- `03-rekor-neg`: deterministic Rekor fault classification + diagnostic blob generation.
|
||||
- `04-big-dsse-referrers`: oversized DSSE + malformed referrer graceful reject tests.
|
||||
- `05-corpus`: deterministic fixture corpus and archive manifest builder.
|
||||
|
||||
## Run
|
||||
|
||||
- Linux/macOS:
|
||||
- `bash tests/supply-chain/run.sh smoke`
|
||||
- PowerShell:
|
||||
- `pwsh tests/supply-chain/run.ps1 -Profile smoke`
|
||||
- Direct:
|
||||
- `python tests/supply-chain/run_suite.py --profile smoke --seed 20260226`
|
||||
|
||||
## Profiles
|
||||
|
||||
- `smoke`: CI PR gate (`02-schema-fuzz` limit=1000, time=60s).
|
||||
- `nightly`: scheduled lane (`02-schema-fuzz` limit=5000, time=300s).
|
||||
|
||||
## Pass/Fail Gates
|
||||
|
||||
- JCS lane: zero invariant failures.
|
||||
- Fuzz lane: zero `crash` classifications.
|
||||
- Rekor negative lane: all cases return expected deterministic error classes.
|
||||
- Big DSSE/referrers lane: malformed/oversized cases are rejected with `unknown_state` and `reprocessToken`.
|
||||
|
||||
## Failure Artifacts
|
||||
|
||||
Each lane writes machine-readable artifacts under `out/supply-chain/<lane>/`.
|
||||
|
||||
- `junit.xml`: CI-visible test result summary.
|
||||
- `report.json` / `summary.json`: deterministic counters and classifications.
|
||||
- `failures/<case>/diagnostic_blob.json`: replay-ready diagnostics.
|
||||
- `hypothesis_seed.txt`: deterministic seed (name retained for familiarity).
|
||||
|
||||
## Replay
|
||||
|
||||
To replay a failing smoke run:
|
||||
|
||||
`python tests/supply-chain/run_suite.py --profile smoke --seed 20260226 --output out/supply-chain-replay`
|
||||
13
tests/supply-chain/run.ps1
Normal file
13
tests/supply-chain/run.ps1
Normal file
@@ -0,0 +1,13 @@
|
||||
param(
|
||||
[ValidateSet("smoke", "nightly")]
|
||||
[string]$Profile = "smoke",
|
||||
[int]$Seed = 20260226,
|
||||
[string]$Output = "out/supply-chain"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
python tests/supply-chain/run_suite.py `
|
||||
--profile $Profile `
|
||||
--seed $Seed `
|
||||
--output $Output
|
||||
11
tests/supply-chain/run.sh
Normal file
11
tests/supply-chain/run.sh
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROFILE="${1:-smoke}"
|
||||
SEED="${SUPPLY_CHAIN_SEED:-20260226}"
|
||||
OUT_DIR="${SUPPLY_CHAIN_OUT_DIR:-out/supply-chain}"
|
||||
|
||||
python tests/supply-chain/run_suite.py \
|
||||
--profile "${PROFILE}" \
|
||||
--seed "${SEED}" \
|
||||
--output "${OUT_DIR}"
|
||||
123
tests/supply-chain/run_suite.py
Normal file
123
tests/supply-chain/run_suite.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Orchestrate deterministic supply-chain hardening lanes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def _python() -> str:
|
||||
return sys.executable
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: pathlib.Path) -> int:
|
||||
completed = subprocess.run(cmd, cwd=str(cwd), check=False)
|
||||
return completed.returncode
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run supply-chain hardening suite.")
|
||||
parser.add_argument("--seed", type=int, default=20260226)
|
||||
parser.add_argument("--profile", choices=["smoke", "nightly"], default="smoke")
|
||||
parser.add_argument("--output", type=pathlib.Path, default=pathlib.Path("out/supply-chain"))
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = pathlib.Path(__file__).resolve().parents[2]
|
||||
output_root = args.output.resolve()
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.profile == "smoke":
|
||||
fuzz_limit = 1000
|
||||
fuzz_seconds = 60
|
||||
else:
|
||||
fuzz_limit = 5000
|
||||
fuzz_seconds = 300
|
||||
|
||||
lanes = [
|
||||
(
|
||||
"01-jcs-property",
|
||||
[
|
||||
_python(),
|
||||
"tests/supply-chain/01-jcs-property/test_jcs.py",
|
||||
"--seed",
|
||||
str(args.seed),
|
||||
"--output",
|
||||
str(output_root / "01-jcs-property"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"02-schema-fuzz",
|
||||
[
|
||||
_python(),
|
||||
"tests/supply-chain/02-schema-fuzz/run_mutations.py",
|
||||
"--seed",
|
||||
str(args.seed),
|
||||
"--limit",
|
||||
str(fuzz_limit),
|
||||
"--time-seconds",
|
||||
str(fuzz_seconds),
|
||||
"--output",
|
||||
str(output_root / "02-schema-fuzz"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"03-rekor-neg",
|
||||
[
|
||||
_python(),
|
||||
"tests/supply-chain/03-rekor-neg/run_negative_suite.py",
|
||||
"--output",
|
||||
str(output_root / "03-rekor-neg"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"04-big-dsse-referrers",
|
||||
[
|
||||
_python(),
|
||||
"tests/supply-chain/04-big-dsse-referrers/run_big_cases.py",
|
||||
"--output",
|
||||
str(output_root / "04-big-dsse-referrers"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"05-corpus-archive",
|
||||
[
|
||||
_python(),
|
||||
"tests/supply-chain/05-corpus/build_corpus_archive.py",
|
||||
"--output",
|
||||
str(output_root / "05-corpus"),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
start = time.perf_counter()
|
||||
lane_results: list[dict[str, object]] = []
|
||||
|
||||
for lane_name, command in lanes:
|
||||
print(f"[supply-chain] lane={lane_name} command={' '.join(command)}")
|
||||
return_code = _run(command, repo_root)
|
||||
lane_results.append(
|
||||
{
|
||||
"lane": lane_name,
|
||||
"returnCode": return_code,
|
||||
"status": "pass" if return_code == 0 else "fail",
|
||||
}
|
||||
)
|
||||
|
||||
summary = {
|
||||
"profile": args.profile,
|
||||
"seed": args.seed,
|
||||
"durationSeconds": round(time.perf_counter() - start, 4),
|
||||
"lanes": lane_results,
|
||||
}
|
||||
(output_root / "summary.json").write_text(json.dumps(summary, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
failures = [lane for lane in lane_results if lane["returnCode"] != 0]
|
||||
return 0 if not failures else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
Binary file not shown.
64
tests/supply-chain/tools/canonicalize_json.py
Normal file
64
tests/supply-chain/tools/canonicalize_json.py
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic JSON parsing and canonicalization helpers for supply-chain tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
class DuplicateKeyError(ValueError):
|
||||
"""Raised when JSON object contains duplicate keys."""
|
||||
|
||||
|
||||
def _strict_object_pairs_hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
seen: set[str] = set()
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in seen:
|
||||
raise DuplicateKeyError(f"Duplicate key detected: {key}")
|
||||
seen.add(key)
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def parse_json_strict(text: str) -> Any:
|
||||
"""Parse JSON and reject duplicate keys deterministically."""
|
||||
return json.loads(text, object_pairs_hook=_strict_object_pairs_hook)
|
||||
|
||||
|
||||
def canonicalize_value(value: Any) -> str:
|
||||
"""
|
||||
Canonicalize JSON value with deterministic ordering.
|
||||
|
||||
This is a strict deterministic serializer used for test invariants.
|
||||
"""
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def canonicalize_text(text: str) -> str:
|
||||
"""Parse and canonicalize a JSON document."""
|
||||
return canonicalize_value(parse_json_strict(text))
|
||||
|
||||
|
||||
def sha256_hex(value: str) -> str:
|
||||
"""Compute hex SHA-256 digest for canonical payload tracking."""
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanonicalResult:
|
||||
canonical_json: str
|
||||
sha256: str
|
||||
|
||||
|
||||
def canonical_result_from_text(text: str) -> CanonicalResult:
|
||||
canonical = canonicalize_text(text)
|
||||
return CanonicalResult(canonical_json=canonical, sha256=sha256_hex(canonical))
|
||||
102
tests/supply-chain/tools/emit_artifacts.py
Normal file
102
tests/supply-chain/tools/emit_artifacts.py
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Artifact and JUnit emitters for deterministic supply-chain hardening lanes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import xml.etree.ElementTree as et
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
def _write_json(path: pathlib.Path, payload: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(payload, handle, sort_keys=True, indent=2, ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
|
||||
|
||||
def _write_text(path: pathlib.Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestCaseResult:
|
||||
suite: str
|
||||
name: str
|
||||
passed: bool
|
||||
duration_seconds: float = 0.0
|
||||
failure_message: str | None = None
|
||||
|
||||
|
||||
def write_junit(path: pathlib.Path, test_cases: Iterable[TestCaseResult]) -> None:
|
||||
cases = list(test_cases)
|
||||
failures = sum(0 if case.passed else 1 for case in cases)
|
||||
|
||||
suite = et.Element(
|
||||
"testsuite",
|
||||
attrib={
|
||||
"name": "supply-chain-hardening",
|
||||
"tests": str(len(cases)),
|
||||
"failures": str(failures),
|
||||
"errors": "0",
|
||||
"skipped": "0",
|
||||
},
|
||||
)
|
||||
|
||||
for case in sorted(cases, key=lambda item: (item.suite, item.name)):
|
||||
node = et.SubElement(
|
||||
suite,
|
||||
"testcase",
|
||||
attrib={
|
||||
"classname": case.suite,
|
||||
"name": case.name,
|
||||
"time": f"{case.duration_seconds:.3f}",
|
||||
},
|
||||
)
|
||||
if not case.passed:
|
||||
failure = et.SubElement(node, "failure", attrib={"type": "assertion"})
|
||||
failure.text = case.failure_message or "failure"
|
||||
|
||||
tree = et.ElementTree(suite)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tree.write(path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def record_failure(
|
||||
*,
|
||||
lane_output_dir: pathlib.Path,
|
||||
case_id: str,
|
||||
seed: int,
|
||||
payload_text: str,
|
||||
error_class: str,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
canonical_diff_patch: str | None = None,
|
||||
) -> pathlib.Path:
|
||||
"""
|
||||
Write deterministic failure artifacts for replay.
|
||||
|
||||
Returns the failure directory path.
|
||||
"""
|
||||
failure_dir = lane_output_dir / "failures" / case_id
|
||||
failure_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_write_text(failure_dir / "failing_case.json", payload_text)
|
||||
_write_text(failure_dir / "hypothesis_seed.txt", f"{seed}\n")
|
||||
|
||||
diagnostic_payload: dict[str, Any] = {
|
||||
"caseId": case_id,
|
||||
"errorClass": error_class,
|
||||
"message": message,
|
||||
}
|
||||
if details:
|
||||
diagnostic_payload["details"] = details
|
||||
_write_json(failure_dir / "diagnostic_blob.json", diagnostic_payload)
|
||||
|
||||
if canonical_diff_patch is not None:
|
||||
_write_text(failure_dir / "canonical_diff.patch", canonical_diff_patch)
|
||||
|
||||
return failure_dir
|
||||
Reference in New Issue
Block a user