* Reduce and tighten comments and docstrings in tests Shorten verbose comments and docstrings across the test suite without changing any test logic. Remove narration that restates the next line, collapse long module and test docstrings to a single line, and drop banner separators. Keep regression context (issue and PR references, run ids), skip reasons, mocking and timing rationale, license headers, lint and type directives, and commented-out code. Comments and docstrings only: an AST signature check confirms no code, assertions, or string literals changed, and the suite byte-compiles cleanly. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
334 lines
12 KiB
Python
334 lines
12 KiB
Python
"""Regression tests for the offline `scripts/lockfile_supply_chain_audit.py`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
SCRIPT = REPO_ROOT / "scripts" / "lockfile_supply_chain_audit.py"
|
|
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
|
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
from scripts import lockfile_supply_chain_audit as lsa # noqa: E402
|
|
|
|
|
|
def _run_auditor(
|
|
*,
|
|
root: Path,
|
|
npm_lockfiles: list[Path] | None = None,
|
|
cargo_lockfiles: list[Path] | None = None,
|
|
strict: bool = False,
|
|
timeout: int = 30,
|
|
) -> subprocess.CompletedProcess:
|
|
cmd = [sys.executable, str(SCRIPT), "--root", str(root)]
|
|
if strict:
|
|
cmd.append("--strict")
|
|
for p in npm_lockfiles or []:
|
|
cmd.extend(["--npm-lockfile", str(p)])
|
|
for p in cargo_lockfiles or []:
|
|
cmd.extend(["--cargo-lockfile", str(p)])
|
|
return subprocess.run(
|
|
cmd,
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = timeout,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# npm lockfile audit.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_malicious_lockfile_exits_1(tmp_path):
|
|
"""Non-registry URL + IOC substring + missing integrity hash -> auditor exits 1."""
|
|
fixture = FIXTURES / "malicious_lockfile.json"
|
|
assert fixture.is_file()
|
|
proc = _run_auditor(root = tmp_path, npm_lockfiles = [fixture])
|
|
assert proc.returncode == 1, (
|
|
f"expected exit 1, got {proc.returncode}\n"
|
|
f"--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}"
|
|
)
|
|
combined = proc.stdout + proc.stderr
|
|
assert "non-registry-resolved-url" in combined
|
|
assert "missing-integrity-hash" in combined
|
|
assert "known-ioc-string" in combined
|
|
# IOC literal built at runtime so CodeQL's
|
|
# py/incomplete-url-substring-sanitization rule doesn't false-positive on
|
|
# the source-literal + `in` (the operand is the scanner's own output).
|
|
_ioc_host = "filev2." + "getsession.org"
|
|
assert _ioc_host in combined
|
|
|
|
|
|
def test_clean_lockfile_exits_0(tmp_path):
|
|
fixture = FIXTURES / "clean_lockfile.json"
|
|
proc = _run_auditor(root = tmp_path, npm_lockfiles = [fixture])
|
|
assert proc.returncode == 0, (
|
|
f"expected exit 0, got {proc.returncode}\n"
|
|
f"--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}"
|
|
)
|
|
assert "0 findings" in proc.stdout
|
|
|
|
|
|
def test_audit_npm_lockfile_direct_call_findings():
|
|
"""In-process audit_npm_lockfile() returns the same findings as the subprocess."""
|
|
findings = lsa.audit_npm_lockfile(FIXTURES / "malicious_lockfile.json")
|
|
kinds = {f.kind for f in findings}
|
|
assert "non-registry-resolved-url" in kinds
|
|
assert "missing-integrity-hash" in kinds
|
|
assert "known-ioc-string" in kinds
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# IOC string table -- gated on Fork 1's NPM_IOC_STRINGS additions.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_MAY12_IOCS = (
|
|
"git-tanstack.com",
|
|
"transformers.pyz",
|
|
"/tmp/transformers.pyz",
|
|
"With Love TeamPCP",
|
|
)
|
|
|
|
|
|
def test_npm_ioc_strings_contains_may11_baseline():
|
|
"""May-11 wave IOCs must remain in NPM_IOC_STRINGS (baseline)."""
|
|
iocs = set(lsa.NPM_IOC_STRINGS)
|
|
for needle in (
|
|
"router_init.js",
|
|
"tanstack_runner.js",
|
|
"router_runtime.js",
|
|
"filev2.getsession.org",
|
|
):
|
|
assert needle in iocs, f"baseline IOC {needle!r} disappeared"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not all(s in lsa.NPM_IOC_STRINGS for s in _MAY12_IOCS),
|
|
reason = "Fork 1 (May-12 IOC additions) not merged yet",
|
|
)
|
|
def test_npm_ioc_strings_contains_may12_additions():
|
|
iocs = set(lsa.NPM_IOC_STRINGS)
|
|
for needle in _MAY12_IOCS:
|
|
assert needle in iocs
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not hasattr(lsa, "BLOCKED_NPM_VERSIONS"),
|
|
reason = "Fork 1 (BLOCKED_NPM_VERSIONS in auditor) not merged yet",
|
|
)
|
|
def test_lockfile_auditor_blocked_versions_match_scanner():
|
|
"""Auditor's BLOCKED_NPM_VERSIONS must mirror the scanner's table verbatim."""
|
|
from scripts import scan_npm_packages as snp
|
|
assert (
|
|
lsa.BLOCKED_NPM_VERSIONS == snp.BLOCKED_NPM_VERSIONS
|
|
), "auditor and scanner BLOCKED_NPM_VERSIONS tables drifted"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cargo.lock audit.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_MALICIOUS_CARGO_LOCK = """\
|
|
version = 3
|
|
|
|
[[package]]
|
|
name = "fix-path-env"
|
|
version = "0.0.1"
|
|
source = "git+https://example.com/foo#deadbeef"
|
|
|
|
[[package]]
|
|
name = "honest-crate"
|
|
version = "1.0.0"
|
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
checksum = "0000000000000000000000000000000000000000000000000000000000000000"
|
|
"""
|
|
|
|
|
|
def test_malicious_cargo_lockfile_refused(tmp_path):
|
|
"""git+https:// Cargo source trips non-registry-cargo-source; --strict makes it blocking."""
|
|
lockfile = tmp_path / "Cargo.lock"
|
|
lockfile.write_text(_MALICIOUS_CARGO_LOCK)
|
|
proc = _run_auditor(
|
|
root = tmp_path,
|
|
npm_lockfiles = [FIXTURES / "clean_lockfile.json"],
|
|
cargo_lockfiles = [lockfile],
|
|
strict = True,
|
|
)
|
|
assert proc.returncode == 1
|
|
combined = proc.stdout + proc.stderr
|
|
assert "non-registry-cargo-source" in combined
|
|
assert "git+https://example.com" in combined
|
|
|
|
|
|
def test_malicious_cargo_lockfile_default_mode_advisory(tmp_path):
|
|
"""Default mode emits non-registry-cargo-source as advisory ::warning:: but exits 0."""
|
|
lockfile = tmp_path / "Cargo.lock"
|
|
lockfile.write_text(_MALICIOUS_CARGO_LOCK)
|
|
proc = _run_auditor(
|
|
root = tmp_path,
|
|
npm_lockfiles = [FIXTURES / "clean_lockfile.json"],
|
|
cargo_lockfiles = [lockfile],
|
|
)
|
|
assert proc.returncode == 0, (
|
|
f"expected exit 0 (advisory), got {proc.returncode}\n"
|
|
f"--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}"
|
|
)
|
|
combined = proc.stdout + proc.stderr
|
|
assert "non-registry-cargo-source" in combined
|
|
assert "advisory finding" in combined
|
|
|
|
|
|
def test_audit_cargo_lockfile_direct_call(tmp_path):
|
|
lockfile = tmp_path / "Cargo.lock"
|
|
lockfile.write_text(_MALICIOUS_CARGO_LOCK)
|
|
findings = lsa.audit_cargo_lockfile(lockfile)
|
|
kinds = {f.kind for f in findings}
|
|
assert "non-registry-cargo-source" in kinds
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GitHub Actions annotation escape: ::warning:: / ::error:: messages
|
|
# are truncated at the first newline unless escaped, so the multi-line
|
|
# Finding must be collapsed via the spec'd %0A / %0D / %25 encoding.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_gha_escape_collapses_finding_to_one_line():
|
|
"""_gha_escape() encodes \\n/\\r/% so GHA annotations aren't truncated; % must escape first."""
|
|
assert lsa._gha_escape("a\nb\nc") == "a%0Ab%0Ac"
|
|
assert lsa._gha_escape("a\rb") == "a%0Db"
|
|
assert lsa._gha_escape("100%") == "100%25"
|
|
# Order regression: `%` must escape before `\n`, else escapes double-encode.
|
|
assert lsa._gha_escape("a%b\nc") == "a%25b%0Ac"
|
|
|
|
f = lsa.Finding(
|
|
path = "/x/lock.json",
|
|
package = "node_modules/foo",
|
|
kind = "missing-integrity-hash",
|
|
detail = "bad stuff",
|
|
)
|
|
escaped = lsa._gha_escape(str(f))
|
|
assert "\n" not in escaped
|
|
assert "%0A" in escaped
|
|
assert "missing-integrity-hash" in escaped
|
|
assert "node_modules/foo" in escaped
|
|
assert "bad stuff" in escaped
|
|
|
|
|
|
def test_advisory_finding_emitted_as_single_line_annotation(tmp_path):
|
|
"""Advisory ::warning:: must be one physical line (%0A-escaped). Regression for PR #5604."""
|
|
lockfile = tmp_path / "Cargo.lock"
|
|
lockfile.write_text(_MALICIOUS_CARGO_LOCK)
|
|
proc = _run_auditor(
|
|
root = tmp_path,
|
|
npm_lockfiles = [FIXTURES / "clean_lockfile.json"],
|
|
cargo_lockfiles = [lockfile],
|
|
)
|
|
warning_lines = [line for line in proc.stderr.splitlines() if line.startswith("::warning::")]
|
|
assert warning_lines, (
|
|
"expected at least one ::warning:: annotation; " f"stderr was:\n{proc.stderr}"
|
|
)
|
|
for line in warning_lines:
|
|
# One physical line: kind/package/detail joined via %0A, not split.
|
|
assert "%0A" in line, (
|
|
f"::warning:: line has no %0A escape; multi-line text "
|
|
f"would be truncated by GH Actions:\n{line}"
|
|
)
|
|
assert "non-registry-cargo-source" in line
|
|
assert "package:" in line
|
|
assert "detail:" in line
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SF4: skip env var requires a justification value.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_skip_env_var_with_short_value_rejected(tmp_path):
|
|
"""SF4: a short/boolean UNSLOTH_LOCKFILE_AUDIT_SKIP is rejected; a real justification is honored."""
|
|
fixture = FIXTURES / "clean_lockfile.json"
|
|
|
|
# Case 1 -- "1" rejected, audit RUNS.
|
|
env_bad = {**os.environ, "UNSLOTH_LOCKFILE_AUDIT_SKIP": "1"}
|
|
proc_bad = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SCRIPT),
|
|
"--root",
|
|
str(tmp_path),
|
|
"--npm-lockfile",
|
|
str(fixture),
|
|
],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 30,
|
|
env = env_bad,
|
|
)
|
|
combined_bad = proc_bad.stdout + proc_bad.stderr
|
|
assert "::warning::" in combined_bad, combined_bad
|
|
assert "REQUIRES a justification" in combined_bad, combined_bad
|
|
# Per-file banner proves the audit ran.
|
|
assert "[lockfile-audit] npm:" in combined_bad, combined_bad
|
|
# Clean fixture -> exit 0, but the audit was performed.
|
|
assert proc_bad.returncode == 0, (
|
|
f"expected rc 0 on clean fixture, got {proc_bad.returncode}\n"
|
|
f"--- stdout ---\n{proc_bad.stdout}\n"
|
|
f"--- stderr ---\n{proc_bad.stderr}"
|
|
)
|
|
|
|
# Case 2 -- a real-looking justification accepted, audit skipped.
|
|
env_ok = {**os.environ, "UNSLOTH_LOCKFILE_AUDIT_SKIP": "ticket-5397"}
|
|
proc_ok = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SCRIPT),
|
|
"--root",
|
|
str(tmp_path),
|
|
"--npm-lockfile",
|
|
str(fixture),
|
|
],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 30,
|
|
env = env_ok,
|
|
)
|
|
combined_ok = proc_ok.stdout + proc_ok.stderr
|
|
assert proc_ok.returncode == 0
|
|
assert "::warning::" in combined_ok
|
|
assert "skipped" in combined_ok.lower()
|
|
assert "ticket-5397" in combined_ok
|
|
# Skip path: no "npm:" banner means the audit body never ran.
|
|
assert "[lockfile-audit] npm:" not in combined_ok, combined_ok
|
|
|
|
# Case 3 -- the booleanish tokens are ALL rejected.
|
|
for bad_val in ("true", "yes", "on", "0", ""):
|
|
env_b = {**os.environ, "UNSLOTH_LOCKFILE_AUDIT_SKIP": bad_val}
|
|
p = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SCRIPT),
|
|
"--root",
|
|
str(tmp_path),
|
|
"--npm-lockfile",
|
|
str(fixture),
|
|
],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 30,
|
|
env = env_b,
|
|
)
|
|
c = p.stdout + p.stderr
|
|
assert (
|
|
"::warning::" in c and "REQUIRES" in c
|
|
), f"value {bad_val!r} should have been rejected; got:\n{c}"
|
|
assert "[lockfile-audit] npm:" in c, (
|
|
f"value {bad_val!r} should have fallen through to run audit; " f"got:\n{c}"
|
|
)
|