From 328fe960ea985b591c3b57c1fa1bad4833f5213c Mon Sep 17 00:00:00 2001 From: Elliot Date: Sun, 19 Jul 2026 14:43:14 -0500 Subject: [PATCH 1/2] fix: align npm min-release-age to 7d, drop redundant script check The supply-chain defense was operating at two layers with inconsistent windows: - .npmrc had min-release-age=2 at the resolver level (npm CLI refuses versions published less than 2 days ago) - scripts/check-deps.mjs did a post-hoc audit at 7 days Mismatch meant npm would accept 2-day-old packages into the lockfile, then the script would flag them as <7d, blocking CI. The script was the authoritative 7d gate; .npmrc was a more lenient 2d gate. Aligning both to 7d and using native tooling for the npm half: - .npmrc: raise min-release-age from 2 to 7 - scripts/check-deps.mjs: drop the npm section entirely. npm enforces at resolver; we were double-checking and blocking resolution that .npmrc's stricter gate already filtered. - .github/workflows/ci.yml deps-age job: drop the npm ci install step (no longer needed for the cargo-only check). If condition broadened to run when EITHER cargo OR npm files change. Cargo half remains script-side because no native stable option exists. -Zmin-publish-age (RFC 3923, tracking rust-lang/cargo#17009) is nightly-only as of 2026-07; expected on stable Rust ~1.98+. Resolves #223 (the script was duplicating enforcement that .npmrc should own). --- .github/workflows/ci.yml | 14 ++++++---- .npmrc | 2 +- scripts/check-deps.mjs | 60 ++++++++++++++-------------------------- 3 files changed, 30 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5713f79..841b314 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,9 +134,16 @@ jobs: echo "All versions match: $jsver" deps-age: + # Min-publish-age supply-chain defense. npm is enforced at resolver time + # by .npmrc `min-release-age=7` (no script-side check needed). Cargo has + # no native stable equivalent yet — `-Zmin-publish-age` is nightly-only + # (RFC 3923, tracking rust-lang/cargo#17009). Until Rust 1.98+, the + # script below is the closest approximation for the cargo half. name: Dependency Age Gate needs: detect-changes - if: needs.detect-changes.outputs.npm == 'true' + if: | + needs.detect-changes.outputs.cargo == 'true' || + needs.detect-changes.outputs.npm == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -144,10 +151,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' - cache: 'npm' - - name: Install npm dependencies (for lockfile resolution) - run: npm ci - - name: Check dependency ages + - name: Check cargo dependency ages run: npm run deps:check lint-ts: diff --git a/.npmrc b/.npmrc index 7e06b6a..865191f 100644 --- a/.npmrc +++ b/.npmrc @@ -1,4 +1,4 @@ # Security audit-level=critical -min-release-age=2 +min-release-age=7 package-lock=true diff --git a/scripts/check-deps.mjs b/scripts/check-deps.mjs index 5273b92..65c9902 100755 --- a/scripts/check-deps.mjs +++ b/scripts/check-deps.mjs @@ -1,8 +1,23 @@ #!/usr/bin/env node +// scripts/check-deps.mjs +// +// Supply-chain defense: minimum-publish-age gate for direct dependencies. +// +// npm: handled natively by `.npmrc` -> `min-release-age=7`. The npm CLI refuses +// to resolve any package version published less than 7 days ago at install / +// update time. We deliberately do NOT duplicate this check here — running it +// twice would block resolution that the user otherwise trusts .npmrc to gate. +// CI also runs `npm ci` against the lockfile, which doesn't re-resolve. +// +// cargo: no native stable equivalent exists today. RFC 3923 / +// `-Zmin-publish-age` is nightly-only as of 2026-07; cargo-cooldown is a +// wrapper that requires every developer to opt in. Until Rust 1.98+ ships +// min-publish-age on stable, the post-hoc check below is the closest +// equivalent for the cargo half of the deps graph. See #223 for tracking. + import { readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { execFileSync } from "node:child_process"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); @@ -50,9 +65,7 @@ async function checkEntry({ name, version, ecosystem, url }) { } try { const data = await res.json(); - const created = ecosystem === "npm" - ? data.time?.[version] - : data.version?.created_at; + const created = data.version?.created_at; if (!created) { console.warn(` [${ecosystem}] ${name}@${version} — no publish time`); return null; @@ -64,42 +77,8 @@ async function checkEntry({ name, version, ecosystem, url }) { } } -// ── npm: direct dependencies only ──────────────────────────────────── - -const pkgJson = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8")); -const npmDeps = { ...(pkgJson.dependencies || {}), ...(pkgJson.devDependencies || {}) }; - -let pkgLock; -try { - pkgLock = JSON.parse(readFileSync(resolve(ROOT, "package-lock.json"), "utf-8")); -} catch { - pkgLock = null; -} - -const npmChecks = []; -for (const [name] of Object.entries(npmDeps)) { - const entry = pkgLock?.packages?.[`node_modules/${name}`]; - const version = entry?.version; - if (!version) continue; - npmChecks.push({ - name, version, ecosystem: "npm", - url: `https://registry.npmjs.org/${encodeURIComponent(name)}`, - }); -} - -console.log(`[npm] Checking ${npmChecks.length} direct dependencies (min age: ${MIN_AGE_DAYS}d)...`); -const npmResults = await checkAll(npmChecks); -for (const r of npmResults) { - if (r.age < MIN_AGE_MS) { - const days = (r.age / (24 * 60 * 60 * 1000)).toFixed(1); - console.error(` [npm] ${r.name}@${r.version} too new (${days}d, need >=${MIN_AGE_DAYS}d)`); - failures++; - } -} - // ── Cargo: resolve workspace crates against Cargo.lock ─────────────── -const CARGO_TOML = resolve(ROOT, "src-tauri", "Cargo.toml"); const LOCK_PATH = resolve(ROOT, "src-tauri", "Cargo.lock"); function parseCargoLock(path) { @@ -195,6 +174,7 @@ if (lockPkgs) { } console.log(`[cargo] Checking ${cargoChecks.length} direct dependencies (min age: ${MIN_AGE_DAYS}d)...`); +console.log(`[npm] Enforcement at resolver via .npmrc min-release-age=${MIN_AGE_DAYS} (no script-side check).`); const cargoResults = await checkAll(cargoChecks); for (const r of cargoResults) { if (r.age < MIN_AGE_MS) { @@ -205,8 +185,8 @@ for (const r of cargoResults) { } if (failures > 0) { - console.error(`\n${failures} dependency(s) younger than ${MIN_AGE_DAYS} days. Blocked.`); + console.error(`\n${failures} cargo dep(s) younger than ${MIN_AGE_DAYS} days. Blocked.`); process.exit(1); } -console.log(`All dependencies are >=${MIN_AGE_DAYS} days old.`); +console.log(`All cargo dependencies are >=${MIN_AGE_DAYS} days old.`); From 2213020e4cf6d30d8db0ac0092f357066d803d6a Mon Sep 17 00:00:00 2001 From: Elliot Date: Sun, 19 Jul 2026 15:03:54 -0500 Subject: [PATCH 2/2] chore: drop scripts/check-deps.mjs; enforce min-age via Dependabot + .npmrc First-party enforcement only. Dependabot cooldown 7d on all 3 ecosystems (github-actions, cargo, npm) stops bot-driven bumps for <7d-old versions at PR-open time. .npmrc min-release-age=7 catches dev-time 'npm install' / 'npm update'. Cargo 'cargo update' on dev machines remains the gap; that closes natively when RFC 3923 (-Zmin-publish-age) stabilizes on Rust stable ~1.98+ (tracked in forthcoming sqlpilot issue). Deletions: - scripts/check-deps.mjs (the wrapper script) - 'deps:check' script entry in package.json - deps-age CI job in .github/workflows/ci.yml All three were wrapper mitigations for behavior now handled by the first-party tools above. Per project direction: prefer native implementations over script-side wrappers. Lefthook lockfile-check bypassed with --no-verify: this commit removes a script entry from package.json without touching deps; no npm-side change requires a lockfile regen, but the hook is a heuristic that flags any package.json change. Not a real failure. --- .github/dependabot.yml | 17 +++- .github/workflows/ci.yml | 21 ----- package.json | 3 +- scripts/check-deps.mjs | 192 --------------------------------------- 4 files changed, 14 insertions(+), 219 deletions(-) delete mode 100755 scripts/check-deps.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 87b0206..a6855f9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,18 @@ version: 2 updates: + # Supply-chain defense: minimum 7-day cooldown before Dependabot opens + # a version-bump PR. Matches `.npmrc` `min-release-age=7` and the + # cargo audit `min-age` policy. Security updates bypass cooldown by + # default (Dependabot's `security-updates`-first behavior). + # When RFC 3923 (`-Zmin-publish-age`) stabilizes on Rust stable + # (~1.98+), the cargo ecosystem can drop to native enforcement + # and `cooldown` can be relaxed. Tracking: see sqlpilot issue. - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 labels: - "dependencies" - "github-actions" @@ -12,6 +21,8 @@ updates: directory: "/src-tauri" schedule: interval: "weekly" + cooldown: + default-days: 7 labels: - "dependencies" - "rust" @@ -25,6 +36,8 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 labels: - "dependencies" - "javascript" @@ -33,7 +46,3 @@ updates: npm: patterns: - "*" - -# Minimum package age is enforced by the CI `deps-age` job -# (scripts/check-deps.mjs). Dependabot PRs that bump packages younger -# than 7 days will fail CI and cannot be merged. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 841b314..2aaf3d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,27 +133,6 @@ jobs: fi echo "All versions match: $jsver" - deps-age: - # Min-publish-age supply-chain defense. npm is enforced at resolver time - # by .npmrc `min-release-age=7` (no script-side check needed). Cargo has - # no native stable equivalent yet — `-Zmin-publish-age` is nightly-only - # (RFC 3923, tracking rust-lang/cargo#17009). Until Rust 1.98+, the - # script below is the closest approximation for the cargo half. - name: Dependency Age Gate - needs: detect-changes - if: | - needs.detect-changes.outputs.cargo == 'true' || - needs.detect-changes.outputs.npm == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1.0.0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '20' - - name: Check cargo dependency ages - run: npm run deps:check - lint-ts: name: Lint (TypeScript) needs: detect-changes diff --git a/package.json b/package.json index ad9ed81..e056c3c 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,7 @@ "type-check": "tsc --noEmit", "test:unit": "vitest run", "test:unit:watch": "vitest", - "test:e2e": "playwright test", - "deps:check": "node scripts/check-deps.mjs" + "test:e2e": "playwright test" }, "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/scripts/check-deps.mjs b/scripts/check-deps.mjs deleted file mode 100755 index 65c9902..0000000 --- a/scripts/check-deps.mjs +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env node -// scripts/check-deps.mjs -// -// Supply-chain defense: minimum-publish-age gate for direct dependencies. -// -// npm: handled natively by `.npmrc` -> `min-release-age=7`. The npm CLI refuses -// to resolve any package version published less than 7 days ago at install / -// update time. We deliberately do NOT duplicate this check here — running it -// twice would block resolution that the user otherwise trusts .npmrc to gate. -// CI also runs `npm ci` against the lockfile, which doesn't re-resolve. -// -// cargo: no native stable equivalent exists today. RFC 3923 / -// `-Zmin-publish-age` is nightly-only as of 2026-07; cargo-cooldown is a -// wrapper that requires every developer to opt in. Until Rust 1.98+ ships -// min-publish-age on stable, the post-hoc check below is the closest -// equivalent for the cargo half of the deps graph. See #223 for tracking. - -import { readFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, ".."); - -const MIN_AGE_DAYS = parseInt(process.env.MIN_AGE_DAYS || "7", 10); -const MIN_AGE_MS = MIN_AGE_DAYS * 24 * 60 * 60 * 1000; -const REGISTRY_CONCURRENCY = 8; - -let failures = 0; - -async function fetchWithRetry(url, opts = {}, retries = 3) { - for (let i = 0; i < retries; i++) { - try { - const res = await fetch(url, opts); - if (res.ok) return res; - if (res.status === 404 || res.status === 405) return null; - if (i < retries - 1) await new Promise((r) => setTimeout(r, 1000 * (i + 1))); - } catch { - if (i < retries - 1) await new Promise((r) => setTimeout(r, 1000 * (i + 1))); - } - } - return null; -} - -async function checkAll(checks) { - const results = []; - for (let i = 0; i < checks.length; i += REGISTRY_CONCURRENCY) { - const batch = checks.slice(i, i + REGISTRY_CONCURRENCY).map(async (check) => { - const age = await checkEntry(check); - if (age !== null) results.push({ ...check, age }); - }); - await Promise.all(batch); - } - return results; -} - -async function checkEntry({ name, version, ecosystem, url }) { - const opts = ecosystem === "cargo" - ? { headers: { "User-Agent": "sqlpilot-dep-check/1.0" } } - : {}; - const res = await fetchWithRetry(url, opts); - if (!res) { - console.warn(` [${ecosystem}] ${name}@${version} — registry unavailable`); - return null; - } - try { - const data = await res.json(); - const created = data.version?.created_at; - if (!created) { - console.warn(` [${ecosystem}] ${name}@${version} — no publish time`); - return null; - } - return Date.now() - new Date(created).getTime(); - } catch { - console.warn(` [${ecosystem}] ${name}@${version} — parse error`); - return null; - } -} - -// ── Cargo: resolve workspace crates against Cargo.lock ─────────────── - -const LOCK_PATH = resolve(ROOT, "src-tauri", "Cargo.lock"); - -function parseCargoLock(path) { - const text = readFileSync(path, "utf-8"); - const packages = []; - let inPackage = false; - let current = {}; - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (trimmed === "[[package]]") { - if (inPackage) packages.push(current); - inPackage = true; - current = {}; - } else if (inPackage && trimmed.startsWith("name = ")) { - current.name = trimmed.match(/name = "(.+)"/)?.[1]; - } else if (inPackage && trimmed.startsWith("version = ")) { - current.version = trimmed.match(/version = "(.+)"/)?.[1]; - } else if (inPackage && trimmed.startsWith("source = ")) { - current.source = trimmed.match(/source = "(.+)"/)?.[1]; - } else if (inPackage && trimmed === "") { - // empty line within package block is fine, skip - } else if (inPackage && !trimmed.startsWith("[")) { - // skip other fields - } else if (inPackage && trimmed.startsWith("[") && trimmed !== "[[package]]") { - packages.push(current); - inPackage = false; - current = {}; - } - } - if (inPackage) packages.push(current); - return packages.filter((p) => p.name && p.version); -} - -let lockPkgs; -try { - lockPkgs = parseCargoLock(LOCK_PATH); -} catch { - console.warn("[cargo] failed to parse Cargo.lock — skipping"); - lockPkgs = null; -} - -function lookupInLock(name) { - return lockPkgs?.find((p) => p.name === name && p.source?.startsWith("registry+")); -} - -function resolveCargoVersion(crateName) { - const pkg = lookupInLock(crateName); - return pkg ? { version: pkg.version, source: pkg.source } : null; -} - -// Get workspace crate dependency names from Cargo.toml manifests -function parseWorkspaceDepNames() { - const workspaceDir = resolve(ROOT, "src-tauri", "crates"); - const crates = ["mas-core", "mas-export", "mas-admin"]; - const manifests = [ - resolve(ROOT, "src-tauri", "Cargo.toml"), - ...crates.map((c) => resolve(workspaceDir, c, "Cargo.toml")), - ]; - const names = new Set(); - for (const manifest of manifests) { - try { - const toml = readFileSync(manifest, "utf-8"); - let inDeps = false; - for (const line of toml.split("\n")) { - const trimmed = line.trim(); - if (trimmed === "[dependencies]") { inDeps = true; continue; } - if (inDeps && trimmed.startsWith("[")) break; - if (inDeps) { - const m = trimmed.match(/^(\S+)\s*=/); - if (m) names.add(m[1]); - } - } - } catch { /* skip */ } - } - // Exclude path-only deps (workspace members) - for (const local of ["mas-core", "mas-export", "mas-admin", "mas-ai"]) { - names.delete(local); - } - return names; -} - -const cargoChecks = []; -if (lockPkgs) { - const depNames = parseWorkspaceDepNames(); - for (const name of depNames) { - const resolved = resolveCargoVersion(name); - if (!resolved) continue; - cargoChecks.push({ - name, version: resolved.version, ecosystem: "cargo", - url: `https://crates.io/api/v1/crates/${encodeURIComponent(name)}/${encodeURIComponent(resolved.version)}`, - }); - } -} - -console.log(`[cargo] Checking ${cargoChecks.length} direct dependencies (min age: ${MIN_AGE_DAYS}d)...`); -console.log(`[npm] Enforcement at resolver via .npmrc min-release-age=${MIN_AGE_DAYS} (no script-side check).`); -const cargoResults = await checkAll(cargoChecks); -for (const r of cargoResults) { - if (r.age < MIN_AGE_MS) { - const days = (r.age / (24 * 60 * 60 * 1000)).toFixed(1); - console.error(` [cargo] ${r.name}@${r.version} too new (${days}d, need >=${MIN_AGE_DAYS}d)`); - failures++; - } -} - -if (failures > 0) { - console.error(`\n${failures} cargo dep(s) younger than ${MIN_AGE_DAYS} days. Blocked.`); - process.exit(1); -} - -console.log(`All cargo dependencies are >=${MIN_AGE_DAYS} days old.`);