unsloth/.github/workflows/security-audit.yml
Daniel Han b5dca66cb1
scripts: refresh scan_packages allowlist baseline (#7032)
* scripts: refresh scan_packages allowlist baseline

Regenerate scripts/scan_packages_baseline.json against the current
resolved dependency set so the blocking pip scan-packages gate matches
what the scanner now finds. Refreshes evidence hashes for benign
findings whose code shifted lines (unsloth-zoo mlx loader, gguf/mlx
test /tmp fixtures) and adds two mainstream-library entries that were
newly surfaced (torch inductor codecache base64+subprocess compile
cache, torch testing common_utils socket import). Stale entries whose
matching code changed and no longer triggers are dropped.

All entries remain CRITICAL/HIGH findings manually judged benign;
matched on (package, file, check, evidence_hash).

* ci(security-audit): re-run scan when the allowlist baseline changes

The security-audit pull_request trigger listed the scanners but not
their allowlist baselines, so a baseline-only edit never re-ran the
scan that consumes it. A refreshed baseline could therefore merge
without CI confirming its evidence hashes match what the scanner finds.
Add scan_packages_baseline.json and scan_npm_packages_baseline.json to
the paths filter so baseline changes are validated on their own PR.
2026-07-09 04:52:30 -07:00

1246 lines
60 KiB
YAML

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Multi-language supply-chain audit. Triggers:
# - PRs touching any dependency manifest (Python / npm / Cargo), a
# scanner or its allowlist baseline, or this workflow file,
# - push to main / pip,
# - nightly @ 04:13 UTC so newly-published advisories surface even
# when no PR opens,
# - workflow_dispatch for ad-hoc invocations.
#
# Two jobs:
# - advisory-audit: one runner that runs pip-audit + npm audit +
# cargo audit back-to-back. All three are
# advisory-DB lookups -- fast, lockfile-driven,
# no archive download. Setting up the python /
# node / rust toolchains on one runner and
# running the three commands serially is
# cheaper than spinning up three runners.
# - pip-scan-packages: 3-shard matrix that downloads + pattern-scans
# every PyPI archive in the transitive closure.
# This is the expensive job (~6 min/shard,
# running in parallel) and it must stay
# independent so a CVE-DB hit in advisory-audit
# does not block the supply-chain pattern scan
# (or vice versa).
#
# All steps are non-blocking initially. The default branch already
# carries a known-vuln backlog (the dependabot banner shows 17 today,
# pip-audit catches 2 more, npm/cargo will catch their own); a hard
# gate now would block every PR on a baseline we have not triaged.
# As each baseline closes, drop continue-on-error per step.
#
# Dependency coverage:
# - unsloth core (pyproject.toml [project.dependencies])
# - unsloth `huggingfacenotorch` extras (the canonical install path
# for fine-tuning users; pulls transformers / peft / accelerate /
# trl / datasets / diffusers / sentence-transformers / etc.)
# - all six Studio backend requirements files
# - Studio frontend (npm) and Tauri shell (cargo)
# Each Python step builds a filtered dep list from pyproject.toml +
# requirements/*.txt before auditing. We do NOT install any of these
# -- pip-audit resolves through PyPI metadata, scan_packages.py
# downloads sdist/wheel archives and inspects them without running
# install hooks, so an attacker who has compromised a transitive dep
# cannot execute code in this workflow.
name: Security audit
on:
pull_request:
paths:
- 'studio/backend/requirements/**'
- 'studio/frontend/package.json'
- 'studio/frontend/package-lock.json'
- 'studio/src-tauri/Cargo.toml'
- 'studio/src-tauri/Cargo.lock'
- 'pyproject.toml'
- 'scripts/scan_packages.py'
- 'scripts/scan_packages_baseline.json'
- 'scripts/scan_npm_packages.py'
- 'scripts/scan_npm_packages_baseline.json'
- '.github/workflows/security-audit.yml'
push:
branches: [main, pip]
schedule:
- cron: '13 4 * * *' # 04:13 UTC daily, off the cron rush
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
# ──────────────────────────────────────────────────────────────────────
# Network-resilience knobs, applied to every job/step. These add retries
# and backoff ONLY; they do not relax a single integrity check. cargo
# still resolves against Cargo.lock (--locked), pip still verifies the
# wheels it downloads, npm still enforces package-lock integrity, the
# harden-runner egress allowlists below are unchanged, and every action
# stays SHA-pinned. The advisory-audit run on 2026-05-29 red-failed when
# one crates.io tarball fetch hit "Recv failure: Connection reset by
# peer" (curl 56); cargo's default of 3 retries over an HTTP/2-multiplexed
# connection did not recover. The settings below make that class of
# transient fault self-heal instead of failing the whole run.
env:
# pip: raise the built-in retry count and per-connection timeout.
PIP_RETRIES: "10"
PIP_DEFAULT_TIMEOUT: "60"
# cargo: retry network ops and disable HTTP/2 multiplexing -- the
# documented mitigation for the curl-56 connection resets above.
CARGO_NET_RETRY: "10"
CARGO_HTTP_MULTIPLEXING: "false"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
# npm: retry registry fetches with capped exponential backoff.
NPM_CONFIG_FETCH_RETRIES: "5"
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "2000"
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "60000"
jobs:
# ─────────────────────────────────────────────────────────────────────
# Combined advisory-DB audit: pip-audit + npm audit + cargo audit
# all on one runner. Each step is continue-on-error so a finding in
# one toolchain does not suppress the others.
# ─────────────────────────────────────────────────────────────────────
advisory-audit:
name: advisory audit (pip + npm + cargo)
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
# step-security/harden-runner installs an eBPF-based egress
# firewall on the runner. In `audit` mode it logs every outbound
# connection without blocking; in `block` mode it rejects
# anything outside `allowed-endpoints`. We run audit-only
# initially: the next time this job hits a real PyPI advisory or
# an attacker-funded archive in pip-scan-packages, the audit log
# tells us exactly which hosts were dialed and we promote the
# allowlist to block. Would have *contained* the litellm exfil
# even if scan_packages had missed the .pth payload.
# SHA-pinned (not @v2): the litellm 1.82.7 attack chain hijacked
# mutable tags on aquasecurity/trivy-action and would have hit
# anyone using @v0 / @v2 / @latest references. Pinning to a 40-
# char SHA freezes this action at known-good code; Dependabot's
# github-actions ecosystem will auto-bump the SHA.
# v2.19.1 commit:
# Per-job allowlist: advisory-audit hits PyPI, npm registry,
# crates.io advisories, GitHub release artefacts (osv-scanner
# binary), Semgrep registry, and TruffleHog's own GitHub action.
- name: Harden runner (egress block)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: block
disable-sudo: true
allowed-endpoints: >
api.github.com:443
github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
raw.githubusercontent.com:443
release-assets.githubusercontent.com:443
registry.npmjs.org:443
pypi.org:443
files.pythonhosted.org:443
static.rust-lang.org:443
index.crates.io:443
static.crates.io:443
crates.io:443
semgrep.dev:443
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Full history so TruffleHog can diff base..head; without
# this it sees only the latest commit and reports nothing.
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: 'pip'
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
- uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: studio/src-tauri -> target
- name: Install pip-audit + cargo-audit
# cargo-audit pulls advisories from the RustSec advisory-db on
# first run and caches them under ~/.cargo/advisory-db. Pin
# --locked so the version we install matches Cargo.lock
# determinism. cargo-audit 0.22 supports the CVSS 4.0 schema
# used in 2026 advisories (e.g. RUSTSEC-2026-0073); 0.21
# crashes with a TOML parse error on that file.
# npm audit is bundled with the node toolchain, no install.
run: |
retry() { # retry <max-attempts> <command...> with exponential backoff
local max="$1"; shift
local n=1 delay=5
until "$@"; do
if [ "$n" -ge "$max" ]; then
echo "::error::command failed after ${n} attempts: $*" >&2
return 1
fi
echo "attempt ${n}/${max} failed; retrying in ${delay}s: $*" >&2
sleep "$delay"; n=$((n + 1)); delay=$((delay * 2))
done
}
retry 5 python -m pip install --upgrade pip 'pip-audit>=2.7'
# --locked keeps the resolved tree identical to Cargo.lock; the
# CARGO_NET_* env above plus this outer loop survive transient
# crates.io connection resets without weakening that guarantee.
retry 5 cargo install --locked --version '^0.22' cargo-audit
# ─────────────────────────────────────────────────────────────
# Python: pip-audit
# ─────────────────────────────────────────────────────────────
- name: Build filtered Python requirements set
# Two transforms:
# (1) Generate audit-reqs/unsloth-deps.txt from pyproject.toml
# so pip-audit sees the unsloth pip package's own dep set
# (core + huggingfacenotorch extras: transformers / peft /
# accelerate / trl / datasets / diffusers /
# sentence-transformers / huggingface_hub / hf_transfer /
# etc.).
# (2) Copy each studio/backend/requirements/*.txt into
# audit-reqs/ with `git+` lines stripped. pip-audit's `-r`
# mode does a dry-run resolve against PyPI metadata; a
# `git+https://...` spec forces it to clone, which is
# both slow and outside the threat model (we audit
# PyPI-served archives; a git ref is whatever HEAD says
# on the runner). A comment line is left in place so the
# skipped specs are obvious in the artifact.
# The `huggingface` extra is `huggingfacenotorch` plus torch /
# torchvision / triton, deliberately skipped: Studio backend
# already pins a torch and the +cu* / +cpu local-version tags
# trip up the PyPI resolver in `-r` mode.
run: |
mkdir -p audit-reqs
python <<'PY' > audit-reqs/unsloth-deps.txt
import tomllib
with open("pyproject.toml", "rb") as f:
d = tomllib.load(f)
core = d["project"]["dependencies"]
extras = d["project"]["optional-dependencies"]["huggingfacenotorch"]
print("# Auto-generated from pyproject.toml by security-audit.yml.")
print("# core deps + huggingfacenotorch extras.")
for spec in core + extras:
print(spec)
PY
for f in studio.txt extras.txt extras-no-deps.txt \
no-torch-runtime.txt overrides.txt triton-kernels.txt; do
python <<PY > "audit-reqs/$f"
src = "studio/backend/requirements/$f"
with open(src) as fh:
for line in fh:
stripped = line.strip()
before_comment = stripped.split("#", 1)[0]
if "git+" in before_comment:
print(f"# [security-audit] skipped git+ spec: {stripped}")
continue
print(line.rstrip("\n"))
PY
done
- name: pip-audit (declared Python deps, no install)
# `-r requirements.txt` resolves the requirements through pip's
# dependency resolver against PyPI metadata and audits the
# resolved tree without ever executing setup.py / install
# hooks. Way faster than installing the full Studio runtime
# and -- critically -- safer: an attacker who has compromised
# a transitive dep cannot run code in this job.
#
# extras.txt + extras-no-deps.txt have legacy setup.py
# packages (notably openai-whisper) whose setup.py imports
# `pkg_resources`, which the isolated build env's current
# setuptools no longer ships. PIP_CONSTRAINT pins an older
# setuptools into the build env so those builds resolve.
# Per-file loop so one bad file doesn't take out the whole
# audit.
continue-on-error: true
env:
PIP_CONSTRAINT: ${{ github.workspace }}/audit-reqs/build-constraints.txt
run: |
set +e
cat > audit-reqs/build-constraints.txt <<'CONSTRAINTS'
setuptools<78
wheel
CONSTRAINTS
: > logs-pip-audit.txt
for f in unsloth-deps studio extras extras-no-deps \
no-torch-runtime overrides triton-kernels; do
if ! grep -qE '^[^#[:space:]]' "audit-reqs/$f.txt"; then
echo "[security-audit] $f.txt has no PyPI specs after git+ filter, skipping" \
| tee -a logs-pip-audit.txt
continue
fi
echo "::group::pip-audit -r audit-reqs/$f.txt"
{
echo
echo "=== $f ==="
pip-audit -r "audit-reqs/$f.txt" --format=columns
echo "=== end $f (rc=$?) ==="
} 2>&1 | tee -a logs-pip-audit.txt
echo "::endgroup::"
done
{
echo "## pip-audit (Python)"
echo
echo '### Coverage'
echo '- unsloth core + `huggingfacenotorch` extras (pyproject.toml)'
echo '- studio/backend/requirements/{studio,extras,extras-no-deps,no-torch-runtime,overrides,triton-kernels}.txt'
echo '- `git+` specs are stripped before audit (out of scope: we audit PyPI archives)'
echo
echo '### Findings'
echo '```'
cat logs-pip-audit.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Pre-install lockfile supply-chain audit (npm + cargo).
# Catches structural anomalies (non-registry resolved URLs,
# missing integrity hashes, known IOC strings) BEFORE `npm
# audit` or OSV-Scanner consult the advisory DB. The advisory
# path is reactive -- there is a window between a malicious
# publication and the GHSA landing. This step fires on the
# injection pattern itself so it catches the same class of
# attack the moment the lockfile shape becomes wrong.
# ─────────────────────────────────────────────────────────────
- name: Lockfile supply-chain audit (pre-install scan)
run: |
python3 scripts/lockfile_supply_chain_audit.py
{
echo "## Lockfile supply-chain audit"
echo
echo "Scanned: studio/frontend/package-lock.json + studio/src-tauri/Cargo.lock"
echo
echo "No structural anomalies or known IOC strings."
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# npm: Studio frontend
# ─────────────────────────────────────────────────────────────
- name: npm audit (Studio frontend)
# `npm audit` resolves the lockfile through the npmjs.com
# advisory DB. `--audit-level=high` filters the noise floor
# to only HIGH and CRITICAL. We do NOT pass --omit=dev: a
# malicious dev-only dep can still steal secrets from a CI
# runner, so dev deps need to be in the audit surface.
continue-on-error: true
working-directory: studio/frontend
run: |
set +e
npm audit --audit-level=high | tee ../../logs-npm-audit.txt
# Always also write the full JSON for grep-ability.
npm audit --json > ../../logs-npm-audit.json || true
{
echo "## npm audit (Studio frontend)"
echo
echo '```'
tail -200 ../../logs-npm-audit.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# cargo: Studio Tauri shell
# ─────────────────────────────────────────────────────────────
- name: cargo audit (Studio Tauri)
# `--deny warnings` would make the job fail on any advisory.
# Keep non-blocking initially; drop continue-on-error after
# the baseline closes.
continue-on-error: true
working-directory: studio/src-tauri
run: |
set +e
cargo audit | tee ../../logs-cargo-audit.txt
{
echo "## cargo audit (Studio Tauri)"
echo
echo '```'
tail -200 ../../logs-cargo-audit.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# OSV-Scanner: cross-ecosystem advisory DB (PyPI + npm + cargo)
# ─────────────────────────────────────────────────────────────
- name: Download + verify OSV-Scanner
# Split out from the scan below so binary integrity is a HARD gate:
# a checksum mismatch (swapped release asset, the Trivy-style pivot
# this workflow refuses) fails the job instead of being swallowed by
# the scan step's continue-on-error. A download still failing after
# retries is transient, so we skip the scan rather than red-fail.
# SHA-256 verified BEFORE chmod +x / exec. Bump OSV_SHA256 in lockstep
# with OSV_VERSION (value from the release's osv-scanner_SHA256SUMS).
run: |
set -euo pipefail
OSV_VERSION="v2.0.2"
OSV_SHA256="3abcfd7126c453a00421487e721b296e0cb68085bd431d6cef60872774170fc8"
if ! curl --proto '=https' --tlsv1.2 -fsSL \
--retry 5 --retry-delay 3 --retry-connrefused --retry-all-errors \
-o /tmp/osv-scanner \
"https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"; then
echo "::warning::osv-scanner download failed after retries; skipping scan" >&2
rm -f /tmp/osv-scanner
exit 0 # transient availability: do not red-fail the job
fi
if ! echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -; then
echo "::error::osv-scanner checksum mismatch; refusing to execute" >&2
rm -f /tmp/osv-scanner
exit 1 # integrity failure: hard-fail
fi
chmod +x /tmp/osv-scanner
/tmp/osv-scanner --version
- name: OSV-Scanner (PyPI + npm + cargo, cross-ecosystem advisories)
# OSV's advisory feed is a superset of GitHub-Advisory + RustSec
# + npm advisories; running it alongside the per-ecosystem audit
# tools catches CVEs that haven't propagated to the per-ecosystem
# DBs yet (e.g. langchain-core CVE-2025-68664 was on OSV before
# GitHub Advisory). Single binary, one transitive resolver, all
# three lockfile types in one pass. Binary is checksum-verified in
# the step above; only the advisory scan stays non-blocking until
# baselines close.
continue-on-error: true
run: |
set +e
if [ ! -x /tmp/osv-scanner ]; then
echo "osv-scanner unavailable this run; skipping scan" | tee logs-osv-scanner.txt
else
/tmp/osv-scanner scan source \
--lockfile=studio/frontend/package-lock.json \
--lockfile=studio/src-tauri/Cargo.lock \
--lockfile=requirements.txt:audit-reqs/unsloth-deps.txt \
--lockfile=requirements.txt:audit-reqs/studio.txt \
--lockfile=requirements.txt:audit-reqs/no-torch-runtime.txt \
--lockfile=requirements.txt:audit-reqs/overrides.txt \
--lockfile=requirements.txt:audit-reqs/extras.txt \
--lockfile=requirements.txt:audit-reqs/extras-no-deps.txt \
--format=table 2>&1 | tee logs-osv-scanner.txt
fi
{
echo "## OSV-Scanner (cross-ecosystem)"
echo
echo '```'
tail -200 logs-osv-scanner.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Semgrep: design-flaw detection (catches what regex-pattern
# scanning of malicious authors cannot, e.g. first-party logic bugs
# like langchain-core CVE-2025-68664 dumps/dumpd injection,
# n8n CVE-2025-68668 _pyodide.eval_code sandbox escape, marimo
# CVE-2026-39987 unauth WebSocket).
# ─────────────────────────────────────────────────────────────
- name: Semgrep (supply-chain + python rule packs)
continue-on-error: true
run: |
set +e
python -m pip install --quiet 'semgrep>=1.95'
semgrep --version
semgrep scan \
--config p/supply-chain \
--config p/python \
--config p/javascript \
--config p/security-audit \
--severity ERROR --severity WARNING \
--metrics off \
--timeout 120 \
studio/backend unsloth scripts \
2>&1 | tee logs-semgrep.txt
{
echo "## Semgrep (supply-chain + python + javascript rules)"
echo
echo '```'
tail -200 logs-semgrep.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Lockfile pin verifier. The litellm 1.82.7 attack window was
# ~40 minutes; anyone resolving with `>=` got the malicious
# version automatically. Flag every spec in the requirements
# files that does not pin to an exact `==` (or `@` for git
# refs, or `===` for arbitrary equality). Warning-only for now;
# graduate to blocking once the baseline is clean.
# ─────────────────────────────────────────────────────────────
- name: Lockfile pin verifier (Python requirements)
continue-on-error: true
run: |
python <<'PY' | tee logs-pin-verifier.txt
import re
from pathlib import Path
# Specs that look like `pkg==1.2.3` or `pkg @ git+...` or
# bare comments / -r lines are pinned-or-not-applicable.
PINNED = re.compile(r"^\s*[A-Za-z0-9_.\-]+\s*(?:===|==)\s*[^,;]+\s*$")
GIT_OR_URL = re.compile(r"^\s*[A-Za-z0-9_.\-]+\s*@\s*(?:git\+|https?://)")
unpinned = []
for f in sorted(Path("studio/backend/requirements").glob("*.txt")):
for i, raw in enumerate(f.read_text().splitlines(), 1):
line = raw.strip()
if not line or line.startswith("#") or line.startswith("-"):
continue
spec = line.split("#", 1)[0].strip().split(";", 1)[0].strip()
if not spec:
continue
if "git+" in spec or PINNED.match(spec) or GIT_OR_URL.match(spec):
continue
unpinned.append((str(f), i, line))
print(f"::group::Lockfile pin status")
if unpinned:
print(f"WARN: {len(unpinned)} non-`==` specs across requirements/*.txt")
print("(litellm 1.82.7 wave hit anyone on `>=`; tighten when feasible.)")
for f, i, line in unpinned[:80]:
print(f" {f}:{i}: {line}")
if len(unpinned) > 80:
print(f" ... and {len(unpinned) - 80} more")
else:
print("OK: every spec is exact-pinned.")
print("::endgroup::")
PY
{
echo "## Lockfile pin verifier"
echo
echo '```'
cat logs-pin-verifier.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Trivy is deliberately NOT installed here. Trivy was the entry
# point for the litellm 1.82.7 supply-chain compromise (March
# 2026): attackers force-rewrote 76 of 77 tags in
# aquasecurity/trivy-action to point at malicious commits;
# anyone running the action with a tag ref auto-pulled a
# credential-harvesting payload. By design a security scanner
# has broad read access to runner secrets, which is exactly
# what made it the ideal pivot. We pick up Trivy's CVE coverage
# from OSV-Scanner (NVD + GHSA + GitLab) and its secret
# detection from TruffleHog. IaC misconfig detection (Trivy's
# one unique value-add) is unfilled for now -- revisit with
# checkov / kics when we ship a Dockerfile or k8s manifests.
# See https://docs.litellm.ai/blog/security-update-march-2026
# and the Microsoft / Trend Micro / Snyk incident write-ups.
# ─────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────
# TruffleHog secret-leak scan on the PR diff. Catches API keys
# / tokens / cred files committed accidentally. --only-verified
# filters out probabilistic findings, so we only flag tokens
# that the source provider confirmed are live. On push to main
# / pip we scan the full repo; on PR we scan base..head.
# SHA-pinned for the same reason as harden-runner above.
# v3.95.2 commit:
# ─────────────────────────────────────────────────────────────
- name: TruffleHog (secrets in diff)
continue-on-error: true
uses: trufflesecurity/trufflehog@37b77001d0174ebec2fcca2bd83ff83a6d45a3ab # v3.95.3
with:
path: ./
base: ${{ github.event.pull_request.base.sha || '' }}
head: ${{ github.event.pull_request.head.sha || github.sha }}
# The action passes --no-update internally; passing it here
# too triggers `flag 'no-update' cannot be repeated`. Stick
# with --only-verified so we only flag tokens the source
# provider confirmed are live (no probabilistic findings).
extra_args: --only-verified
# ─────────────────────────────────────────────────────────────
# CycloneDX SBOM. Lets downstream consumers audit what's
# actually shipped in unsloth wheels and the Studio backend
# runtime. Generates one JSON file per requirements input plus
# a combined SBOM keyed off pyproject.toml; uploads as a build
# artifact (and a future step can attest it via SLSA).
# ─────────────────────────────────────────────────────────────
- name: Generate CycloneDX SBOM
continue-on-error: true
run: |
set +e
python -m pip install --quiet 'cyclonedx-bom>=4.6'
mkdir -p sbom
# Per-requirements-file SBOM (the audit-reqs/ files are the
# filtered, git+-stripped views built earlier in this job).
# cyclonedx-py 4.x uses `--sv` for spec version and `-o` for
# the output file; the older `--schema-version`/`--outfile`
# spellings are not accepted.
for f in audit-reqs/*.txt; do
base=$(basename "$f" .txt)
if grep -qE '^[^#[:space:]]' "$f"; then
cyclonedx-py requirements "$f" \
--sv 1.6 \
--of JSON \
-o "sbom/sbom-$base.json" 2>&1 | tail -5 || true
fi
done
# Project-level SBOM from pyproject.toml.
cyclonedx-py environment \
--sv 1.6 \
--of JSON \
-o sbom/sbom-environment.json 2>&1 | tail -5 || true
ls -la sbom/
{
echo "## CycloneDX SBOM"
echo
echo "Generated SBOM files:"
ls sbom/ | sed 's/^/- sbom\//'
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# GitHub Actions pinning verifier. tj-actions/changed-files
# was compromised in March 2025; anyone using `@v4` (a mutable
# ref) auto-shipped the malicious version. Catch every
# non-SHA-pinned `uses:` across the workflows tree. Warn-only
# initially so the existing baseline doesn't block PRs.
# ─────────────────────────────────────────────────────────────
- name: GitHub Actions pinning verifier
continue-on-error: true
run: |
python <<'PY' | tee logs-actions-pinning.txt
import re
from pathlib import Path
# SHA pin = 40 hex chars after @
SHA_PIN = re.compile(r"@[0-9a-f]{40}\b")
# First-party / GitHub-published actions get a softer pass
# (still recommended to pin; not a security gate).
FIRST_PARTY = re.compile(r"^\s*-\s*uses:\s*(actions|github)/[^@]+@")
USES = re.compile(r"^\s*-\s*uses:\s*([^@\s]+)@(\S+)")
unpinned_third = []
unpinned_first = []
for f in sorted(Path(".github/workflows").glob("*.yml")):
for i, line in enumerate(f.read_text().splitlines(), 1):
m = USES.match(line)
if not m:
continue
name, ref = m.group(1), m.group(2)
if SHA_PIN.search(line):
continue
bucket = unpinned_first if FIRST_PARTY.match(line) else unpinned_third
bucket.append((str(f), i, name, ref))
print("::group::Action pinning status")
print(f"third-party actions on mutable refs: {len(unpinned_third)}")
for f, i, n, r in unpinned_third:
print(f" HIGH {f}:{i}: {n}@{r}")
print()
print(f"first-party (actions/* | github/*) on mutable refs: {len(unpinned_first)}")
for f, i, n, r in unpinned_first[:30]:
print(f" WARN {f}:{i}: {n}@{r}")
if len(unpinned_first) > 30:
print(f" ... and {len(unpinned_first) - 30} more")
print()
print("Recommendation: pin third-party actions to a 40-char SHA.")
print("Dependabot's github-actions ecosystem will auto-bump them.")
print("::endgroup::")
PY
{
echo "## GitHub Actions pinning verifier"
echo
echo '```'
cat logs-actions-pinning.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Hash-pin verifier. `==` pinning protects against version
# drift but not against a re-uploaded malicious wheel at the
# same version (PyPI lets a yanked release be re-published with
# different bytes for ~5 minutes via `--filename` collision).
# `pip install --require-hashes` rejects any download whose
# SHA-256 doesn't match. Inspector step that reports how many
# specs would gain from a hash pin -- conversion is a roadmap
# item (needs pip-tools / uv pip compile --generate-hashes).
# ─────────────────────────────────────────────────────────────
- name: Hash-pin verifier (Python requirements)
continue-on-error: true
run: |
python <<'PY' | tee logs-hash-verifier.txt
import re
from pathlib import Path
PINNED = re.compile(r"^\s*[A-Za-z0-9_.\-]+\s*==\s*[^,;]+\s*$")
HASH_LINE = re.compile(r"--hash=sha256:[0-9a-f]{64}")
total_pinned = 0
with_hash = 0
for f in sorted(Path("studio/backend/requirements").glob("*.txt")):
text = f.read_text()
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#") or line.startswith("-"):
continue
spec = line.split("#", 1)[0].strip().split(";", 1)[0]
if PINNED.match(spec):
total_pinned += 1
if HASH_LINE.search(raw):
with_hash += 1
print(f"::group::Hash-pin status")
print(f" exact == pins: {total_pinned}")
print(f" with --hash=sha256: {with_hash}")
print(f" without --hash: {total_pinned - with_hash}")
print()
print("Roadmap: convert to hash-locked installs via")
print("`uv pip compile --generate-hashes` and `pip install --require-hashes`.")
print("Hash-locked installs would have refused a republished")
print("malicious litellm 1.82.7 wheel even at the same version.")
print("::endgroup::")
PY
{
echo "## Hash-pin verifier"
echo
echo '```'
cat logs-hash-verifier.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: advisory-audit-logs
path: |
logs-pip-audit.txt
logs-npm-audit.txt
logs-npm-audit.json
logs-cargo-audit.txt
logs-osv-scanner.txt
logs-semgrep.txt
logs-pin-verifier.txt
logs-actions-pinning.txt
logs-hash-verifier.txt
audit-reqs/
sbom/
retention-days: 30
# ─────────────────────────────────────────────────────────────────────
# Python: pre-install package scan (no install, no execution)
# ─────────────────────────────────────────────────────────────────────
pip-scan-packages:
# Downloads each declared dep WITHOUT installing it and inspects
# the archive contents for known malicious patterns: weaponized
# .pth files, credential stealers, obfuscated payloads,
# install-time droppers, suspicious subprocess / network /
# base64-blob combinations.
#
# This is the kind of check that would have caught:
# - litellm 1.82.7 / 1.82.8 (March 2026, supply-chain compromise)
# - the typo-squat campaign against PyTorch Lightning
# before either landed in the install path. pip-audit only knows
# about CVE-published vulnerabilities, so it does NOT see novel
# malicious uploads. scan_packages.py runs deterministic regex
# pattern matching, no LLM calls.
#
# `--with-deps` makes the scan transitive: every package the
# declared set resolves to gets fetched and pattern-scanned, not
# just the top-level pins. Resolving the full transitive closure
# of the unsloth + Studio dep tree downloads several hundred
# archives, hence the longer timeout.
#
# Sharded across runners for wall-clock parallelism. Each shard
# runs scan_packages.py once with --with-deps so its own slice
# benefits from pip's deduped transitive resolve. Shard
# composition tries to balance load:
# - hf-stack: pyproject extras + no-torch-runtime
# (~150 archives, transformers/peft/accelerate/...)
# - studio: FastAPI/Studio backend + overrides + extras-no-deps
# (~150 archives, smaller scientific stack)
# - extras: the heavy openai-whisper / scikit-learn / librosa
# stack (~250 archives, dominant cost)
# triton-kernels.txt is git+-only, fully skipped.
name: ${{ matrix.shard.name }}
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
shard:
- name: 'pip scan-packages :: hf-stack'
id: hf-stack
files: 'unsloth-deps no-torch-runtime'
- name: 'pip scan-packages :: studio'
id: studio
files: 'studio overrides extras-no-deps'
- name: 'pip scan-packages :: extras'
id: extras
files: 'extras'
steps:
# Egress block on every shard. Each shard pulls hundreds of
# PyPI archives -- if a malicious wheel ever phones home from
# within the scanner sandbox (it shouldn't; we never execute
# the archive), harden-runner now rejects the connect outright.
# Per-job allowlist: pip-scan-packages only fetches PyPI archives
# via scan_packages.py + pip download. No npm or cargo traffic.
- name: Harden runner (egress block)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: block
disable-sudo: true
allowed-endpoints: >
api.github.com:443
github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install scan_packages.py runtime deps
# scan_packages.py imports requests + packaging at runtime to
# talk to PyPI's JSON API and to parse version specifiers. We
# do not install the packages it scans -- those are downloaded
# raw and inspected without ever touching `pip install`.
run: python -m pip install --upgrade pip requests packaging
- name: Build filtered requirements set
# Mirrors the advisory-audit job's input transform: pyproject.toml
# extraction + git+ stripping. scan_packages.py downloads
# PyPI archives without building, so it tolerates legacy
# setup.py packages (no resolver dry-run); but `--with-deps`
# delegates resolution to a single `pip download` call that
# cannot satisfy `git+` specs without git operations, so we
# strip them here too.
run: |
mkdir -p audit-reqs
python <<'PY' > audit-reqs/unsloth-deps.txt
import tomllib
with open("pyproject.toml", "rb") as f:
d = tomllib.load(f)
core = d["project"]["dependencies"]
extras = d["project"]["optional-dependencies"]["huggingfacenotorch"]
print("# Auto-generated from pyproject.toml by security-audit.yml.")
print("# core deps + huggingfacenotorch extras.")
for spec in core + extras:
print(spec)
PY
for f in studio.txt extras.txt extras-no-deps.txt \
no-torch-runtime.txt overrides.txt triton-kernels.txt; do
python <<PY > "audit-reqs/$f"
src = "studio/backend/requirements/$f"
with open(src) as fh:
for line in fh:
stripped = line.strip()
before_comment = stripped.split("#", 1)[0]
if "git+" in before_comment:
print(f"# [security-audit] skipped git+ spec: {stripped}")
continue
print(line.rstrip("\n"))
PY
done
- name: Sanity-check scan_packages.py
# The scanner lives at scripts/scan_packages.py in this repo
# so we don't depend on a network fetch at job time.
run: |
test -f scripts/scan_packages.py
head -3 scripts/scan_packages.py
grep -q "Standalone pre-install package scanner" scripts/scan_packages.py
- name: Scan declared + transitive Python deps
# scan_packages.py exits 1 on NON-baselined CRITICAL/HIGH
# findings, 0 otherwise. It scans code-only (docstrings and
# comments are blanked first) and suppresses reviewed
# known-good findings via scripts/scan_packages_baseline.json,
# so legitimate-library noise no longer red-fails the gate.
# The step stays advisory until SCAN_ENFORCE=1 (see env below);
# then PIPESTATUS propagates the scanner's exit code.
#
# `--with-deps` walks PyPI metadata to enumerate every
# transitive dep the declared set would install, then scans
# them all. Without this flag, we'd only catch a malicious
# *direct* dep -- and supply-chain attacks usually land
# several hops down (litellm 1.82.7 was a dep of a dep for
# most users).
#
# This step runs once per matrix shard. Within a shard, every
# -r file is fed to a single `pip download` call so pip
# intersects version constraints and yields a deduped
# transitive set (no point fetching the same transformers
# wheel five times). Across shards we accept some redundant
# downloads in exchange for wall-clock parallelism.
env:
SHARD_FILES: ${{ matrix.shard.files }}
# Enforcement switch. "1" = blocking: a non-baselined CRITICAL/HIGH
# fails the build. scan_packages.py scans code-only (docstrings/comments
# stripped), fetches sdist-only packages directly from PyPI (no build)
# so every shard resolves, and honors the reviewed allowlist at
# scripts/scan_packages_baseline.json, so only NON-baselined
# CRITICAL/HIGH cause its exit 1. The committed baseline makes all three
# shards exit 0 today; set this back to "0" to return to advisory.
SCAN_ENFORCE: "1"
run: |
set +e
mkdir -p logs
LOG="logs-scan-packages-${{ matrix.shard.id }}.txt"
echo "::group::shard ${{ matrix.shard.id }} input files"
REQ_ARGS=()
for f in $SHARD_FILES; do
if grep -qE '^[^#[:space:]]' "audit-reqs/$f.txt"; then
echo " + audit-reqs/$f.txt"
REQ_ARGS+=( -r "audit-reqs/$f.txt" )
else
echo " - audit-reqs/$f.txt (empty after git+ filter, skipping)"
fi
done
echo "::endgroup::"
rc=0
if [ ${#REQ_ARGS[@]} -eq 0 ]; then
echo "[security-audit] shard ${{ matrix.shard.id }}: no PyPI specs, nothing to scan" \
| tee "$LOG"
else
python scripts/scan_packages.py --with-deps "${REQ_ARGS[@]}" \
2>&1 | tee "$LOG"
rc=${PIPESTATUS[0]}
fi
{
echo "## scan_packages :: shard ${{ matrix.shard.id }}"
echo
echo "### Files in this shard"
for f in $SHARD_FILES; do echo "- audit-reqs/$f.txt"; done
echo
echo "scan_packages.py exit code: $rc (enforce=$SCAN_ENFORCE)"
echo
echo '### Findings (tail)'
echo '```'
tail -200 "$LOG"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Advisory by default; blocking once SCAN_ENFORCE=1 and the baseline
# is committed. PIPESTATUS is captured above so `tee` does not mask the
# scanner's exit code.
if [ "$SCAN_ENFORCE" = "1" ]; then
exit "$rc"
fi
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: scan-packages-log-${{ matrix.shard.id }}
path: |
logs-scan-packages-${{ matrix.shard.id }}.txt
audit-reqs/
retention-days: 30
# ─────────────────────────────────────────────────────────────────────
# npm: pre-install tarball content scan.
# ─────────────────────────────────────────────────────────────────────
npm-scan-packages:
# Counterpart to pip-scan-packages for the npm side. Reads
# studio/frontend/package-lock.json, downloads each resolved
# tarball DIRECTLY from registry.npmjs.org (never via `npm
# install` -- no lifecycle scripts ever run), verifies the
# lockfile integrity hash, unpacks each tarball into a sandboxed
# temp dir behind size / count / path-escape / symlink guards,
# and pattern-scans the extracted file contents for the
# signatures common to npm supply-chain attacks:
#
# - lifecycle (preinstall / install / postinstall / prepare)
# scripts in any package.json that fetch + execute external
# code,
# - C2 / exfiltration hosts (getsession.org, AWS IMDS,
# Kubernetes ServiceAccount token paths, GitHub Actions OIDC,
# HashiCorp Vault endpoints),
# - credential-stealing references (.npmrc, .aws/credentials,
# GITHUB_TOKEN / NPM_TOKEN in JS sources),
# - known IOC filenames (router_init.js, tanstack_runner.js,
# router_runtime.js),
# - obfuscation shapes (Function/eval against base64 blobs).
#
# Threat model: every tarball is hostile. Safety guarantees are
# documented at scripts/scan_npm_packages.py top-of-file. The
# script is stdlib-only so adding it does not increase the
# transitive supply-chain surface.
name: npm scan-packages (Studio frontend tarballs)
runs-on: ubuntu-latest
timeout-minutes: 30
needs: []
steps:
# Per-job allowlist: npm-scan-packages only fetches tarballs from
# registry.npmjs.org. GitHub endpoints retained for checkout +
# setup-python action machinery.
- name: Harden runner (egress block)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: block
disable-sudo: true
allowed-endpoints: >
api.github.com:443
github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
registry.npmjs.org:443
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Sanity-check scan_npm_packages.py
run: |
test -f scripts/scan_npm_packages.py
python3 -c "import ast; ast.parse(open('scripts/scan_npm_packages.py').read())"
- name: Scan npm tarballs (declared + transitive, no install)
# scan_npm_packages.py exits 1 on NON-baselined HIGH/CRITICAL
# findings, 0 otherwise. It scans code-only (JS/TS comments are
# blanked first) and honors a reviewed allowlist at
# scripts/scan_npm_packages_baseline.json. It never runs
# `npm install`, never executes anything from a downloaded
# tarball, and only fetches from registry.npmjs.org. The npm
# corpus is clean (the baseline is empty), so the gate is
# enforcing (SCAN_ENFORCE=1) and any new finding fails the build.
env:
SCAN_ENFORCE: "1"
run: |
set +e
LOG=logs-scan-npm.txt
python3 scripts/scan_npm_packages.py 2>&1 | tee "$LOG"
rc=${PIPESTATUS[0]}
{
echo "## scan_npm_packages"
echo
echo "scan_npm_packages.py exit code: $rc (enforce=$SCAN_ENFORCE)"
echo
echo '### Findings (tail)'
echo '```'
tail -300 "$LOG"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Blocking: the npm corpus is clean, so any non-baselined
# HIGH/CRITICAL is new and should fail the build. PIPESTATUS is
# captured above so `tee` does not mask the scanner's exit code.
if [ "$SCAN_ENFORCE" = "1" ]; then
exit "$rc"
fi
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: scan-npm-packages-log
path: logs-scan-npm.txt
retention-days: 30
# ─────────────────────────────────────────────────────────────────────
# Workflow-trigger lint. Refuses two patterns that together powered the
# TanStack GHSA-g7cv-rxg3-hmpx supply-chain compromise:
#
# 1. `pull_request_target` -- runs a fork's workflow YAML against
# the base repository's secrets. There is no safe use of this
# trigger for a public open-source project.
#
# 2. Shared cache keys between PR-triggered workflows and the
# publish workflow. A fork PR can poison the cache; the publish
# workflow then restores the poisoned cache on next run.
#
# Cheap pure-Python lint, runs in seconds. Fail-closed.
# ─────────────────────────────────────────────────────────────────────
workflow-trigger-lint:
name: workflow-trigger lint (pull_request_target / cache-poisoning)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden runner (egress block)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: block
disable-sudo: true
allowed-endpoints: >
api.github.com:443
github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install PyYAML
run: pip install pyyaml
- name: Lint workflow triggers + cache keys
run: python3 scripts/lint_workflow_triggers.py
# ─────────────────────────────────────────────────────────────────────
# Regression tests: pin scanner IOC tables and pre-install fixtures.
# Hard gate (no continue-on-error) so future drift in the IOC tables
# or scanner exit semantics fails this PR at review time.
# ─────────────────────────────────────────────────────────────────────
tests-security:
name: pytest tests/security
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Harden runner (egress block)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: block
disable-sudo: true
allowed-endpoints: >
api.github.com:443
github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install pytest + PyYAML
# PyYAML is imported by scripts/lint_workflow_triggers.py, which the
# `tests/security/test_lint_workflow_triggers.py` regression suite
# exercises as a subprocess. Without it the lint script bails with
# `ERROR: PyYAML is required` (exit 2) and the 5 lint regression
# tests fail. Pinned the same way pytest is pinned.
run: pip install pytest==9.0.3 pyyaml==6.0.2
- name: Run security regression tests
run: python3 -m pytest tests/security -v
# ─────────────────────────────────────────────────────────────────────
# npm provenance + new install-script diff. Catches the two npm
# supply-chain levers we don't yet gate on:
#
# 1. `npm audit signatures` validates the registry-signed
# provenance of every tarball laid down in node_modules. Pulled
# from the public npm transparency log; surfaces unsigned or
# mis-signed deps. Informational for now (continue-on-error)
# while the baseline settles.
#
# 2. `check_new_install_scripts.py` diffs the PR's lockfile
# against the base ref and refuses any newly-added dep that
# ships a postinstall hook. Every recent npm supply-chain
# compromise leveraged a postinstall as the execution lever, so
# blocking new ones at PR time is a small, high-signal gate.
# ─────────────────────────────────────────────────────────────────────
npm-provenance-and-install-scripts:
name: npm provenance + new install-script diff
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden runner (egress block)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: audit
disable-sudo: true
allowed-endpoints: >
api.github.com:443
github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
registry.npmjs.org:443
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Need the base commit accessible for `git show
# <base-sha>:studio/frontend/package-lock.json` below.
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install Studio frontend deps (--ignore-scripts)
# `npm audit signatures` requires node_modules to be populated.
# `--ignore-scripts` is mandatory: this is exactly the lever the
# new-install-script gate below protects against, and we must
# not run any third-party hook to set up the audit.
working-directory: studio/frontend
run: |
retry() { # retry <max-attempts> <command...> with exponential backoff
local max="$1"; shift
local n=1 delay=5
until "$@"; do
if [ "$n" -ge "$max" ]; then
echo "::error::command failed after ${n} attempts: $*" >&2
return 1
fi
echo "attempt ${n}/${max} failed; retrying in ${delay}s: $*" >&2
sleep "$delay"; n=$((n + 1)); delay=$((delay * 2))
done
}
# --ignore-scripts is mandatory here (no third-party hook runs);
# the retry only re-attempts the registry fetch, it never relaxes
# that flag or the package-lock integrity check npm ci enforces.
retry 5 npm ci --ignore-scripts
- name: npm audit signatures (informational)
# Surfaces unsigned / mis-signed packages from the npm
# transparency log. continue-on-error during baseline-build
# phase; promote to hard gate once the lockfile is fully
# signed (most major maintainers signed by mid-2025).
working-directory: studio/frontend
continue-on-error: true
run: |
set -o pipefail
LOG=logs-audit-signatures.txt
npm audit signatures 2>&1 | tee "$LOG"
{
echo "## npm audit signatures"
echo
echo '```'
tail -200 "$LOG"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Extract base-ref lockfile (PR triggers only)
if: github.event_name == 'pull_request'
run: |
set -e
BASE_SHA="${{ github.event.pull_request.base.sha }}"
git show "$BASE_SHA:studio/frontend/package-lock.json" \
> /tmp/base-package-lock.json
- name: Diff for newly-added install-script deps
if: github.event_name == 'pull_request'
run: |
python3 scripts/check_new_install_scripts.py \
--base /tmp/base-package-lock.json \
--head studio/frontend/package-lock.json
- name: Skip install-script diff (non-PR trigger)
if: github.event_name != 'pull_request'
run: |
echo "Not a pull_request event; install-script diff requires a base ref."
echo "This step is intentionally a no-op outside PR triggers."
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: npm-audit-signatures-log
path: studio/frontend/logs-audit-signatures.txt
if-no-files-found: ignore
retention-days: 30