diff --git a/.github/workflows/fix-dependabot-alerts.yml b/.github/workflows/fix-dependabot-alerts.yml index e2e0dec04..6da5c9925 100644 --- a/.github/workflows/fix-dependabot-alerts.yml +++ b/.github/workflows/fix-dependabot-alerts.yml @@ -122,6 +122,11 @@ jobs: id: fix env: GH_TOKEN: ${{ steps.app-token.outputs.token }} + # Enforce the org's 7-day release-age policy in every workspace the + # script processes. Without this, workspaces that lack a + # pnpm-workspace.yaml (e.g. docs/) would fall back to 0 (disabled) + # and could still pin versions published fewer than 7 days ago. + DEPENDABOT_MIN_RELEASE_AGE_DAYS: "7" run: | SCRIPT="$GITHUB_WORKSPACE/ts/tools/scripts/fix-dependabot-alerts.mjs" FLAGS="${{ inputs.auto-fix-args || '--auto-fix' }}" @@ -130,10 +135,13 @@ jobs: ALL_SKIPPED_RECENT_ROLLBACK="" ALL_BLOCKED_PACKAGES="" ALL_NO_PATCH_PACKAGES="" + ALL_DEFERRED_PACKAGES="" ALL_FAILED_WORKSPACES="" TOTAL_RESOLVED=0 TOTAL_BLOCKED=0 TOTAL_NO_PATCH=0 + TOTAL_DEFERRED=0 + MIN_AGE_DAYS="" TOTAL_FAILED=0 TOTAL_SKIPPED=0 @@ -218,8 +226,17 @@ jobs: WS_BLOCKED=$(jq '.summary.blocked' /tmp/dep-analysis.json) WS_NO_PATCH=$(jq '.summary.noPatch' /tmp/dep-analysis.json) + WS_DEFERRED=$(jq '.summary.deferred // 0' /tmp/dep-analysis.json) BLOCKED_PKGS=$(jq -r '[.blocked[].package] | unique | join(", ")' /tmp/dep-analysis.json) NO_PATCH_PKGS=$(jq -r '[.noPatch[].package] | unique | join(", ")' /tmp/dep-analysis.json) + # Deferred packages carry an eligibility date; show it inline so + # reviewers know when each fix becomes adoptable. + DEFERRED_PKGS=$(jq -r '[.deferred[]? | .package + (if .deferredUntil then " (eligible " + .deferredUntil[0:10] + ")" else "" end)] | unique | join(", ")' /tmp/dep-analysis.json) + # Minimum release age (days) the script deferred against — the same + # across workspaces; capture the first non-empty value for display. + if [ -z "$MIN_AGE_DAYS" ]; then + MIN_AGE_DAYS=$(jq -r '.minReleaseAgeDays // empty' /tmp/dep-analysis.json) + fi # If there are blocked packages, capture --show-chains output # for the PR body so reviewers can see why each was blocked @@ -243,11 +260,13 @@ jobs: echo "Fixable $WS_DIR packages ($FIXABLE_COUNT): $FIXABLE" echo "::endgroup::" - # Accumulate blocked/no-patch + # Accumulate blocked/no-patch/deferred TOTAL_BLOCKED=$((TOTAL_BLOCKED + WS_BLOCKED)) TOTAL_NO_PATCH=$((TOTAL_NO_PATCH + WS_NO_PATCH)) + TOTAL_DEFERRED=$((TOTAL_DEFERRED + WS_DEFERRED)) [ -n "$BLOCKED_PKGS" ] && ALL_BLOCKED_PACKAGES="${ALL_BLOCKED_PACKAGES:+$ALL_BLOCKED_PACKAGES, }$BLOCKED_PKGS" [ -n "$NO_PATCH_PKGS" ] && ALL_NO_PATCH_PACKAGES="${ALL_NO_PATCH_PACKAGES:+$ALL_NO_PATCH_PACKAGES, }$NO_PATCH_PKGS" + [ -n "$DEFERRED_PKGS" ] && ALL_DEFERRED_PACKAGES="${ALL_DEFERRED_PACKAGES:+$ALL_DEFERRED_PACKAGES, }$DEFERRED_PKGS" if [ "$FIXABLE_COUNT" -eq 0 ]; then echo "No fixable alerts in $WS_DIR" @@ -392,6 +411,8 @@ jobs: echo "resolved=$TOTAL_RESOLVED" >> "$GITHUB_OUTPUT" echo "blocked=$TOTAL_BLOCKED" >> "$GITHUB_OUTPUT" echo "no_patch=$TOTAL_NO_PATCH" >> "$GITHUB_OUTPUT" + echo "deferred=$TOTAL_DEFERRED" >> "$GITHUB_OUTPUT" + echo "min_release_age_days=$MIN_AGE_DAYS" >> "$GITHUB_OUTPUT" echo "failed=$TOTAL_FAILED" >> "$GITHUB_OUTPUT" echo "skipped=$TOTAL_SKIPPED" >> "$GITHUB_OUTPUT" echo "applied_packages=$ALL_APPLIED" >> "$GITHUB_OUTPUT" @@ -399,6 +420,7 @@ jobs: echo "skipped_packages=$ALL_SKIPPED_RECENT_ROLLBACK" >> "$GITHUB_OUTPUT" echo "blocked_packages=$ALL_BLOCKED_PACKAGES" >> "$GITHUB_OUTPUT" echo "no_patch_packages=$ALL_NO_PATCH_PACKAGES" >> "$GITHUB_OUTPUT" + echo "deferred_packages=$ALL_DEFERRED_PACKAGES" >> "$GITHUB_OUTPUT" echo "failed_workspaces=$ALL_FAILED_WORKSPACES" >> "$GITHUB_OUTPUT" cd "$GITHUB_WORKSPACE" @@ -473,6 +495,7 @@ jobs: Applied:${{ steps.fix.outputs.applied_packages }} Rolled back:${{ steps.fix.outputs.rolled_back_packages || ' (none)' }} Blocked: ${{ steps.fix.outputs.blocked }} package(s) + Deferred (min release age): ${{ steps.fix.outputs.deferred || '0' }} package(s) Shell packaging: ${{ steps.shell.outputs.shell_ok == 'true' && 'passed' || 'skipped' }} Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" @@ -507,6 +530,8 @@ jobs: FAILED="${{ steps.fix.outputs.failed }}" BLOCKED_PKGS="${{ steps.fix.outputs.blocked_packages }}" NO_PATCH_PKGS="${{ steps.fix.outputs.no_patch_packages }}" + DEFERRED_PKGS="${{ steps.fix.outputs.deferred_packages }}" + DEFER_AGE="${{ steps.fix.outputs.min_release_age_days }}" BODY=$(cat <<'PREOF' ## Automated Dependabot Alert Remediation @@ -522,6 +547,7 @@ jobs: - **Applied ($RESOLVED):**$APPLIED - **Blocked ($BLOCKED):**${BLOCKED_PKGS:- (none)} - **No patch available (${{ steps.fix.outputs.no_patch }}):**${NO_PATCH_PKGS:- (none)} + - **Deferred — fix not yet ${DEFER_AGE:-7} days old (${{ steps.fix.outputs.deferred || '0' }}):**${DEFERRED_PKGS:- (none)} - **Rolled back ($FAILED):**${ROLLED:- (none)} - **Skipped (recent rollback, ${{ steps.fix.outputs.skipped }}):**${{ steps.fix.outputs.skipped_packages || ' (none)' }} - **Workspaces with analysis failures:**${{ steps.fix.outputs.failed_workspaces && format(' {0}', steps.fix.outputs.failed_workspaces) || ' (none)' }} @@ -531,6 +557,14 @@ jobs: > _Note: the analysis source (\`fix-dependabot-alerts.mjs\`) is broader than the GitHub Dependabot REST API — it also audits the lockfile directly. Some packages listed above may not have a corresponding open Dependabot alert, and vice versa._ " + # Explain deferred fixes when present so a waiting security fix isn't + # mistaken for a missing one. + if [ "${{ steps.fix.outputs.deferred || '0' }}" -gt 0 ]; then + BODY="$BODY + > _⏸ Deferred fixes require a package version that has been published for at least ${DEFER_AGE:-7} days (enforced by pnpm's \`minimumReleaseAge\` and the org release-age policy). They are intentionally **not** applied yet and will be picked up automatically on a later run once a qualifying version matures._ + " + fi + # Embed dependency chain output for blocked packages so reviewers # can see why each package couldn't be auto-fixed. if [ -s /tmp/all-blocked-chains.txt ]; then @@ -577,6 +611,7 @@ jobs: echo "| Applied | ${{ steps.fix.outputs.resolved || '0' }} |" >> "$GITHUB_STEP_SUMMARY" echo "| Blocked | ${{ steps.fix.outputs.blocked || '0' }} |" >> "$GITHUB_STEP_SUMMARY" echo "| No patch available | ${{ steps.fix.outputs.no_patch || '0' }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Deferred (min release age) | ${{ steps.fix.outputs.deferred || '0' }} |" >> "$GITHUB_STEP_SUMMARY" echo "| Rolled back | ${{ steps.fix.outputs.failed || '0' }} |" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" if [ -n "${{ steps.fix.outputs.applied_packages }}" ]; then @@ -585,6 +620,9 @@ jobs: if [ -n "${{ steps.fix.outputs.rolled_back_packages }}" ]; then echo "**Rolled back:**${{ steps.fix.outputs.rolled_back_packages }}" >> "$GITHUB_STEP_SUMMARY" fi + if [ -n "${{ steps.fix.outputs.deferred_packages }}" ]; then + echo "**⏸ Deferred (waiting for minimum release age):** ${{ steps.fix.outputs.deferred_packages }}" >> "$GITHUB_STEP_SUMMARY" + fi if [ -n "${{ steps.fix.outputs.failed_workspaces }}" ]; then echo "**⚠️ Analysis failed for workspaces:** ${{ steps.fix.outputs.failed_workspaces }}" >> "$GITHUB_STEP_SUMMARY" fi diff --git a/ts/packages/config/package.json b/ts/packages/config/package.json index 66caf10f7..340dfb041 100644 --- a/ts/packages/config/package.json +++ b/ts/packages/config/package.json @@ -40,7 +40,7 @@ "@azure/keyvault-secrets": "^4.9.0", "debug": "^4.4.0", "dotenv": "^16.3.1", - "js-yaml": "^4.3.0", + "js-yaml": "^4.2.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/ts/packages/shell/package.json b/ts/packages/shell/package.json index 20129b69a..40b1d188a 100644 --- a/ts/packages/shell/package.json +++ b/ts/packages/shell/package.json @@ -82,7 +82,7 @@ "dotenv": "^16.3.1", "electron-updater": "^6.6.2", "jose": "^5.9.6", - "js-yaml": "^4.3.0", + "js-yaml": "^4.2.0", "markdown-it": "^14.2.0", "microsoft-cognitiveservices-speech-sdk": "^1.38.0", "typeagent": "workspace:*", diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index 08f53f610..3475a6c7b 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -4204,8 +4204,8 @@ importers: specifier: ^16.3.1 version: 16.5.0 js-yaml: - specifier: ^4.3.0 - version: 4.3.0 + specifier: ^4.2.0 + version: 4.2.0 zod: specifier: ^3.23.8 version: 3.25.76 @@ -5530,8 +5530,8 @@ importers: specifier: ^5.9.6 version: 5.10.0 js-yaml: - specifier: ^4.3.0 - version: 4.3.0 + specifier: ^4.2.0 + version: 4.2.0 markdown-it: specifier: ^14.2.0 version: 14.2.0 @@ -6247,8 +6247,8 @@ importers: specifier: ^4.4.0 version: 4.4.1 js-yaml: - specifier: ^4.3.0 - version: 4.3.0 + specifier: ^4.2.0 + version: 4.2.0 lodash-es: specifier: ^4.18.1 version: 4.18.1 @@ -13246,16 +13246,16 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.0: - resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true jsbi@2.0.5: @@ -19182,7 +19182,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.15.0 + js-yaml: 3.14.2 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.3': {} @@ -19709,7 +19709,7 @@ snapshots: dependencies: fast-glob: 3.3.3 jju: 1.4.0 - js-yaml: 4.3.0 + js-yaml: 4.2.0 '@marijn/find-cluster-break@1.0.2': {} @@ -21077,7 +21077,7 @@ snapshots: '@textlint/types': 14.7.1 chalk: 4.1.2 debug: 4.4.3(supports-color@8.1.1) - js-yaml: 3.15.0 + js-yaml: 3.14.2 lodash: 4.18.1 pluralize: 2.0.0 string-width: 4.2.3 @@ -22384,7 +22384,7 @@ snapshots: hosted-git-info: 4.1.0 isbinaryfile: 5.0.0 jiti: 2.5.1 - js-yaml: 4.3.0 + js-yaml: 4.2.0 json5: 2.2.3 lazy-val: 1.0.5 minimatch: 10.2.4 @@ -22820,7 +22820,7 @@ snapshots: fs-extra: 10.1.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - js-yaml: 4.3.0 + js-yaml: 4.2.0 sanitize-filename: 1.6.3 source-map-support: 0.5.21 stat-mode: 1.0.0 @@ -23314,7 +23314,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.4.5): dependencies: import-fresh: 3.3.0 - js-yaml: 4.3.0 + js-yaml: 4.2.0 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -23324,7 +23324,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.0 - js-yaml: 4.3.0 + js-yaml: 4.2.0 parse-json: 5.2.0 optionalDependencies: typescript: 5.4.5 @@ -23868,7 +23868,7 @@ snapshots: builder-util: 26.8.1 fs-extra: 10.1.0 iconv-lite: 0.6.3 - js-yaml: 4.3.0 + js-yaml: 4.2.0 optionalDependencies: dmg-license: 1.0.11 transitivePeerDependencies: @@ -24022,7 +24022,7 @@ snapshots: dependencies: builder-util-runtime: 9.3.1 fs-extra: 10.1.0 - js-yaml: 4.3.0 + js-yaml: 4.2.0 lazy-val: 1.0.5 lodash.escaperegexp: 4.1.2 lodash.isequal: 4.5.0 @@ -26322,7 +26322,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.15.0: + js-yaml@3.14.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 @@ -26331,7 +26331,7 @@ snapshots: dependencies: argparse: 2.0.1 - js-yaml@4.3.0: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -27410,7 +27410,7 @@ snapshots: glob: 10.5.0 he: 1.2.0 is-path-inside: 3.0.3 - js-yaml: 4.3.0 + js-yaml: 4.2.0 log-symbols: 4.1.0 minimatch: 9.0.9 ms: 2.1.3 @@ -28361,7 +28361,7 @@ snapshots: rc-config-loader@4.1.3: dependencies: debug: 4.4.3(supports-color@8.1.1) - js-yaml: 4.3.0 + js-yaml: 4.2.0 json5: 2.2.3 require-from-string: 2.0.2 transitivePeerDependencies: @@ -30528,7 +30528,7 @@ snapshots: '@oozcitak/dom': 2.0.2 '@oozcitak/infra': 2.0.2 '@oozcitak/util': 10.0.0 - js-yaml: 4.3.0 + js-yaml: 4.2.0 xmlbuilder@11.0.1: {} diff --git a/ts/pnpm-workspace.yaml b/ts/pnpm-workspace.yaml index b0cb51515..5e33d498a 100644 --- a/ts/pnpm-workspace.yaml +++ b/ts/pnpm-workspace.yaml @@ -18,6 +18,16 @@ packages: - tools - tools/* +# Require dependency versions to have been published at least 7 days +# (10080 minutes) ago before pnpm will resolve them. This keeps freshly +# published versions out of the lockfile until they have propagated to +# downstream mirrors/feeds and satisfied the organization's minimum +# package-age window, and it reduces exposure to compromised just-published +# releases. The constraint is applied at resolution time to all direct and +# transitive dependencies; installs from an existing frozen lockfile are not +# re-resolved and are therefore unaffected. +minimumReleaseAge: 10080 + onlyBuiltDependencies: - "@azure/msal-node-extensions" - "@azure/msal-node-runtime" diff --git a/ts/tools/package.json b/ts/tools/package.json index 20a1ecd14..df85bcf1b 100644 --- a/ts/tools/package.json +++ b/ts/tools/package.json @@ -20,7 +20,7 @@ "@typeagent/config": "workspace:*", "chalk": "^5.3.0", "debug": "^4.4.0", - "js-yaml": "^4.3.0", + "js-yaml": "^4.2.0", "lodash-es": "^4.18.1", "semver": "^7.7.2", "sort-package-json": "^3.0.0", diff --git a/ts/tools/scripts/fix-dependabot-alerts.mjs b/ts/tools/scripts/fix-dependabot-alerts.mjs index 187807a4e..54f8a1b41 100644 --- a/ts/tools/scripts/fix-dependabot-alerts.mjs +++ b/ts/tools/scripts/fix-dependabot-alerts.mjs @@ -13,10 +13,27 @@ import { spawnSync, execFile, execFileSync } from "node:child_process"; import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; import { AsyncLocalStorage } from "node:async_hooks"; import chalk from "chalk"; import semver from "semver"; +// True only when this file is run directly (node fix-dependabot-alerts.mjs …), +// false when imported for unit testing. Computed once up front so import-time +// argument validation and any process.exit() can be skipped under a test +// runner, which may inject its own --* argv flags. Paths are compared +// case-insensitively on Windows so casing/separator differences don't produce +// a false negative. +function invokedDirectly() { + if (!process.argv[1]) return false; + const self = resolve(fileURLToPath(import.meta.url)); + const argv = resolve(process.argv[1]); + return process.platform === "win32" + ? self.toLowerCase() === argv.toLowerCase() + : self === argv; +} +const IS_MAIN = invokedDirectly(); + // Derive ROOT from the git root + workspace prefix so running from a // subdirectory (e.g. ts/tools) still targets the correct workspace root. function detectWorkspaceRoot() { @@ -53,6 +70,7 @@ const KNOWN_FLAG_PREFIXES = [ "--skip-install", "--json", "--verbose", + "--min-release-age-days", "--help", ]; const unknownFlags = args.filter( @@ -62,7 +80,7 @@ const unknownFlags = args.filter( (prefix) => a === prefix || a.startsWith(prefix + "="), ), ); -if (unknownFlags.length > 0) { +if (IS_MAIN && unknownFlags.length > 0) { console.error( `Error: unrecognized flag(s): ${unknownFlags.join(", ")}\nRun with --help to see available options.`, ); @@ -128,7 +146,60 @@ const SKIP_INSTALL = args.includes("--skip-install"); const JSON_OUTPUT = args.includes("--json"); const VERBOSE = args.includes("--verbose"); -if (args.includes("--help")) { +// Minimum age (in days) a published version must have before it is eligible +// to be adopted as a fix. When a security fix would require a version younger +// than this, the fix is deferred (reported, not applied) rather than pinned, +// so the generated lockfile never references a version that pnpm's +// `minimumReleaseAge` resolution guard — or a device/registry release-age +// policy — would reject at install time. A value of 0 disables deferral. +// +// Resolution order (first match wins): +// 1. --min-release-age-days=N flag +// 2. DEPENDABOT_MIN_RELEASE_AGE_DAYS env var +// 3. `minimumReleaseAge` (minutes) in pnpm-workspace.yaml, converted to days +// 4. 0 (disabled) +function readMinReleaseAgeDays({ + argv = args, + env = process.env, + root = ROOT, +} = {}) { + const flag = argv.find((a) => a.startsWith("--min-release-age-days=")); + if (flag) { + const n = Number(flag.slice("--min-release-age-days=".length)); + if (Number.isFinite(n) && n >= 0) return n; + } + const envVal = env.DEPENDABOT_MIN_RELEASE_AGE_DAYS; + if (envVal !== undefined && envVal !== "") { + const n = Number(envVal); + if (Number.isFinite(n) && n >= 0) return n; + } + try { + const ws = readFileSync(resolve(root, "pnpm-workspace.yaml"), "utf-8"); + // Top-level `minimumReleaseAge: ` scalar (optionally quoted, + // trailing comment allowed). + const m = ws.match( + /^minimumReleaseAge:\s*["']?(\d+)["']?\s*(?:#.*)?$/m, + ); + if (m) return Number(m[1]) / (60 * 24); + // Present but unparseable: warn rather than silently disabling the + // release-age gate, which would let too-new fixes through unnoticed. + // console.warn goes to stderr, so it never corrupts --json stdout. + if (/^\s*minimumReleaseAge:/m.test(ws)) { + console.warn( + "[fix-dependabot-alerts] Could not parse minimumReleaseAge in " + + "pnpm-workspace.yaml; release-age deferral is disabled.", + ); + } + } catch { + /* no workspace file or unreadable — deferral stays disabled */ + } + return 0; +} +const MIN_RELEASE_AGE_DAYS = readMinReleaseAgeDays(); +const MIN_RELEASE_AGE_MS = MIN_RELEASE_AGE_DAYS * 24 * 60 * 60 * 1000; +export { readMinReleaseAgeDays, MIN_RELEASE_AGE_DAYS }; + +if (IS_MAIN && args.includes("--help")) { console.log(`Usage: node tools/scripts/fix-dependabot-alerts.mjs [options] Options: @@ -159,6 +230,12 @@ Options: --json Output results as structured JSON (for CI integration) --verbose Show detailed constraint analysis, advisory IDs, and debug output + --min-release-age-days=N + Defer fixes that would require a package version + published fewer than N days ago, instead of pinning a + too-new version. Defaults to the DEPENDABOT_MIN_RELEASE_AGE_DAYS + env var, else pnpm-workspace.yaml's minimumReleaseAge + (minutes) converted to days, else 0 (disabled). --help Show this help message and exit Exit codes: @@ -168,7 +245,7 @@ Exit codes: process.exit(0); } -if (PRUNE_OVERRIDES && (APPLY_OVERRIDES || UPDATE_PARENTS)) { +if (IS_MAIN && PRUNE_OVERRIDES && (APPLY_OVERRIDES || UPDATE_PARENTS)) { console.error( "Error: --prune-overrides cannot be combined with --apply-overrides or --update-parents.\n" + "Run fixes first, then re-run with --prune-overrides to clean up stale entries.", @@ -1071,14 +1148,16 @@ function getWorkspaceDepInfo(workspaceName, depPkg) { } /** - * Fetch `versions` and `dist-tags.latest` for a package from npm. - * @returns {{ versions: string[], latest: string|null } | null} + * Fetch `versions`, `dist-tags.latest`, and per-version publish `time` + * for a package from npm. + * @returns {{ versions: string[], latest: string|null, + * time: Record|null } | null} */ const getNpmInfo = cachedAsync("getNpmInfo", { fetchFn: async (pkgName) => { const output = await runCmdAsync( "npm", - ["view", pkgName, "versions", "dist-tags.latest", "--json"], + ["view", pkgName, "versions", "dist-tags.latest", "time", "--json"], { nothrow: true }, ); if (!output) return null; @@ -1086,11 +1165,93 @@ const getNpmInfo = cachedAsync("getNpmInfo", { return { versions: raw.versions || [], latest: raw["dist-tags.latest"] || raw["dist-tags"]?.latest || null, + time: raw.time || null, }; }, semaphore: _npmSem, }); +/** + * Assess whether a published version of `pkg` satisfying `>=patched` is old + * enough to adopt, per the configured minimum release age (MIN_RELEASE_AGE_MS). + * + * pnpm resolves an override/range to the highest satisfying version that also + * meets `minimumReleaseAge`; if none qualifies, resolution fails outright. + * This mirrors that policy at planning time: the fix is "eligible" when at + * least one satisfying version is already old enough, otherwise it reports the + * earliest time the fix becomes adoptable so the caller can defer it. + * + * @param {string} pkg + * @param {string} patched - minimum safe version from the advisory + * @returns {Promise<{ eligible: boolean, matureVersion: string|null, + * eligibleAtMs: number|null }>} + */ +async function assessFixMaturity(pkg, patched) { + const notDeferred = { + eligible: true, + matureVersion: null, + eligibleAtMs: null, + }; + if (MIN_RELEASE_AGE_MS <= 0) return notDeferred; + if (!patched || !semver.valid(patched)) return notDeferred; + + const info = await getNpmInfo(pkg); + // Without publish-time metadata we cannot judge age; don't block the fix + // (matches pnpm's lenient handling of registries that omit `time`). + if (!info?.time) return notDeferred; + + const now = Date.now(); + const publishedMs = (v) => { + const t = info.time[v]; + const ms = t ? Date.parse(t) : NaN; + return Number.isFinite(ms) ? ms : null; + }; + // Use range semantics (not a bare version comparison) so the assessment + // matches what pnpm will actually resolve for the `>=${patched}` override — + // e.g. prereleases are excluded unless the range opts into them, so a fresh + // `2.1.0-beta.1` never masquerades as a mature fix for `>=2.0.0`. + const range = `>=${patched}`; + const satisfying = (info.versions || []).filter( + (v) => semver.valid(v) && semver.satisfies(v, range), + ); + // No published version satisfies the fix range — nothing to defer; let the + // normal fix path handle it. + if (satisfying.length === 0) return notDeferred; + + const mature = satisfying + .filter((v) => { + const ms = publishedMs(v); + return ms !== null && now - ms >= MIN_RELEASE_AGE_MS; + }) + .sort(semver.rcompare); + if (mature.length > 0) { + return { eligible: true, matureVersion: mature[0], eligibleAtMs: null }; + } + + // If any satisfying version lacks usable publish-time metadata we cannot + // prove it is too new, so stay lenient and let pnpm make the final call at + // resolution time. This prevents deferring a fix indefinitely just because + // the registry omitted a `time` entry for a version. + const times = satisfying.map(publishedMs); + if (times.some((ms) => ms === null)) return notDeferred; + + // Every satisfying version is known and too new. The earliest a fix can be + // adopted is when the oldest satisfying version crosses the age floor. + const eligibleAtMs = Math.min(...times) + MIN_RELEASE_AGE_MS; + return { eligible: false, matureVersion: null, eligibleAtMs }; +} + +/** Human-readable eligibility note for a deferred fix. */ +function formatDeferralEta(eligibleAtMs) { + if (!eligibleAtMs) return `not yet ${MIN_RELEASE_AGE_DAYS}d old`; + const date = new Date(eligibleAtMs).toISOString().slice(0, 10); + const daysLeft = Math.max( + 0, + Math.ceil((eligibleAtMs - Date.now()) / (24 * 60 * 60 * 1000)), + ); + return `eligible ${date} (~${daysLeft}d)`; +} + /** * Find the smallest published version of `pkgName` newer than * `currentVersion` whose dependency on `depPkg` allows (or drops) @@ -2258,6 +2419,16 @@ async function formatPackageAnalysis(entry, whyData, pkgIndex, pkgTotal) { `\n ${progress} \ud83d\udce6 ${clr.pkg.bold(pkg)} (${colorSeverity(severity)}) ${clr.meta("—")} ${versionGap} ${clr.meta("\u2192")} need ${clr.versionOk(`\u2265${patched}`)}`, ); + // Deferred: a fix exists but no published version is old enough to adopt + // yet. Show the note and skip the action lines (applying now would be + // rejected at install time by the minimum-release-age guard). + if (entry.deferred) { + log( + ` ${clr.warn("\u23f8 deferred")} ${clr.meta("\u2014")} no published ${clr.pkg(pkg)} ${clr.versionOk(`\u2265${patched}`)} is ${MIN_RELEASE_AGE_DAYS}d old yet; ${clr.meta(formatDeferralEta(entry.deferred.eligibleAtMs))}`, + ); + return; + } + // ── Advisory IDs (verbose only) ────────────────────────────────────── if (VERBOSE) { const uniqueGhsaIds = [...new Set(ghsaIds)]; @@ -2328,6 +2499,23 @@ async function classifyWithFixPlan(entry, whyData) { ); } } + + // Defer fixes whose required version is not yet old enough to adopt. + // Only entries that would otherwise be applied are eligible for deferral + // (a fix plan exists and nothing else blocks it), so deferral never hides a + // real blocker or reclassifies an already-fixed package. Resolving to a + // too-new version would be rejected by pnpm's minimumReleaseAge guard (and + // any device/registry release-age policy) at install time, so surface it as + // "come back later" instead of applying it. + if (patched && entry.fixPlan && entry.blockingReasons.length === 0) { + const maturity = await assessFixMaturity(pkg, patched); + if (!maturity.eligible) { + entry.deferred = { + patched, + eligibleAtMs: maturity.eligibleAtMs, + }; + } + } } // ── Stage functions ────────────────────────────────────────────────────────── @@ -2616,7 +2804,7 @@ async function analyzeVulnerabilities(byPackage) { /** Stage 5: Execute resolutions. */ async function executeResolutions(analyses) { const actionable = analyses.filter( - (a) => a.fixPlan && a.blockingReasons.length === 0, + (a) => a.fixPlan && a.blockingReasons.length === 0 && !a.deferred, ); if (actionable.length > 0) { @@ -2626,7 +2814,10 @@ async function executeResolutions(analyses) { const results = { alreadyFixed: analyses.filter((a) => a.patched && !a.fixPlan), resolved: [], - blocked: analyses.filter((a) => a.blockingReasons.length > 0), + deferred: analyses.filter((a) => a.deferred), + blocked: analyses.filter( + (a) => !a.deferred && a.blockingReasons.length > 0, + ), noPatch: analyses.filter((a) => !a.patched), failed: [], }; @@ -3140,6 +3331,8 @@ function printSummary(results) { ), results.blocked.length > 0 && clr.warn(results.blocked.length + " blocked"), + results.deferred.length > 0 && + clr.warn(results.deferred.length + " deferred"), results.noPatch.length > 0 && clr.fail(results.noPatch.length + " no fix available"), results.failed.length > 0 && @@ -3149,6 +3342,24 @@ function printSummary(results) { log(`\n ${summaryParts.join(" | ")}`); } + if (results.deferred.length > 0) { + log( + clr.meta( + `\n Deferred (waiting for ${MIN_RELEASE_AGE_DAYS}-day minimum release age):`, + ), + ); + for (const a of results.deferred) { + log( + ` ${clr.warn("\u23f8")} ${clr.pkg(a.pkg)} ${clr.versionOk(`>=${a.patched}`)} ${clr.meta("\u2014")} ${clr.meta(formatDeferralEta(a.deferred.eligibleAtMs))}`, + ); + } + log( + clr.meta( + " These fixes will apply automatically on a later run once the version is old enough.", + ), + ); + } + if (results.blocked.length > 0) { const parentBlocked = results.blocked.filter( (a) => hasUnblockedActions(a) && !needsOverride(a), @@ -3416,6 +3627,9 @@ function emitJson(results) { latestVersion: a.latestVersion, inShellBundle: isInShellBundle(a.pkg), blockingReasons: a.blockingReasons, + deferredUntil: a.deferred?.eligibleAtMs + ? new Date(a.deferred.eligibleAtMs).toISOString() + : null, risk: a.risk ?? null, fixPlan: a.fixPlan ? { @@ -3431,13 +3645,16 @@ function emitJson(results) { alreadyFixed: results.alreadyFixed.length, resolved: results.resolved.length, blocked: results.blocked.length, + deferred: results.deferred.length, noPatch: results.noPatch.length, failed: results.failed.length, }, dryRun: DRY_RUN, + minReleaseAgeDays: MIN_RELEASE_AGE_DAYS, alreadyFixed: results.alreadyFixed.map(toJson), resolved: results.resolved.map(toJson), blocked: results.blocked.map(toJson), + deferred: results.deferred.map(toJson), noPatch: results.noPatch.map(toJson), failed: results.failed.map(toJson), }; @@ -3473,7 +3690,13 @@ async function main() { } } -main().catch((e) => { - console.error(e.message); - process.exit(1); -}); +export { assessFixMaturity, getNpmInfo }; + +// Kick off a full run only when invoked directly (see IS_MAIN near the top), +// so importing this module for unit tests never triggers a run. +if (IS_MAIN) { + main().catch((e) => { + console.error(e.message); + process.exit(1); + }); +}