#!/usr/bin/env python3 # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. """Lockfile supply-chain audit for the Studio frontend and Tauri shell. Runs BEFORE `npm ci` / `cargo fetch` in CI. Refuses to proceed when a lockfile contains patterns indicating supply-chain injection (npm Shai-Hulud waves, cargo crates.io brand-squats). Checks package-lock.json (lockfileVersion 2/3): `resolved` URL must be the npm registry (direct git/github/file refs are the injection vector); `integrity` SHA must be present; known IOC substrings grepped from the body. Checks Cargo.lock: `source` must be the crates.io registry index; known cargo IOC substrings. Exit codes: 0 = clean (or skip env var set to a justification >=5 chars, not '1'/'true'); 1 = findings; 2 = internal error. Only PARSES the lockfiles, never executes or networks. Complements (not replaces) `npm audit` / OSV-Scanner / the advisory-DB pipeline. Fires before any third-party install script runs on the runner. """ from __future__ import annotations import argparse import json import os import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] # Known IOC strings (case-sensitive substring match). Each is tied to a # public advisory; speculative/generic patterns would false-positive on # upgrades. NPM_IOC_STRINGS: tuple[str, ...] = ( # Shai-Hulud TanStack wave -- May 11, 2026 (GHSA-g7cv-rxg3-hmpx). "router_init.js", "tanstack_runner.js", "router_runtime.js", "@tanstack/setup", "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c", # Exfiltration endpoints observed across both Shai-Hulud waves. "filev2.getsession.org", "getsession.org/file/", # Campaign markers; the worm tarballs print this to stdout on run. "A Mini Shai-Hulud has Appeared", # Mini Shai-Hulud May-12 2026 wave. "git-tanstack.com", "transformers.pyz", "/tmp/transformers.pyz", "With Love TeamPCP", # Aikido (May-12 wave): payload SHA-256 hashes + Bun marker. "ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c", "2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96", "bun run tanstack_runner.js", "We've been online over 2 hours", ) # Hard pin-blocks for publicly confirmed malicious versions. # keep in sync with scripts/scan_npm_packages.py BLOCKED_NPM_VERSIONS: dict[str, set[str]] = { # GHSA-g7cv-rxg3-hmpx -- TanStack May-11 2026 (84 versions). "@tanstack/arktype-adapter": {"1.166.12", "1.166.15"}, "@tanstack/eslint-plugin-router": {"1.161.9", "1.161.12"}, "@tanstack/eslint-plugin-start": {"0.0.4", "0.0.7"}, "@tanstack/history": {"1.161.9", "1.161.12"}, "@tanstack/nitro-v2-vite-plugin": {"1.154.12", "1.154.15"}, "@tanstack/react-router": {"1.169.5", "1.169.8"}, "@tanstack/react-router-devtools": {"1.166.16", "1.166.19"}, "@tanstack/react-router-ssr-query": {"1.166.15", "1.166.18"}, "@tanstack/react-start": {"1.167.68", "1.167.71"}, "@tanstack/react-start-client": {"1.166.51", "1.166.54"}, "@tanstack/react-start-rsc": {"0.0.47", "0.0.50"}, "@tanstack/react-start-server": {"1.166.55", "1.166.58"}, "@tanstack/router-cli": {"1.166.46", "1.166.49"}, "@tanstack/router-core": {"1.169.5", "1.169.8"}, "@tanstack/router-devtools": {"1.166.16", "1.166.19"}, "@tanstack/router-devtools-core": {"1.167.6", "1.167.9"}, "@tanstack/router-generator": {"1.166.45", "1.166.48"}, "@tanstack/router-plugin": {"1.167.38", "1.167.41"}, "@tanstack/router-ssr-query-core": {"1.168.3", "1.168.6"}, "@tanstack/router-utils": {"1.161.11", "1.161.14"}, "@tanstack/router-vite-plugin": {"1.166.53", "1.166.56"}, "@tanstack/solid-router": {"1.169.5", "1.169.8"}, "@tanstack/solid-router-devtools": {"1.166.16", "1.166.19"}, "@tanstack/solid-router-ssr-query": {"1.166.15", "1.166.18"}, "@tanstack/solid-start": {"1.167.65", "1.167.68"}, "@tanstack/solid-start-client": {"1.166.50", "1.166.53"}, "@tanstack/solid-start-server": {"1.166.54", "1.166.57"}, "@tanstack/start-client-core": {"1.168.5", "1.168.8"}, "@tanstack/start-fn-stubs": {"1.161.9", "1.161.12"}, "@tanstack/start-plugin-core": {"1.169.23", "1.169.26"}, "@tanstack/start-server-core": {"1.167.33", "1.167.36"}, "@tanstack/start-static-server-functions": {"1.166.44", "1.166.47"}, "@tanstack/start-storage-context": {"1.166.38", "1.166.41"}, "@tanstack/valibot-adapter": {"1.166.12", "1.166.15"}, "@tanstack/virtual-file-routes": {"1.161.10", "1.161.13"}, "@tanstack/vue-router": {"1.169.5", "1.169.8"}, "@tanstack/vue-router-devtools": {"1.166.16", "1.166.19"}, "@tanstack/vue-router-ssr-query": {"1.166.15", "1.166.18"}, "@tanstack/vue-start": {"1.167.61", "1.167.64"}, "@tanstack/vue-start-client": {"1.166.46", "1.166.49"}, "@tanstack/vue-start-server": {"1.166.50", "1.166.53"}, "@tanstack/zod-adapter": {"1.166.12", "1.166.15"}, # Mini Shai-Hulud May-12 wave: OpenSearch JS client. "@opensearch-project/opensearch": {"3.5.3", "3.6.2", "3.7.0", "3.8.0"}, # Mini Shai-Hulud May-12 wave: @squawk/* (22 packages, 5 versions each; # https://safedep.io/mass-npm-supply-chain-attack-tanstack-mistral/). "@squawk/airport-data": {"0.7.4", "0.7.5", "0.7.6", "0.7.7", "0.7.8"}, "@squawk/airports": {"0.6.2", "0.6.3", "0.6.4", "0.6.5", "0.6.6"}, "@squawk/airspace": {"0.8.1", "0.8.2", "0.8.3", "0.8.4", "0.8.5"}, "@squawk/airspace-data": {"0.5.3", "0.5.4", "0.5.5", "0.5.6", "0.5.7"}, "@squawk/airway-data": {"0.5.4", "0.5.5", "0.5.6", "0.5.7", "0.5.8"}, "@squawk/airways": {"0.4.2", "0.4.3", "0.4.4", "0.4.5", "0.4.6"}, "@squawk/fix-data": {"0.6.4", "0.6.5", "0.6.6", "0.6.7", "0.6.8"}, "@squawk/fixes": {"0.3.2", "0.3.3", "0.3.4", "0.3.5", "0.3.6"}, "@squawk/flight-math": {"0.5.4", "0.5.5", "0.5.6", "0.5.7", "0.5.8"}, "@squawk/flightplan": {"0.5.2", "0.5.3", "0.5.4", "0.5.5", "0.5.6"}, "@squawk/geo": {"0.4.4", "0.4.5", "0.4.6", "0.4.7", "0.4.8"}, "@squawk/icao-registry": {"0.5.2", "0.5.3", "0.5.4", "0.5.5", "0.5.6"}, "@squawk/icao-registry-data": {"0.8.4", "0.8.5", "0.8.6", "0.8.7", "0.8.8"}, "@squawk/mcp": {"0.9.1", "0.9.2", "0.9.3", "0.9.4", "0.9.5"}, "@squawk/navaid-data": {"0.6.4", "0.6.5", "0.6.6", "0.6.7", "0.6.8"}, "@squawk/navaids": {"0.4.2", "0.4.3", "0.4.4", "0.4.5", "0.4.6"}, "@squawk/notams": {"0.3.6", "0.3.7", "0.3.8", "0.3.9", "0.3.10"}, "@squawk/procedure-data": {"0.7.3", "0.7.4", "0.7.5", "0.7.6", "0.7.7"}, "@squawk/procedures": {"0.5.2", "0.5.3", "0.5.4", "0.5.5", "0.5.6"}, "@squawk/types": {"0.8.1", "0.8.2", "0.8.3", "0.8.4", "0.8.5"}, "@squawk/units": {"0.4.3", "0.4.4", "0.4.5", "0.4.6", "0.4.7"}, "@squawk/weather": {"0.5.6", "0.5.7", "0.5.8", "0.5.9", "0.5.10"}, # Mini Shai-Hulud May-12 wave: @uipath/* (64 packages, single version each; # https://www.aikido.dev/blog/mini-shai-hulud-is-back-tanstack-compromised). "@uipath/apollo-react": {"4.24.5"}, "@uipath/apollo-wind": {"2.16.2"}, "@uipath/cli": {"1.0.1"}, "@uipath/rpa-tool": {"0.9.5"}, "@uipath/apollo-core": {"5.9.2"}, "@uipath/filesystem": {"1.0.1"}, "@uipath/solutionpackager-tool-core": {"0.0.34"}, "@uipath/solution-tool": {"1.0.1"}, "@uipath/maestro-tool": {"1.0.1"}, "@uipath/codedapp-tool": {"1.0.1"}, "@uipath/agent-tool": {"1.0.1"}, "@uipath/orchestrator-tool": {"1.0.1"}, "@uipath/integrationservice-tool": {"1.0.2"}, "@uipath/rpa-legacy-tool": {"1.0.1"}, "@uipath/vertical-solutions-tool": {"1.0.1"}, "@uipath/flow-tool": {"1.0.2"}, "@uipath/codedagent-tool": {"1.0.1"}, "@uipath/common": {"1.0.1"}, "@uipath/resource-tool": {"1.0.1"}, "@uipath/auth": {"1.0.1"}, "@uipath/docsai-tool": {"1.0.1"}, "@uipath/case-tool": {"1.0.1"}, "@uipath/api-workflow-tool": {"1.0.1"}, "@uipath/test-manager-tool": {"1.0.2"}, "@uipath/robot": {"1.3.4"}, "@uipath/traces-tool": {"1.0.1"}, "@uipath/agent-sdk": {"1.0.2"}, "@uipath/integrationservice-sdk": {"1.0.2"}, "@uipath/maestro-sdk": {"1.0.1"}, "@uipath/data-fabric-tool": {"1.0.2"}, "@uipath/tasks-tool": {"1.0.1"}, "@uipath/insights-tool": {"1.0.1"}, "@uipath/insights-sdk": {"1.0.1"}, "@uipath/uipath-python-bridge": {"1.0.1"}, "@uipath/ap-chat": {"1.5.7"}, "@uipath/project-packager": {"1.1.16"}, "@uipath/packager-tool-case": {"0.0.9"}, "@uipath/packager-tool-workflowcompiler-browser": {"0.0.34"}, "@uipath/packager-tool-connector": {"0.0.19"}, "@uipath/packager-tool-workflowcompiler": {"0.0.16"}, "@uipath/packager-tool-webapp": {"1.0.6"}, "@uipath/packager-tool-apiworkflow": {"0.0.19"}, "@uipath/packager-tool-functions": {"0.1.1"}, "@uipath/widget.sdk": {"1.2.3"}, "@uipath/resources-tool": {"0.1.11"}, "@uipath/agent.sdk": {"0.0.18"}, "@uipath/codedagents-tool": {"0.1.12"}, "@uipath/aops-policy-tool": {"0.3.1"}, "@uipath/solution-packager": {"0.0.35"}, "@uipath/packager-tool-bpmn": {"0.0.9"}, "@uipath/packager-tool-flow": {"0.0.19"}, "@uipath/telemetry": {"0.0.7"}, "@uipath/tool-workflowcompiler": {"0.0.12"}, "@uipath/vss": {"0.1.6"}, "@uipath/solutionpackager-sdk": {"1.0.11"}, "@uipath/ui-widgets-multi-file-upload": {"1.0.1"}, "@uipath/access-policy-tool": {"0.3.1"}, "@uipath/context-grounding-tool": {"0.1.1"}, "@uipath/gov-tool": {"0.3.1"}, "@uipath/admin-tool": {"0.1.1"}, "@uipath/identity-tool": {"0.1.1"}, "@uipath/llmgw-tool": {"1.0.1"}, "@uipath/resourcecatalog-tool": {"0.1.1"}, "@uipath/functions-tool": {"1.0.1"}, "@uipath/access-policy-sdk": {"0.3.1"}, "@uipath/platform-tool": {"1.0.1"}, # Mini Shai-Hulud May-12 wave: @mistralai/* (npm) — separate from PyPI mistralai # (https://www.aikido.dev/blog/mini-shai-hulud-is-back-tanstack-compromised). "@mistralai/mistralai": {"2.2.2", "2.2.3", "2.2.4"}, "@mistralai/mistralai-gcp": {"1.7.1", "1.7.2", "1.7.3"}, "@mistralai/mistralai-azure": {"1.7.1", "1.7.2", "1.7.3"}, # Mini Shai-Hulud May-12 wave: @tallyui/* (30 entries, 10 packages) # (Aikido enumeration). "@tallyui/components": {"1.0.1", "1.0.2", "1.0.3"}, "@tallyui/connector-medusa": {"1.0.1", "1.0.2", "1.0.3"}, "@tallyui/connector-shopify": {"1.0.1", "1.0.2", "1.0.3"}, "@tallyui/connector-vendure": {"1.0.1", "1.0.2", "1.0.3"}, "@tallyui/connector-woocommerce": {"1.0.1", "1.0.2", "1.0.3"}, "@tallyui/core": {"0.2.1", "0.2.2", "0.2.3"}, "@tallyui/database": {"1.0.1", "1.0.2", "1.0.3"}, "@tallyui/pos": {"0.1.1", "0.1.2", "0.1.3"}, "@tallyui/storage-sqlite": {"0.2.1", "0.2.2", "0.2.3"}, "@tallyui/theme": {"0.2.1", "0.2.2", "0.2.3"}, # Mini Shai-Hulud May-12 wave: @beproduct/nestjs-auth (18 versions) # (Aikido enumeration). "@beproduct/nestjs-auth": { "0.1.2", "0.1.3", "0.1.4", "0.1.5", "0.1.6", "0.1.7", "0.1.8", "0.1.9", "0.1.10", "0.1.11", "0.1.12", "0.1.13", "0.1.14", "0.1.15", "0.1.16", "0.1.17", "0.1.18", "0.1.19", }, # Mini Shai-Hulud May-12 wave: @draftlab/* + @draftauth/* # (Aikido enumeration). "@draftauth/client": {"0.2.1", "0.2.2"}, "@draftauth/core": {"0.13.1", "0.13.2"}, "@draftlab/auth": {"0.24.1", "0.24.2"}, "@draftlab/auth-router": {"0.5.1", "0.5.2"}, "@draftlab/db": {"0.16.1"}, # Mini Shai-Hulud May-12 wave: @taskflow-corp/cli + @tolka/cli # (Aikido enumeration). "@taskflow-corp/cli": {"0.1.24", "0.1.25", "0.1.26", "0.1.27", "0.1.28", "0.1.29"}, "@tolka/cli": {"1.0.2", "1.0.3", "1.0.4", "1.0.5", "1.0.6"}, # Mini Shai-Hulud May-12 wave: @ml-toolkit-ts/* + @mesadev/* + @dirigible-ai/sdk + @supersurkhet/* # (Aikido enumeration). "@dirigible-ai/sdk": {"0.6.2", "0.6.3"}, "@mesadev/rest": {"0.28.3"}, "@mesadev/saguaro": {"0.4.22"}, "@mesadev/sdk": {"0.28.3"}, "@ml-toolkit-ts/preprocessing": {"1.0.2", "1.0.3"}, "@ml-toolkit-ts/xgboost": {"1.0.3", "1.0.4"}, "@supersurkhet/cli": {"0.0.2", "0.0.3", "0.0.4", "0.0.5", "0.0.6", "0.0.7"}, "@supersurkhet/sdk": {"0.0.2", "0.0.3", "0.0.4", "0.0.5", "0.0.6", "0.0.7"}, # Mini Shai-Hulud May-12 wave: Unscoped packages (10 entries) # (Aikido enumeration). "safe-action": {"0.8.3", "0.8.4"}, "ts-dna": {"3.0.1", "3.0.2", "3.0.3", "3.0.4"}, "cross-stitch": {"1.1.3", "1.1.4", "1.1.5", "1.1.6"}, "cmux-agent-mcp": {"0.1.3", "0.1.4", "0.1.5", "0.1.6", "0.1.7", "0.1.8"}, "agentwork-cli": {"0.1.4", "0.1.5"}, "git-branch-selector": {"1.3.3", "1.3.4", "1.3.5", "1.3.6", "1.3.7"}, "wot-api": {"0.8.1", "0.8.2", "0.8.3", "0.8.4"}, "git-git-git": {"1.0.8", "1.0.9", "1.0.10", "1.0.11", "1.0.12"}, "nextmove-mcp": {"0.1.3", "0.1.4", "0.1.5", "0.1.6", "0.1.7"}, "ml-toolkit-ts": {"1.0.4", "1.0.5"}, # Cross-ecosystem Mini Shai-Hulud (Apr-30 wave): npm counterpart of # PyPI lightning 2.6.2/2.6.3. Same threat actor (TeamPCP) per Semgrep, # Aikido, OX Security, Resecurity. Safe version: 7.0.3 and earlier. "intercom-client": {"7.0.4"}, } CARGO_IOC_STRINGS: tuple[str, ...] = ( # Empty by default; the `source` origin check catches the structural # pattern. Reserved for future cargo-side incidents. ) # Allowed lockfile origins. NPM_REGISTRY_PREFIX = "https://registry.npmjs.org/" NPM_REGISTRY_PREFIXES_ALLOWED: tuple[str, ...] = (NPM_REGISTRY_PREFIX,) CARGO_REGISTRY_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" # Cargo non-registry source allowlist: `(crate_name, exact_source_string)`. # Both must match verbatim; bumping the pinned SHA forces a re-review. # Studio's Tauri shell pulls `fix-path-env` from git because it is not # published to crates.io; commit c4c45d5 was reviewed when it landed. CARGO_SOURCE_ALLOWLIST: tuple[tuple[str, str], ...] = ( ( "fix-path-env", "git+https://github.com/tauri-apps/fix-path-env-rs#" "c4c45d503ea115a839aae718d02f79e7c7f0f673", ), ) class Finding: __slots__ = ("path", "package", "kind", "detail") def __init__(self, path: str, package: str, kind: str, detail: str) -> None: self.path = path self.package = package self.kind = kind self.detail = detail def __str__(self) -> str: return ( f" [{self.kind}] {self.path}\n" f" package: {self.package}\n" f" detail: {self.detail}" ) def _gha_escape(text: str) -> str: """Escape a string for a GH Actions `::warning::`/`::error::` message. GH Actions truncates at the first newline unless `\\n`/`\\r` are escaped as `%0A`/`%0D`. `%` must be replaced first to avoid double-encoding the subsequent escapes. """ return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") def audit_npm_lockfile(path: Path) -> list[Finding]: findings: list[Finding] = [] if not path.exists(): # Missing lockfile is a config error, not a clean audit. findings.append( Finding( path = str(path), package = "", kind = "missing-lockfile", detail = ( "expected lockfile not found; refusing to silently " "report a clean audit for a path that was not scanned" ), ) ) return findings try: raw = path.read_text(encoding = "utf-8") except OSError as exc: # Surface as a finding instead of crashing CI with a traceback. findings.append( Finding( path = str(path), package = "", kind = "unreadable-lockfile", detail = f"could not read file: {exc}", ) ) return findings try: lock = json.loads(raw) except json.JSONDecodeError as exc: findings.append( Finding( path = str(path), package = "", kind = "malformed-lockfile", detail = f"could not parse as JSON: {exc}", ) ) return findings lockfile_version = lock.get("lockfileVersion") if lockfile_version not in (2, 3): findings.append( Finding( path = str(path), package = "", kind = "unsupported-lockfile-version", detail = (f"only lockfileVersion 2 or 3 audited; got {lockfile_version}"), ) ) packages = lock.get("packages") or {} for key, entry in packages.items(): # Empty key "" is the project root (no `resolved`); skip it. if key == "": continue if entry.get("link"): # Workspace symlink; no tarball to resolve. continue resolved = entry.get("resolved") # Entries nested in another package's node_modules are bundled # fold-ins covered by the parent's integrity; treat as transparent. nested = key.count("/node_modules/") >= 1 # 1. resolved-URL origin. if resolved is None: if nested or entry.get("bundled"): # Bundled / fold-in entry; covered by parent integrity. pass elif entry.get("version"): # Top-level entry without a resolved URL is suspicious. findings.append( Finding( path = str(path), package = key, kind = "missing-resolved-url", detail = ( f"version={entry['version']!r} but no `resolved` " "field; lockfile is incomplete" ), ) ) else: if not any(resolved.startswith(p) for p in NPM_REGISTRY_PREFIXES_ALLOWED): findings.append( Finding( path = str(path), package = key, kind = "non-registry-resolved-url", detail = ( f"resolved={resolved!r}; only " f"{NPM_REGISTRY_PREFIX} is permitted. Direct " "GitHub / git / file references are the " "Shai-Hulud injection vector." ), ) ) # 2. integrity-hash presence. if resolved is not None and not entry.get("integrity"): findings.append( Finding( path = str(path), package = key, kind = "missing-integrity-hash", detail = ( "no `integrity` field; npm cannot verify the " "tarball SHA against the registry-published hash" ), ) ) # 3. Blocked malicious version list. nm_prefix = "node_modules/" pkg_name = key[len(nm_prefix) :] if key.startswith(nm_prefix) else key version = entry.get("version") blocked = BLOCKED_NPM_VERSIONS.get(pkg_name, set()) if version and version in blocked: findings.append( Finding( path = str(path), package = key, kind = "blocked-known-malicious", detail = (f"{pkg_name}@{version} is on the BLOCKED_NPM_VERSIONS list"), ) ) # 4. Known IOC strings: scan the raw body to catch fields the # structural pass doesn't enumerate (scripts, optional deps, etc.). for ioc in NPM_IOC_STRINGS: if ioc in raw: line_no = _first_line_containing(raw, ioc) findings.append( Finding( path = f"{path}:{line_no}" if line_no else str(path), package = "", kind = "known-ioc-string", detail = ( f"matched known IOC substring {ioc!r}; this is " "a public indicator of a recent supply-chain " "compromise. Refuse to install." ), ) ) return findings def _first_line_containing(text: str, needle: str) -> int | None: for i, line in enumerate(text.splitlines(), start = 1): if needle in line: return i return None # Cargo.lock is TOML; parsed with stdlib tomllib (Python 3.11+). _PACKAGE_HEADER = re.compile(r"^\[\[package\]\]\s*$") def audit_cargo_lockfile(path: Path) -> list[Finding]: findings: list[Finding] = [] if not path.exists(): # See audit_npm_lockfile: missing lockfile is a finding. findings.append( Finding( path = str(path), package = "", kind = "missing-lockfile", detail = ( "expected lockfile not found; refusing to silently " "report a clean audit for a path that was not scanned" ), ) ) return findings try: raw = path.read_text(encoding = "utf-8") except OSError as exc: findings.append( Finding( path = str(path), package = "", kind = "unreadable-lockfile", detail = f"could not read file: {exc}", ) ) return findings try: import tomllib # type: ignore[import-not-found] except ImportError: # Python <3.11; fall back to a tomli shim if importable. try: import tomli as tomllib # type: ignore[no-redef] except ImportError: findings.append( Finding( path = str(path), package = "", kind = "missing-toml-parser", detail = ( "Python 3.11+ tomllib or tomli is required to " "parse Cargo.lock; install tomli or upgrade " "Python before re-running this audit" ), ) ) return findings try: lock = tomllib.loads(raw) except Exception as exc: findings.append( Finding( path = str(path), package = "", kind = "malformed-lockfile", detail = f"could not parse as TOML: {exc}", ) ) return findings for entry in lock.get("package", []): name = entry.get("name") or "" version = entry.get("version") or "" source = entry.get("source") # Workspace-local crates have no `source` field; skip them. if source is None: continue if source != CARGO_REGISTRY_SOURCE: if (name, source) in CARGO_SOURCE_ALLOWLIST: # Pre-approved non-registry source pinned by SHA. pass else: findings.append( Finding( path = str(path), package = f"{name}@{version}", kind = "non-registry-cargo-source", detail = ( f"source={source!r}; only " f"{CARGO_REGISTRY_SOURCE!r} is permitted " "by default, and no allowlist entry covers " "this crate. If the source is legitimate, " "add `(name, source)` to " "CARGO_SOURCE_ALLOWLIST after reviewing the " "pinned commit." ), ) ) if not entry.get("checksum") and source == CARGO_REGISTRY_SOURCE: findings.append( Finding( path = str(path), package = f"{name}@{version}", kind = "missing-cargo-checksum", detail = ( "registry crate without checksum; cargo cannot " "verify the downloaded source against the " "registry-published SHA" ), ) ) for ioc in CARGO_IOC_STRINGS: if ioc in raw: line_no = _first_line_containing(raw, ioc) findings.append( Finding( path = f"{path}:{line_no}" if line_no else str(path), package = "", kind = "known-ioc-string", detail = f"matched known IOC substring {ioc!r}", ) ) return findings # Finding kinds split into BLOCKING vs ADVISORY for the default run mode. # Blocking = public attack indicators (known-malicious version, IOC # string). Advisory = structural anomalies that warn but don't block. # --strict makes every finding blocking. BLOCKING_KINDS: frozenset[str] = frozenset( { "blocked-known-malicious", "known-ioc-string", # A structurally broken lockfile might hide a real attack. "malformed-lockfile", "missing-lockfile", "unreadable-lockfile", "missing-toml-parser", } ) DEFAULT_NPM_LOCKFILES = ( "studio/frontend/package-lock.json", "studio/backend/core/data_recipe/oxc-validator/package-lock.json", "studio/package-lock.json", ) DEFAULT_CARGO_LOCKFILES = ("studio/src-tauri/Cargo.lock",) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description = "Pre-install lockfile supply-chain audit.", ) parser.add_argument( "--root", default = str(REPO_ROOT), help = "Repo root (default: parent of this script).", ) parser.add_argument( "--npm-lockfile", action = "append", default = None, help = ( "Path to a package-lock.json (repeatable). " "Default: studio/frontend/package-lock.json, " "studio/backend/core/data_recipe/oxc-validator/package-lock.json, " "and studio/package-lock.json (Tauri CLI for desktop release)." ), ) parser.add_argument( "--cargo-lockfile", action = "append", default = None, help = ("Path to a Cargo.lock (repeatable). Default: studio/src-tauri/Cargo.lock."), ) parser.add_argument( "--strict", action = "store_true", help = ( "Treat every finding as blocking (exit 1). " "Default mode only blocks on known-malicious versions, " "indicator-of-compromise strings, or structurally broken " "lockfiles; everything else is printed as an advisory " "warning with exit 0. CI should use the default; local " "audits aiming for zero noise can opt in via --strict." ), ) args = parser.parse_args(argv) # Require a real justification (>=5 chars, not a boolean-shaped token) # for the skip env var. An invalid value warns and falls through to # run the audit (fail-safe); a valid one warns and skips with rc=0. _skip_raw = os.environ.get("UNSLOTH_LOCKFILE_AUDIT_SKIP") if _skip_raw is not None: _skip = _skip_raw.strip() _invalid_tokens = {"", "1", "0", "true", "false", "yes", "no", "on", "off"} if _skip.lower() in _invalid_tokens or len(_skip) < 5: print( "::warning::Lockfile audit skip REQUIRES a justification " f"value (>=5 chars, not '{_skip_raw}'). Proceeding with " "audit. Use e.g. UNSLOTH_LOCKFILE_AUDIT_SKIP=ticket-1234.", file = sys.stderr, flush = True, ) else: print( f"::warning::Lockfile audit skipped: reason='{_skip}'", file = sys.stderr, flush = True, ) return 0 root = Path(args.root).resolve() # Explicit flags scope the scan; defaults apply only to no-args CI. _user_explicit = args.npm_lockfile is not None or args.cargo_lockfile is not None if _user_explicit: npm_paths = [root / p for p in (args.npm_lockfile or ())] cargo_paths = [root / p for p in (args.cargo_lockfile or ())] else: npm_paths = [root / p for p in DEFAULT_NPM_LOCKFILES] cargo_paths = [root / p for p in DEFAULT_CARGO_LOCKFILES] all_findings: list[Finding] = [] for p in npm_paths: print(f"[lockfile-audit] npm: {p}", flush = True) all_findings.extend(audit_npm_lockfile(p)) for p in cargo_paths: print(f"[lockfile-audit] cargo: {p}", flush = True) all_findings.extend(audit_cargo_lockfile(p)) if not all_findings: print( f"[lockfile-audit] OK: 0 findings across " f"{len(npm_paths)} npm + {len(cargo_paths)} cargo lockfile(s)", flush = True, ) return 0 # Split into blocking (known-malicious / IOC / structurally broken) # and advisory (everything else). Default mode prints advisories # without changing the exit code; --strict makes all blocking. blocking = [f for f in all_findings if f.kind in BLOCKING_KINDS] advisory = [f for f in all_findings if f.kind not in BLOCKING_KINDS] if args.strict: blocking = list(all_findings) advisory = [] if advisory: print( f"\n[lockfile-audit] {len(advisory)} advisory finding(s) " "(non-blocking; pass --strict to fail the build on these):\n", file = sys.stderr, ) for f in advisory: # GH Actions warning annotation; _gha_escape collapses the # multi-line Finding onto one line so it renders fully in the UI. print(f"::warning::{_gha_escape(str(f))}", file = sys.stderr) print(file = sys.stderr) if not blocking: print( f"[lockfile-audit] OK: {len(advisory)} advisory finding(s), " "0 blocking. Run with --strict to escalate advisory findings.", flush = True, ) return 0 print( f"\n[lockfile-audit] FAIL: {len(blocking)} blocking finding(s):\n", file = sys.stderr, ) for f in blocking: # Same %-encoding rationale as the advisory branch above. print(f"::error::{_gha_escape(str(f))}", file = sys.stderr) print(file = sys.stderr) print( "[lockfile-audit] Refusing to proceed. Each blocking finding " "above is either a public indicator-of-compromise, a known-" "malicious pinned version, or a structurally broken lockfile. " "Investigate before running `npm ci` or `cargo fetch`.", file = sys.stderr, ) return 1 if __name__ == "__main__": sys.exit(main())