* ci: add advisory lockfile supply-chain audit Adds a fast, focused workflow that scans every checked-in npm and cargo lockfile on PRs touching one. Default behaviour is advisory: only public indicator-of-compromise strings, versions on the public known-malicious list, and structurally broken lockfiles fail the build. Structural anomalies (missing integrity hashes, non-default registry, etc.) surface as :⚠️: annotations without gating merges, so reviewers see the audit result inline on every PR without changing the existing install behaviour. Also commits the two missing npm lockfiles the audit needs: studio/package-lock.json (Tauri CLI holder for desktop release) and studio/backend/core/data_recipe/oxc-validator/package-lock.json (oxc-parser runtime for the data-recipe validator). studio/setup.sh, studio/setup.ps1, build.sh, and pyproject.toml are intentionally left alone so the existing install path keeps working unchanged. Audit script behaviour: default mode -> exits 1 only on blocked-known-malicious, known-ioc-string, malformed-lockfile, missing-lockfile, unreadable-lockfile, or missing-toml-parser --strict -> promotes every finding to blocking (opt-in) Adds a try/except around lockfile reads so a permissions error prints a finding instead of crashing CI with a raw traceback. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * test(security): update cargo regression test for advisory mode `scripts/lockfile_supply_chain_audit.py` now classifies `non-registry-cargo-source` as an advisory finding by default (returns exit 0 with a `:⚠️:` annotation) rather than unconditionally blocking with exit 1. Update the existing `test_malicious_cargo_lockfile_refused` to pass --strict so it keeps verifying the "refuse to install" behavior it is named for, and add a second test that pins the default-mode behavior: advisory finding emitted, exit code 0. * audit: escape Finding for GH Actions annotations `:⚠️:` and `::error::` workflow commands truncate the annotation message at the first newline unless the message is %-encoded per the workflow-commands spec. Since `Finding.__str__` returns three lines (kind+path, package, detail), the package and detail fields were being dropped from the GitHub Actions UI. Add a `_gha_escape()` helper that applies the spec'd escapes (`%` -> `%25`, then `\r` -> `%0D`, then `\n` -> `%0A`; the `%` replacement must happen first so the subsequent escapes are not double-encoded), wrap every Finding rendered into a workflow command with it, and pin both the helper and the end-to-end single-line emission with two new regression tests. Caught by gemini-code-assist on PR #5604. * [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>
384 lines
14 KiB
Python
384 lines
14 KiB
Python
"""Regression tests for `scripts/lockfile_supply_chain_audit.py`.
|
|
|
|
The auditor is fully offline (file reads only); tests run the script
|
|
as a subprocess against the fixture lockfiles plus an inline
|
|
`Cargo.lock` constructed in a tmpdir.
|
|
"""
|
|
|
|
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):
|
|
"""The malicious fixture combines a non-registry resolved URL, a
|
|
known IOC substring (`filev2.getsession.org`), and a missing
|
|
integrity hash. The auditor must refuse with exit 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
|
|
# Verify the scanner WROTE the IOC name into its stdout/stderr. The
|
|
# literal is constructed at runtime so CodeQL's
|
|
# py/incomplete-url-substring-sanitization rule (which fires on
|
|
# source-literal + `in` even when the operand is the scanner's own
|
|
# output, not a URL being sanitized) does not false-positive across
|
|
# pre-commit reformatting that may split the assert onto multiple
|
|
# lines and detach an inline lgtm comment from the operator.
|
|
_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 call to `audit_npm_lockfile()` returns the same
|
|
finding shape we expect the subprocess to emit.
|
|
"""
|
|
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():
|
|
"""The auditor's BLOCKED_NPM_VERSIONS must mirror the scanner's
|
|
table verbatim (Fork 1's plan says to duplicate with a sync
|
|
comment until the next PR factors them into a shared module).
|
|
"""
|
|
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):
|
|
"""Inline Cargo.lock with `source = "git+https://example.com/..."`
|
|
must trip the `non-registry-cargo-source` check.
|
|
|
|
`non-registry-cargo-source` is an advisory finding kind in the
|
|
auditor's default mode (per the audit script's BLOCKING_KINDS
|
|
set). To exercise the historical "refuse to install" behavior we
|
|
pass --strict here; that promotes every finding to blocking and
|
|
keeps the test honest about its intent (detection + refusal).
|
|
"""
|
|
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 (non-strict) mode classifies `non-registry-cargo-source`
|
|
as advisory: the finding is still emitted as a `::warning::`
|
|
annotation but the process exits 0 so the build is not gated.
|
|
Regression test for the advisory/strict split.
|
|
"""
|
|
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()` must collapse newlines (`%0A`), carriage
|
|
returns (`%0D`), and percent signs (`%25`) so that
|
|
`::warning::<msg>` / `::error::<msg>` render the full finding
|
|
in the GitHub Actions UI annotation instead of being truncated
|
|
at the first newline. The `%` replacement must happen first or
|
|
the subsequent `%0A` / `%0D` escapes get double-encoded.
|
|
"""
|
|
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` so the literal
|
|
# text `a%b\nc` becomes `a%25b%0Ac`, not `a%250Ab%0Ac`.
|
|
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):
|
|
"""End-to-end check: the `::warning::` line emitted for an
|
|
advisory finding must be a SINGLE physical line (the rest of
|
|
the Finding is `%0A`-escaped inside the message). Regression
|
|
test for the gemini-code-assist review on PR #5604: without
|
|
`_gha_escape`, GitHub Actions truncates the annotation after
|
|
`[kind] path` and the package + detail fields never render.
|
|
"""
|
|
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:
|
|
# Single physical line: kind, package, detail all present
|
|
# via %0A escape, not split across stderr lines.
|
|
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):
|
|
"""`UNSLOTH_LOCKFILE_AUDIT_SKIP=1` used to silently bypass the
|
|
audit. Per SF4 it must instead emit a `::warning::` to stderr and
|
|
fall through to run the audit. A real justification value
|
|
(>=5 chars, not a boolean shape) is still 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
|
|
# Audit actually ran (saw the per-file banner).
|
|
assert "[lockfile-audit] npm:" in combined_bad, combined_bad
|
|
# Fixture is clean, so 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 means the audit body never ran (no "npm:" banner).
|
|
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}"
|
|
)
|