diff --git a/.github/workflows/fix-dependabot-alerts.yml b/.github/workflows/fix-dependabot-alerts.yml new file mode 100644 index 00000000..9decf913 --- /dev/null +++ b/.github/workflows/fix-dependabot-alerts.yml @@ -0,0 +1,267 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Automatically remediate Dependabot security alerts by running the +# tools/scripts/fix-dependabot-alerts.mjs script, verifying each fix +# against build + tests, and opening a single squash-PR with the +# passing changes. +# +# Authentication: uses a GitHub App (via actions/create-github-app-token) +# because the Dependabot alerts REST API isn't reachable with the default +# GITHUB_TOKEN. Requires repo or org variables: +# - DEPENDABOT_APP_ID (variable) +# - DEPENDABOT_APP_PRIVATE_KEY (secret) + +name: fix-dependabot-alerts + +on: + schedule: + # Daily at 09:00 UTC + - cron: "0 9 * * *" + workflow_dispatch: + inputs: + dry-run: + description: "Dry run — analyse only, don't apply fixes" + type: boolean + default: false + skip-tests: + description: "Skip 'npm test' during per-fix verification (build only)" + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + +jobs: + fix-alerts: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # Don't leave the default GITHUB_TOKEN in .git/config — the + # remediation script invokes ``npm`` against potentially + # untrusted dependency updates, and we don't want push + # credentials reachable from build/test scripts. + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "npm" + cache-dependency-path: typescript/package-lock.json + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.DEPENDABOT_APP_ID }} + private-key: ${{ secrets.DEPENDABOT_APP_PRIVATE_KEY }} + + - name: Verify gh authentication + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh auth status + # Fail fast if the Dependabot API isn't reachable — otherwise + # the script would see 0 alerts and silently report "nothing + # to do", masking an infra outage as a clean run. + if ! gh api "repos/${{ github.repository }}/dependabot/alerts?per_page=1" --jq 'length'; then + echo "::error::Dependabot API probe failed — aborting before silently misreporting alerts" + exit 1 + fi + + # Restore prior rollback-state so packages rolled back in earlier + # runs (build/test failures) aren't retried until cooldown expires + # or the underlying lockfile SHA changes. + # + # Cache keys must be unique per save (caches are immutable per key), + # so we save under a run-id-suffixed key and restore from the prefix. + - name: Restore rollback state + id: restore-state + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/fix-dependabot-alerts-rollback-state.json + key: fix-dep-rollback-state-v1-${{ github.run_id }} + restore-keys: | + fix-dep-rollback-state-v1- + + - name: Run remediation script + id: fix + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + DEP_ROLLBACK_STATE_PATH: ${{ runner.temp }}/fix-dependabot-alerts-rollback-state.json + run: | + FLAGS="" + if [ "${{ inputs.dry-run }}" != "true" ]; then + FLAGS="$FLAGS --auto-fix" + fi + if [ "${{ inputs.skip-tests }}" = "true" ]; then + FLAGS="$FLAGS --skip-tests" + fi + node tools/scripts/fix-dependabot-alerts.mjs $FLAGS + + # Always persist the (possibly updated) rollback state, even if + # later steps fail — otherwise a rollback recorded this run would + # be forgotten and the same broken upgrade re-tried tomorrow. + - name: Save rollback state + if: always() + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/fix-dependabot-alerts-rollback-state.json + # ``run_id`` is reused across job re-runs; include + # ``run_attempt`` so each attempt gets a unique (immutable) + # cache key. The restore step uses a shared prefix so any + # prior attempt's state is still picked up. + key: fix-dep-rollback-state-v1-${{ github.run_id }}-${{ github.run_attempt }} + + # ── Final clean build verification ────────────────────────────── + # + # The per-fix incremental verification uses ``npm ci`` against an + # already-warm node_modules; tsc consumes ``.tsbuildinfo`` and may + # skip rechecking files that didn't change locally even if a + # transitive .d.ts upgrade would have broken them. A full clean + # build catches those (see microsoft/TypeAgent PR #2422 for the + # analogous pnpm/fluid-build case). + - name: Final clean build verification + if: ${{ steps.fix.outputs.changes == 'true' && inputs.dry-run != 'true' }} + id: build + working-directory: typescript + run: | + rm -rf node_modules out + npm ci --no-audit --no-fund --ignore-scripts + # ``npm test`` already runs ``npm run build`` as its first + # step (see typescript/package.json), so we only call build + # explicitly in --skip-tests mode to avoid duplicating it. + if [ "${{ inputs.skip-tests }}" = "true" ]; then + npm run build + else + npm test + fi + echo "build_ok=true" >> "$GITHUB_OUTPUT" + + # ── Create PR ─────────────────────────────────────────────────── + # + # App tokens expire after 1 hour; the build/verify phase can + # outrun that. Re-mint immediately before any late ``gh`` calls. + - name: Refresh app token + if: ${{ steps.fix.outputs.changes == 'true' && steps.build.outputs.build_ok == 'true' }} + id: app-token-pr + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.DEPENDABOT_APP_ID }} + private-key: ${{ secrets.DEPENDABOT_APP_PRIVATE_KEY }} + + - name: Create pull request + if: ${{ steps.fix.outputs.changes == 'true' && steps.build.outputs.build_ok == 'true' }} + env: + GH_TOKEN: ${{ steps.app-token-pr.outputs.token }} + run: | + BRANCH="automated/fix-dependabot-alerts-$(date +%Y%m%d)-${{ github.run_number }}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add -A + + # Belt-and-suspenders: only commit/push if there are real + # working-tree changes. ``changes=true`` from the script means + # "applied at least one fix"; an upstream no-op update could + # still produce no diff. + if git diff --cached --quiet; then + echo "No actual file changes to commit despite applied fixes — skipping PR." + exit 0 + fi + + git commit -m "fix: remediate Dependabot security alerts + + Automated by fix-dependabot-alerts workflow. + + Applied:${{ steps.fix.outputs.applied_packages }} + Rolled back:${{ steps.fix.outputs.rolled_back_packages }} + Unfixable: ${{ steps.fix.outputs.unfixable_count }} package(s) + + Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" + # Push using the App token explicitly — we disabled + # ``persist-credentials`` on checkout, so .git/config has no + # creds to fall back on. + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git push origin "$BRANCH" + + APPLIED="${{ steps.fix.outputs.applied_packages }}" + OVERRIDES="${{ steps.fix.outputs.applied_overrides }}" + ROLLED="${{ steps.fix.outputs.rolled_back_packages }}" + UNFIXABLE="${{ steps.fix.outputs.unfixable_packages }}" + COOLDOWN="${{ steps.fix.outputs.cooldown_packages }}" + + BODY="## Automated Dependabot Alert Remediation + + This PR was generated by the \`fix-dependabot-alerts\` workflow. + Each fix was applied individually and verified against \`npm ci\`, \`npm run build\`, and \`npm test\` before inclusion. + + ### Summary + - **Applied (${{ steps.fix.outputs.applied_count }}):**${APPLIED:- (none)} + - **Applied via root \`overrides\`:**${OVERRIDES:- (none)} + - **Rolled back (${{ steps.fix.outputs.rolled_back_count }}):**${ROLLED:- (none)} + - **Unfixable via lockfile bump / overrides (${{ steps.fix.outputs.unfixable_count }}):**${UNFIXABLE:- (none)} + - **Skipped (recent rollback cooldown, ${{ steps.fix.outputs.cooldown_count }}):**${COOLDOWN:- (none)} + + > Packages marked **Unfixable** require a parent-package upgrade — the advisory's safe version is outside every direct parent's declared semver range, and a root \`overrides\` entry was either silently ignored by npm or would force an incompatible version. Triage manually. + + > Packages added under \`overrides\` are tracked technical debt — npm will hold them at the pinned version until the entry is removed, which may mask future upstream regressions. Remove the override once a parent has shipped a compatible release. + + ### How this works + 1. Reads open Dependabot alerts via the REST API. + 2. For each alert, attempts in order: \`npm update --package-lock-only\`, then root \`overrides\` entry. + 3. Verifies every resolved instance in \`package-lock.json\` is ≥ the advisory's \`first_patched_version\`. + 4. Runs \`npm ci\`, \`npm run build\`, and \`npm test\`; rolls back on failure and records a 7-day cooldown. + 5. Only fixes that pass all phases land in this PR. + + ### Review checklist + - [ ] Verify no unrelated lockfile churn + - [ ] Investigate any newly-rolled-back packages separately + - [ ] If \`overrides\` were added, confirm the pinned version is acceptable policy + " + + # Create the new PR FIRST, capture its number, THEN close + # superseded PRs. Otherwise a transient ``gh pr create`` failure + # could leave the repo with no open remediation PR. + NEW_PR=$(gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "fix: remediate Dependabot security alerts ($(date +%Y-%m-%d))" \ + --body "$BODY" \ + | tail -1) + echo "Created $NEW_PR" + NEW_PR_NUM=$(echo "$NEW_PR" | grep -oE '[0-9]+$' || true) + + # Best-effort labels (won't fail the workflow if a label is + # missing — the PR itself is the important artifact). + if [ -n "$NEW_PR_NUM" ]; then + gh pr edit "$NEW_PR_NUM" --add-label "dependencies,security,automated" \ + || echo "::warning::Could not apply all labels to PR #$NEW_PR_NUM" + fi + + # Dedup older auto-PRs from this workflow. Match by branch + # prefix using a jq filter (the GH issue-search ``head:`` / + # ``in:branch`` qualifiers are not reliable for prefix + # matching). Exclude the PR we just created. + PREV_PRS=$(gh pr list \ + --state open \ + --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("automated/fix-dependabot-alerts-")) | select(.headRefName != "'"$BRANCH"'") | .number') + if [ -n "$PREV_PRS" ]; then + echo "Closing superseded Dependabot fix PRs: $PREV_PRS" + for PR in $PREV_PRS; do + gh pr close "$PR" \ + --delete-branch \ + --comment "Superseded by #${NEW_PR_NUM:-newer PR}." \ + || echo "::warning::Failed to close PR #$PR" + done + fi diff --git a/tools/scripts/fix-dependabot-alerts.mjs b/tools/scripts/fix-dependabot-alerts.mjs new file mode 100644 index 00000000..57a7ac0f --- /dev/null +++ b/tools/scripts/fix-dependabot-alerts.mjs @@ -0,0 +1,756 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Automated Dependabot alert remediator for the TypeChat (npm) workspace. + * + * Strategy per alert, applied in order until one succeeds (or all fail): + * + * 1. Plain version bump via ``npm update --package-lock-only``. + * Sufficient for direct deps and most transitives whose parents + * accept a higher version under their declared semver range. + * 2. ``overrides`` entry added to ``package.json`` and another + * ``npm install --package-lock-only`` pass. Forces a safe version + * even when no parent's range admits it. Used only when (1) leaves + * vulnerable instances behind. + * + * After each attempt the script re-parses ``package-lock.json`` and + * verifies every resolved instance of the package is ≥ the advisory's + * first_patched_version. If any vulnerable instance remains the attempt + * is treated as failed. + * + * On a successful fix the workspace is reinstalled deterministically + * with ``npm ci``, then built (optionally tested) to catch breakages + * introduced by the upgrade. On any failure — install, build, test, or + * verification — the script restores ``package.json`` and + * ``package-lock.json`` from backups and records the failure in a + * persistent rollback-state file. Future runs skip recently-rolled-back + * packages (default 7 day cooldown) so the same broken upgrade isn't + * re-proposed each night. + * + * Run modes: + * node tools/scripts/fix-dependabot-alerts.mjs # analyze + * node tools/scripts/fix-dependabot-alerts.mjs --auto-fix # apply + * node tools/scripts/fix-dependabot-alerts.mjs --show-chains # explain + * + * Auth: reads alerts via ``gh api repos///dependabot/alerts`` + * which requires a token with ``security_events`` (org-owned repo) or + * a GitHub App installation token. In CI, the workflow mints the latter. + */ + +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, "..", ".."); + +// ── Configuration ──────────────────────────────────────────────────────── +// +// Workspaces (relative to REPO_ROOT) the script knows how to manage. +// Each entry must have its own ``package-lock.json``; transitive overrides +// are written into that workspace's ``package.json``. +const WORKSPACES = [ + { name: "typescript", dir: "typescript" }, +]; + +const RUN_TEMP = process.env.RUNNER_TEMP || tmpdir(); +const ROLLBACK_STATE_PATH = + process.env.DEP_ROLLBACK_STATE_PATH || + join(RUN_TEMP, "fix-dependabot-alerts-rollback-state.json"); +const ROLLBACK_COOLDOWN_DAYS = Number( + process.env.DEP_ROLLBACK_COOLDOWN_DAYS || 7, +); + +// ── Args ───────────────────────────────────────────────────────────────── + +const ARGS = parseArgs(process.argv.slice(2)); +const DRY_RUN = !ARGS.has("auto-fix"); +const SHOW_CHAINS = ARGS.has("show-chains"); +const SKIP_TESTS = ARGS.has("skip-tests"); +const VERBOSE = ARGS.has("verbose"); + +function parseArgs(argv) { + const flags = new Set(); + for (const a of argv) { + if (a.startsWith("--")) flags.add(a.slice(2).split("=")[0]); + } + return flags; +} + +// ── Logging ────────────────────────────────────────────────────────────── + +const log = (...args) => console.log(...args); +const dbg = (...args) => VERBOSE && console.log("[debug]", ...args); +const warn = (...args) => console.warn("⚠️ ", ...args); +const err = (...args) => console.error("❌", ...args); + +// Sensitive env vars that must NOT leak into npm child processes — +// build/test scripts can execute arbitrary code from newly-installed +// dependencies, so we strip authentication before invoking them. +const SENSITIVE_ENV_VARS = [ + "GH_TOKEN", + "GITHUB_TOKEN", + "NPM_TOKEN", + "NODE_AUTH_TOKEN", + "DEPENDABOT_APP_PRIVATE_KEY", +]; +function sanitizedEnv() { + const e = { ...process.env }; + for (const k of SENSITIVE_ENV_VARS) delete e[k]; + return e; +} + +// ── Shell helpers ──────────────────────────────────────────────────────── + +function run(cmd, args, opts = {}) { + // ``shell: true`` is only needed for tools installed as ``.cmd`` / + // ``.ps1`` shims on Windows (npm, pnpm). Real ``.exe`` tools (gh, + // git, node) must NOT use ``shell: true`` on Windows, because cmd.exe + // then interprets shell metacharacters like ``&`` in URLs. + const needsShell = + opts.shell ?? + (process.platform === "win32" && /^(npm|pnpm|yarn|npx)$/i.test(cmd)); + dbg("$", cmd, args.join(" "), " (cwd:", opts.cwd || process.cwd(), ")"); + const r = spawnSync(cmd, args, { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + ...opts, + shell: needsShell, + }); + return { + ok: r.status === 0, + code: r.status, + stdout: r.stdout || "", + stderr: r.stderr || "", + }; +} + +function mustRun(cmd, args, opts = {}) { + const r = run(cmd, args, opts); + if (!r.ok) { + err(`${cmd} ${args.join(" ")} failed (exit ${r.code})`); + if (r.stdout) console.error(r.stdout); + if (r.stderr) console.error(r.stderr); + process.exit(1); + } + return r; +} + +// ── Semver (minimal — just compare a.b.c) ──────────────────────────────── +// +// Sufficient for "is resolved version >= first_patched_version" checks. +// We deliberately avoid pulling in the ``semver`` package so this script +// can run before any ``node_modules`` are installed. + +function parseSemver(v) { + if (!v) return null; + // Capture [major, minor, patch, prereleaseTag-or-empty]. + // Per semver, a version with a prerelease tag is LOWER than the + // same version without (1.2.3-beta.1 < 1.2.3). This matters for + // security verification: a vulnerable prerelease must not be + // treated as satisfying the patched release. + const m = String(v).match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] || ""]; +} + +function semverGte(a, b) { + const pa = parseSemver(a); + const pb = parseSemver(b); + if (!pa || !pb) return false; + for (let i = 0; i < 3; i++) { + if (pa[i] > pb[i]) return true; + if (pa[i] < pb[i]) return false; + } + // Numeric parts equal — compare prerelease tags. Per semver: + // * no prerelease > any prerelease (1.2.3 > 1.2.3-beta) + // * any prerelease < no prerelease (1.2.3-x < 1.2.3) + // * same prerelease => equal (1.2.3-x = 1.2.3-x) + // * different prereleases: we conservatively treat as NOT >= + // unless the strings are exactly equal, since correct + // lexical comparison would require parsing dot-separated + // identifiers and we deliberately avoid that complexity here. + // This errs on the side of failing verification (safe). + const preA = pa[3]; + const preB = pb[3]; + if (preA === preB) return true; // both "" or identical prerelease + if (preA === "") return true; // a is release, b is prerelease + if (preB === "") return false; // a is prerelease, b is release + return false; // distinct prereleases — refuse to claim >= +} + +// ── Dependabot alerts ──────────────────────────────────────────────────── + +function fetchAlerts(repo) { + const r = run("gh", [ + "api", + "--paginate", + `repos/${repo}/dependabot/alerts?state=open&per_page=100`, + ]); + if (!r.ok) { + err("Failed to fetch Dependabot alerts via gh CLI."); + err(r.stderr.trim()); + process.exit(1); + } + // --paginate concatenates JSON arrays as ``][`` between pages. + const raw = r.stdout.trim(); + if (!raw) return []; + const joined = "[" + raw.replace(/\]\s*\[/g, ",").slice(1, -1) + "]"; + try { + return JSON.parse(joined); + } catch (e) { + err("Could not parse gh paginated JSON:", e.message); + process.exit(1); + } +} + +/** + * Group raw alerts by ``(workspace, package)``. Returns a map + * ``{ "/": { workspace, pkg, ecosystem, minVersion, + * severity, alerts: [...] } }``. ``minVersion`` is the highest + * ``first_patched_version`` across this package's open alerts — i.e. + * the lowest version that resolves *all* known advisories. + */ +function groupAlerts(alerts) { + const groups = new Map(); + let skippedNonNpm = 0; + const skippedManifests = new Set(); + for (const a of alerts) { + const eco = a.dependency?.package?.ecosystem; + const pkg = a.dependency?.package?.name; + const mp = a.dependency?.manifest_path; + if (eco !== "npm" || !pkg || !mp) { + skippedNonNpm++; + continue; + } + + // Map the alert's manifest_path to one of our known workspaces. + // Dependabot reports the manifest where the dep is declared, + // which for transitive vulns in our example workspaces will be + // e.g. ``typescript/examples/foo/package.json``. The root + // ``typescript/package-lock.json`` is the single source of truth + // for resolutions, so we collapse all paths under ``typescript/`` + // to the ``typescript`` workspace; non-matching paths are logged + // so a future ecosystem (e.g. python/, site/) doesn't no-op + // silently. + const ws = WORKSPACES.find((w) => { + if ( + mp === `${w.dir}/package-lock.json` || + mp === `${w.dir}/package.json` + ) { + return true; + } + return ( + mp.startsWith(`${w.dir}/`) && + (mp.endsWith("/package.json") || + mp.endsWith("/package-lock.json")) + ); + }); + if (!ws) { + skippedManifests.add(mp); + continue; + } + + const patched = + a.security_vulnerability?.first_patched_version?.identifier; + const key = `${ws.name}|${pkg}`; + if (!groups.has(key)) { + groups.set(key, { + workspace: ws, + pkg, + ecosystem: eco, + minVersion: patched || null, + severity: a.security_advisory?.severity || "unknown", + alerts: [], + }); + } + const g = groups.get(key); + g.alerts.push(a); + if (patched && (!g.minVersion || semverGte(patched, g.minVersion))) { + g.minVersion = patched; + } + // Track worst severity (high > medium > low). + const sevRank = { critical: 4, high: 3, medium: 2, low: 1, unknown: 0 }; + if ( + sevRank[a.security_advisory?.severity] > + sevRank[g.severity] + ) { + g.severity = a.security_advisory.severity; + } + } + if (skippedManifests.size > 0) { + warn( + `Ignored ${skippedManifests.size} alert manifest(s) outside known workspaces: ${[...skippedManifests].sort().join(", ")}`, + ); + } + if (skippedNonNpm > 0) { + dbg(`Ignored ${skippedNonNpm} non-npm or malformed alert(s)`); + } + return [...groups.values()]; +} + +// ── Lockfile inspection ────────────────────────────────────────────────── +// +// npm v7+ writes a flat ``packages`` map keyed by install path. Each entry +// has a ``version`` field — that's the resolved version. We collect every +// resolved version for a package across the lockfile so the verifier can +// confirm *all* instances are above the advisory threshold. + +function getResolvedVersions(workspaceDir, pkg) { + const lockPath = join(REPO_ROOT, workspaceDir, "package-lock.json"); + if (!existsSync(lockPath)) return []; + let lock; + try { + lock = JSON.parse(readFileSync(lockPath, "utf8")); + } catch { + return []; + } + const versions = new Set(); + const packages = lock.packages || {}; + // The flat packages map keys look like ``node_modules/`` for the + // top-level install, ``node_modules//node_modules/`` for + // nested, and ``""`` (no node_modules prefix) for the + // workspace itself. We just need entries whose key ends in + // ``node_modules/``. + const suffix = `node_modules/${pkg}`; + for (const [path, info] of Object.entries(packages)) { + if (path === suffix || path.endsWith(`/${suffix}`)) { + if (info?.version) versions.add(info.version); + } + } + return [...versions]; +} + +function verifyAllVersionsFixed(workspaceDir, pkg, minVersion) { + const versions = getResolvedVersions(workspaceDir, pkg); + if (versions.length === 0) { + return { ok: false, reason: "package not found in lockfile" }; + } + const unfixed = versions.filter((v) => !semverGte(v, minVersion)); + return { + ok: unfixed.length === 0, + versions, + unfixed, + }; +} + +// ── Backup / restore ───────────────────────────────────────────────────── + +function backupWorkspace(workspaceDir) { + const wsRoot = join(REPO_ROOT, workspaceDir); + const pkgBak = join(RUN_TEMP, "tc-pkg-backup.json"); + const lockBak = join(RUN_TEMP, "tc-lock-backup.json"); + copyFileSync(join(wsRoot, "package.json"), pkgBak); + if (existsSync(join(wsRoot, "package-lock.json"))) { + copyFileSync(join(wsRoot, "package-lock.json"), lockBak); + } + return { pkgBak, lockBak, wsRoot }; +} + +function restoreWorkspace({ pkgBak, lockBak, wsRoot }) { + // Restore files only. Do NOT re-run ``npm install`` here — that + // would re-resolve and potentially re-introduce drift. + copyFileSync(pkgBak, join(wsRoot, "package.json")); + if (existsSync(lockBak)) { + copyFileSync(lockBak, join(wsRoot, "package-lock.json")); + } +} + +// ── Rollback state ─────────────────────────────────────────────────────── + +function loadRollbackState() { + if (!existsSync(ROLLBACK_STATE_PATH)) { + return { version: 1, rollbacks: {} }; + } + try { + return JSON.parse(readFileSync(ROLLBACK_STATE_PATH, "utf8")); + } catch { + return { version: 1, rollbacks: {} }; + } +} + +function saveRollbackState(state) { + writeFileSync(ROLLBACK_STATE_PATH, JSON.stringify(state, null, 2)); +} + +function lockSha(workspaceDir) { + const p = join(REPO_ROOT, workspaceDir, "package-lock.json"); + if (!existsSync(p)) return "no-lock"; + const r = run("git", ["hash-object", p]); + return r.ok ? r.stdout.trim() : "unknown"; +} + +function isRecentlyRolledBack(state, key, currentSha) { + const e = state.rollbacks?.[key]; + if (!e) return null; + if (e.lockSha !== currentSha) return null; + const ageSec = Math.floor(Date.now() / 1000) - e.timestamp; + if (ageSec > ROLLBACK_COOLDOWN_DAYS * 86400) return null; + return { ageDays: Math.floor(ageSec / 86400), reason: e.reason }; +} + +function recordRollback(state, key, reason, currentSha) { + state.rollbacks ||= {}; + state.rollbacks[key] = { + lockSha: currentSha, + timestamp: Math.floor(Date.now() / 1000), + reason, + }; +} + +function clearRollback(state, key) { + if (state.rollbacks?.[key]) delete state.rollbacks[key]; +} + +function pruneRollbacks(state) { + const cutoff = + Math.floor(Date.now() / 1000) - ROLLBACK_COOLDOWN_DAYS * 86400; + for (const [k, v] of Object.entries(state.rollbacks || {})) { + if (v.timestamp < cutoff) delete state.rollbacks[k]; + } +} + +// ── Fix attempts ───────────────────────────────────────────────────────── +// +// Each returns ``{ ok, method, reason? }``. ``method`` records which +// strategy actually worked so the PR body can disclose ``overrides`` +// usage explicitly (these are policy-relevant — they pin a version that +// would not otherwise be reachable from the natural dependency graph). + +function attemptUpdate(workspaceDir, pkg, minVersion) { + const wsRoot = join(REPO_ROOT, workspaceDir); + const r = run( + "npm", + ["update", pkg, "--package-lock-only", "--no-audit", "--no-fund"], + { cwd: wsRoot }, + ); + if (!r.ok) { + return { + ok: false, + method: "update", + reason: `npm update failed: ${(r.stderr || r.stdout) + .split("\n") + .filter(Boolean) + .pop()}`, + }; + } + const v = verifyAllVersionsFixed(workspaceDir, pkg, minVersion); + if (v.ok) { + return { ok: true, method: "update" }; + } + return { + ok: false, + method: "update", + reason: `unfixed versions remain: ${v.unfixed.join(", ") || "(not found)"}`, + }; +} + +function attemptOverride(workspaceDir, pkg, minVersion) { + const wsRoot = join(REPO_ROOT, workspaceDir); + const pkgPath = join(wsRoot, "package.json"); + const original = readFileSync(pkgPath, "utf8"); + const pkgJson = JSON.parse(original); + pkgJson.overrides ||= {}; + // Pin to the exact ``first_patched_version`` rather than ``>=X``. + // A range override silently picks up future major upgrades, which + // can introduce unrelated breakage; an exact pin keeps the + // remediation minimal and deterministic. Dependabot will surface a + // newer override when a future advisory requires it. + pkgJson.overrides[pkg] = minVersion; + // Preserve the file's existing indentation so we don't reformat + // the whole file and create noisy diffs. Detect 2-space vs 4-space + // vs tabs from the first indented line; default to 2 (most common). + const indentMatch = original.match(/^([ \t]+)"/m); + const indent = indentMatch ? indentMatch[1] : " "; + const trailingNewline = original.endsWith("\n") ? "\n" : ""; + writeFileSync(pkgPath, JSON.stringify(pkgJson, null, indent) + trailingNewline); + const r = run( + "npm", + ["install", "--package-lock-only", "--no-audit", "--no-fund"], + { cwd: wsRoot }, + ); + if (!r.ok) { + return { + ok: false, + method: "override", + reason: `npm install (with override) failed: ${(r.stderr || r.stdout) + .split("\n") + .filter(Boolean) + .pop()}`, + }; + } + const v = verifyAllVersionsFixed(workspaceDir, pkg, minVersion); + if (v.ok) { + return { ok: true, method: "override" }; + } + return { + ok: false, + method: "override", + reason: `override applied but vulnerable versions still resolved: ${v.unfixed.join(", ")}`, + }; +} + +function buildAndTest(workspaceDir) { + const wsRoot = join(REPO_ROOT, workspaceDir); + // Scrub auth tokens from the environment before running anything + // that executes dependency code (build/test). ``npm ci`` itself runs + // with ``--ignore-scripts``, but ``npm run build`` invokes our own + // tsc which loads dependency packages, and ``npm test`` executes + // arbitrary test code. + const env = sanitizedEnv(); + // ``npm ci`` materialises the exact lockfile we just wrote so the + // build sees the new versions rather than a stale ``node_modules``. + // ``--ignore-scripts`` skips lifecycle hooks (notably ``prepare``, + // which re-runs ``build-all`` and would double up the build below). + const ci = run( + "npm", + ["ci", "--no-audit", "--no-fund", "--ignore-scripts"], + { cwd: wsRoot, env }, + ); + if (!ci.ok) { + return { ok: false, phase: "install", output: ci.stderr || ci.stdout }; + } + // TypeChat's ``npm test`` already runs ``npm run build`` as its + // first step, so we don't repeat it when tests are enabled. When + // ``--skip-tests`` is set we still need an explicit build to + // catch type errors introduced by a dep upgrade. + if (SKIP_TESTS) { + const build = run("npm", ["run", "build"], { cwd: wsRoot, env }); + if (!build.ok) { + return { + ok: false, + phase: "build", + output: build.stderr || build.stdout, + }; + } + } else { + const test = run("npm", ["test"], { cwd: wsRoot, env }); + if (!test.ok) { + return { + ok: false, + phase: "test", + output: test.stderr || test.stdout, + }; + } + } + return { ok: true }; +} + +// ── Main fix loop ──────────────────────────────────────────────────────── + +function applyFix(group, state) { + const { workspace, pkg, minVersion, severity } = group; + const key = `${workspace.name}|${pkg}`; + const currentSha = lockSha(workspace.dir); + + log(""); + log( + `▶ ${pkg} (${severity}, ${group.alerts.length} alert${group.alerts.length === 1 ? "" : "s"}) → ≥ ${minVersion || "?"}`, + ); + + if (!minVersion) { + log( + ` skipped: no first_patched_version on advisory (likely awaiting upstream fix)`, + ); + return { status: "no_patch", pkg, severity }; + } + + const cooldown = isRecentlyRolledBack(state, key, currentSha); + if (cooldown) { + log( + ` skipped: rolled back ${cooldown.ageDays}d ago against same lockfile (reason: ${cooldown.reason})`, + ); + return { status: "skipped_cooldown", pkg, severity }; + } + + if (DRY_RUN) { + const current = getResolvedVersions(workspace.dir, pkg); + log(` dry-run: would attempt fix. Current resolved: ${current.join(", ") || "(not installed)"}`); + return { status: "would_fix", pkg, severity }; + } + + const backup = backupWorkspace(workspace.dir); + let lastUnfixableReason = null; + + for (const attempt of [attemptUpdate, attemptOverride]) { + const r = attempt(workspace.dir, pkg, minVersion); + if (!r.ok) { + log(` ${r.method}: ${r.reason}`); + // The fix attempt itself didn't lift the resolved version + // above the advisory. Restore so the next strategy starts + // from a clean baseline. No rollback cooldown is recorded + // because nothing actually broke — we just couldn't make + // npm pick a safe version. + lastUnfixableReason = `${r.method}: ${r.reason}`; + restoreWorkspace(backup); + continue; + } + log(` ${r.method}: lockfile-level fix applied`); + + const v = buildAndTest(workspace.dir); + if (v.ok) { + log(` ✓ verified (install + build${SKIP_TESTS ? "" : " + test"})`); + clearRollback(state, key); + return { + status: "applied", + pkg, + severity, + method: r.method, + minVersion, + }; + } + + // A real rollback: the fix took, but the workspace no longer + // builds/tests. Record so the same broken upgrade isn't tried + // again until cooldown expires or the lockfile changes. + log(` ✗ ${v.phase} failed after ${r.method}; rolling back`); + if (VERBOSE && v.output) { + console.log(v.output.slice(0, 4000)); + } + restoreWorkspace(backup); + recordRollback(state, key, `${v.phase} failed (${r.method})`, currentSha); + return { status: "rolled_back", pkg, severity, phase: v.phase }; + } + + return { + status: "unfixable", + pkg, + severity, + reason: lastUnfixableReason || "all fix strategies exhausted", + }; +} + +// ── Reporting ──────────────────────────────────────────────────────────── + +function bucket(results) { + const b = { + applied: [], + rolled_back: [], + unfixable: [], + no_patch: [], + skipped_cooldown: [], + would_fix: [], + }; + for (const r of results) (b[r.status] ||= []).push(r); + return b; +} + +function printSummary(b, totals) { + log(""); + log("─── Summary ───────────────────────────────────────────────"); + log(` Total alerts: ${totals.alerts}`); + log(` Distinct pkgs: ${totals.packages}`); + log(` Applied: ${b.applied.length}${b.applied.length ? " — " + b.applied.map((r) => `${r.pkg}(${r.method})`).join(" ") : ""}`); + log(` Rolled back: ${b.rolled_back.length}${b.rolled_back.length ? " — " + b.rolled_back.map((r) => `${r.pkg}(${r.phase})`).join(" ") : ""}`); + log(` Unfixable: ${b.unfixable.length}${b.unfixable.length ? " — " + b.unfixable.map((r) => r.pkg).join(" ") : ""}`); + log(` No patch yet: ${b.no_patch.length}${b.no_patch.length ? " — " + b.no_patch.map((r) => r.pkg).join(" ") : ""}`); + log(` Cooldown skipped: ${b.skipped_cooldown.length}${b.skipped_cooldown.length ? " — " + b.skipped_cooldown.map((r) => r.pkg).join(" ") : ""}`); + if (DRY_RUN) { + log(` Would attempt: ${b.would_fix.length}${b.would_fix.length ? " — " + b.would_fix.map((r) => r.pkg).join(" ") : ""}`); + } +} + +/** + * Emit step outputs the workflow uses to build the PR body. Written as + * key=value lines to ``$GITHUB_OUTPUT`` when running in Actions, or stdout + * otherwise. + */ +function writeStepOutputs(b, totals) { + const out = process.env.GITHUB_OUTPUT; + const lines = [ + `total_alerts=${totals.alerts}`, + `applied_count=${b.applied.length}`, + `applied_packages=${b.applied.map((r) => r.pkg).join(" ")}`, + `applied_overrides=${b.applied.filter((r) => r.method === "override").map((r) => r.pkg).join(" ")}`, + `rolled_back_count=${b.rolled_back.length}`, + `rolled_back_packages=${b.rolled_back.map((r) => r.pkg).join(" ")}`, + `unfixable_count=${b.unfixable.length}`, + `unfixable_packages=${b.unfixable.map((r) => r.pkg).join(" ")}`, + `no_patch_count=${b.no_patch.length}`, + `no_patch_packages=${b.no_patch.map((r) => r.pkg).join(" ")}`, + `cooldown_count=${b.skipped_cooldown.length}`, + `cooldown_packages=${b.skipped_cooldown.map((r) => r.pkg).join(" ")}`, + `changes=${b.applied.length > 0 ? "true" : "false"}`, + ]; + if (out) { + writeFileSync(out, lines.join("\n") + "\n", { flag: "a" }); + } else if (VERBOSE) { + log(""); + log("--- step outputs ---"); + for (const l of lines) log(l); + } +} + +// ── Optional: dependency chain output for blocked transitives ──────────── + +function showChains(groups) { + log(""); + log("─── Dependency chains for blocked / unfixed packages ───────"); + for (const g of groups) { + log(""); + log(`◆ ${g.pkg} (advisory ≥ ${g.minVersion || "n/a"})`); + const r = run("npm", ["ls", g.pkg, "--all"], { + cwd: join(REPO_ROOT, g.workspace.dir), + }); + log(r.stdout.split("\n").slice(0, 60).join("\n")); + } +} + +// ── Entry point ────────────────────────────────────────────────────────── + +function main() { + const repo = + process.env.GITHUB_REPOSITORY || + process.env.DEP_REPO || + "microsoft/TypeChat"; + + log(`Repo: ${repo}`); + log(`Mode: ${DRY_RUN ? "analyze (dry-run)" : "auto-fix"}`); + log(`State file: ${ROLLBACK_STATE_PATH}`); + + const alerts = fetchAlerts(repo); + log(`Fetched ${alerts.length} open alerts`); + + const groups = groupAlerts(alerts); + log(`Grouped into ${groups.length} distinct (workspace, package) pairs`); + + if (SHOW_CHAINS) { + showChains(groups); + return; + } + + const state = loadRollbackState(); + pruneRollbacks(state); + + const results = []; + for (const g of groups) { + results.push(applyFix(g, state)); + } + + if (!DRY_RUN) { + saveRollbackState(state); + } + + const b = bucket(results); + printSummary(b, { alerts: alerts.length, packages: groups.length }); + writeStepOutputs(b, { alerts: alerts.length, packages: groups.length }); + + // Exit non-zero if any rollback happened — informs the workflow that + // human attention is warranted even though the PR (if any) is valid. + if (b.rolled_back.length > 0) { + warn( + `${b.rolled_back.length} package(s) rolled back; their alerts remain open.`, + ); + } + if (b.unfixable.length > 0) { + warn( + `${b.unfixable.length} package(s) could not be lifted to a safe version by lockfile updates or root overrides (likely require parent-package upgrades). Their alerts remain open.`, + ); + } +} + +main();