diff --git a/.github/workflows/fix-dependabot-alerts.yml b/.github/workflows/fix-dependabot-alerts.yml new file mode 100644 index 0000000..c873a3c --- /dev/null +++ b/.github/workflows/fix-dependabot-alerts.yml @@ -0,0 +1,295 @@ +# 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 dotnet restore (NuGetAudit), build, and tests, then 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 'dotnet 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 ``dotnet`` against potentially + # untrusted dependency updates (MSBuild tasks, source + # generators, and test runners can execute arbitrary code), + # and we don't want push credentials reachable from those. + persist-credentials: false + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + - uses: actions/setup-node@v4 + with: + # The remediation script itself is Node.js; no project depends + # on node, so we don't need a package-lock cache key. + node-version: 24 + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + # Hash the central props file so a CPM change invalidates the + # cache. Cache restore from any prior key prefix on miss. + key: nuget-fixer-${{ runner.os }}-${{ hashFiles('Directory.Packages.props') }} + restore-keys: | + nuget-fixer-${{ runner.os }}- + + - 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 Directory.Packages.props 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 reuses the NuGet cache and + # MSBuild incremental restore state; a stale obj/ folder could + # theoretically mask an issue. A full clean restore + build + test + # catches anything the incremental flow missed. + - name: Final clean build verification + if: ${{ steps.fix.outputs.changes == 'true' && inputs.dry-run != 'true' }} + id: build + run: | + # Wipe per-project intermediate state so the verify build + # doesn't reuse anything from the per-fix iterations. + find . -path ./.git -prune -o -type d \( -name bin -o -name obj \) -prune -exec rm -rf {} + + dotnet restore TypeChat.sln --force + # -m:1 avoids a known parallel-build race in examples' + # Directory.Build.targets file copy. + dotnet build TypeChat.sln -c Release /warnaserror --no-restore -m:1 + if [ "${{ inputs.skip-tests }}" != "true" ]; then + dotnet test tests/TypeChat.UnitTests -c Release --no-build --nologo + 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 is the App token — used by the ``gh`` CLI for + # ``gh pr create`` / labelling / closing superseded PRs so the + # PR appears under the bot's identity. + GH_TOKEN: ${{ steps.app-token-pr.outputs.token }} + # GIT_PUSH_TOKEN is the workflow's default GITHUB_TOKEN, scoped + # via the workflow-level ``permissions: contents: write`` block. + # We use it only at the very end, after all untrusted ``dotnet`` + # build/test phases have finished, to avoid persisting any push + # credential in .git/config (where MSBuild tasks / source + # generators / test runners could read it). + GIT_PUSH_TOKEN: ${{ secrets.GITHUB_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 -F - < + EOF + # Push using the workflow's default GITHUB_TOKEN (scoped to + # contents:write at the workflow level). Configured here, not + # via actions/checkout's ``persist-credentials``, so the token + # isn't reachable from the dotnet build/test phase earlier in + # the job. + git remote set-url origin "https://x-access-token:${GIT_PUSH_TOKEN}@github.com/${{ github.repository }}.git" + git push origin "$BRANCH" + + APPLIED="${{ steps.fix.outputs.applied_packages }}" + PINS="${{ steps.fix.outputs.applied_transitive_pins }}" + ROLLED="${{ steps.fix.outputs.rolled_back_packages }}" + UNFIXABLE="${{ steps.fix.outputs.unfixable_packages }}" + COOLDOWN="${{ steps.fix.outputs.cooldown_packages }}" + + BODY=$(cat <\` entries):**${PINS:- (none)} + - **Rolled back (${{ steps.fix.outputs.rolled_back_count }}):**${ROLLED:- (none)} + - **Unfixable (${{ steps.fix.outputs.unfixable_count }}):**${UNFIXABLE:- (none)} + - **Skipped (recent rollback cooldown, ${{ steps.fix.outputs.cooldown_count }}):**${COOLDOWN:- (none)} + + > Packages marked **Unfixable** could not be lifted to the advisory's \`first_patched_version\` via a CPM edit — typically because the patched version doesn't yet exist in NuGet, or the upgrade introduces transitive constraint violations. Triage manually. + + > Packages marked as **transitive pins** are tracked technical debt — the \`\` entry will hold the package at the pinned version across the whole repo until removed. Remove the entry once the natural dependency graph would resolve to a safe version on its own. + + ### How this works + 1. Reads open Dependabot alerts via the REST API. + 2. For each alert, edits \`Directory.Packages.props\` to set the package's \`\` to the advisory's \`first_patched_version\` (adding a new entry if the package wasn't previously declared — works for transitive deps thanks to \`CentralPackageTransitivePinningEnabled\`). + 3. Runs \`dotnet restore --force\` and confirms no NU1901–NU1904 audit warnings remain for the alert's GHSA. + 4. Runs \`dotnet build -c Release /warnaserror\` and \`dotnet 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 \`Directory.Packages.props\` changes are limited to the listed packages + - [ ] Investigate any newly-rolled-back packages separately + - [ ] If transitive pins were added, confirm the pinned version is acceptable policy + EOF + ) + + # 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 0000000..df36c38 --- /dev/null +++ b/tools/scripts/fix-dependabot-alerts.mjs @@ -0,0 +1,767 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Automated Dependabot alert remediator for the typechat.net (.NET / NuGet) + * repository. + * + * Strategy per alert: edit the single root ``Directory.Packages.props`` to + * pin the package at the advisory's ``first_patched_version``. With + * ``CentralPackageTransitivePinningEnabled`` (set in that file) a single + * ```` entry works for both direct *and* transitive + * dependencies, so there is no second-strategy fallback like the npm + * ``overrides`` mechanism in the parallel TypeChat script. + * + * After each attempt the script runs ``dotnet restore TypeChat.sln`` and + * verifies that no NU1901/NU1902/NU1903/NU1904 warning for the alert's + * GHSA URL remains. ``NuGetAudit`` with ``NuGetAuditMode=all`` is enabled + * by the root ``Directory.Build.props`` so transitive vulnerabilities + * surface here. + * + * On a successful fix the solution is rebuilt with ``/warnaserror`` and + * the unit tests are run to catch breakages introduced by the upgrade. + * On any failure — restore, build, test, or verification — the script + * restores ``Directory.Packages.props`` from a backup and records the + * failure in a persistent rollback-state file. Future runs skip + * recently-rolled-back packages (default 7 day cooldown, keyed off the + * props file's git hash) 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 + * + * 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, rmSync } 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 ──────────────────────────────────────────────────────── + +const PACKAGES_PROPS = "Directory.Packages.props"; +const SOLUTION = "TypeChat.sln"; +const TEST_PROJECT = join("tests", "TypeChat.UnitTests"); + +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 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 dotnet child processes — +// build/test scripts can execute arbitrary code from newly-installed +// dependencies (MSBuild tasks, source generators, test runner adapters), +// so we strip authentication before invoking them. +const SENSITIVE_ENV_VARS = [ + "GH_TOKEN", + "GITHUB_TOKEN", + "NUGET_API_KEY", + "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 = {}) { + // ``dotnet`` and ``git`` and ``gh`` are real .exe on Windows; do not + // use ``shell: true`` for them, otherwise cmd.exe would interpret + // shell metacharacters like ``&`` in URLs. + const needsShell = opts.shell ?? false; + 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 || "", + }; +} + +// ── Semver (minimal — just compare a.b.c[-prerelease]) ─────────────────── +// +// Sufficient for "is resolved version >= first_patched_version" checks. +// NuGet packages use SemVer 2.0; legacy 4-part versions (``4.0.5.0``) +// also exist but the security advisories we consume normalise to 3-part. + +function parseSemver(v) { + if (!v) return null; + // Capture [major, minor, patch, revision, prereleaseTag-or-empty]. + // NuGet supports a legacy 4-part numeric scheme (``4.0.5.10``) on + // top of SemVer 2.0. We include the 4th segment in comparisons so + // ``4.0.5.10`` and ``4.0.5.2`` don't compare equal. + // 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+)(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?/, + ); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4] || 0), m[5] || ""]; +} + +function semverGte(a, b) { + const pa = parseSemver(a); + const pb = parseSemver(b); + if (!pa || !pb) return false; + for (let i = 0; i < 4; i++) { + if (pa[i] > pb[i]) return true; + if (pa[i] < pb[i]) return false; + } + // Numeric parts equal — compare prerelease tags. + // * no prerelease > any prerelease (1.2.3 > 1.2.3-beta) + // * same prerelease => equal (1.2.3-x = 1.2.3-x) + // * different prereleases: conservatively NOT >= (safe default) + const preA = pa[4]; + const preB = pb[4]; + if (preA === preB) return true; + if (preA === "") return true; + if (preB === "") return false; + return false; +} + +// ── Dependabot alerts ──────────────────────────────────────────────────── + +function fetchAlerts(repo) { + // Local-test escape hatch: read alerts from a JSON file instead of + // calling the GitHub API. Only ever set in local seed tests; in CI + // this env var is unset and the real ``gh api`` path runs. + const mockPath = process.env.DEP_MOCK_ALERTS_FILE; + if (mockPath) { + dbg(`[mock] reading alerts from ${mockPath}`); + try { + return JSON.parse(readFileSync(mockPath, "utf8")); + } catch (e) { + err(`Could not read mock alerts file ${mockPath}: ${e.message}`); + process.exit(1); + } + } + 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 package name. Returns + * ``[{ pkg, minVersion, severity, ghsaIds, 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 skippedNonNuGet = 0; + for (const a of alerts) { + const eco = a.dependency?.package?.ecosystem; + const pkg = a.dependency?.package?.name; + if (eco !== "nuget" || !pkg) { + skippedNonNuGet++; + continue; + } + + const patched = + a.security_vulnerability?.first_patched_version?.identifier; + const ghsaId = a.security_advisory?.ghsa_id; + if (!groups.has(pkg)) { + groups.set(pkg, { + pkg, + ecosystem: eco, + minVersion: patched || null, + severity: a.security_advisory?.severity || "unknown", + ghsaIds: new Set(), + alerts: [], + }); + } + const g = groups.get(pkg); + g.alerts.push(a); + if (ghsaId) g.ghsaIds.add(ghsaId); + if (patched && (!g.minVersion || semverGte(patched, g.minVersion))) { + g.minVersion = patched; + } + 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 (skippedNonNuGet > 0) { + dbg(`Ignored ${skippedNonNuGet} non-nuget or malformed alert(s)`); + } + return [...groups.values()]; +} + +// ── Directory.Packages.props editing ───────────────────────────────────── +// +// The file format is a fixed XML schema: +// +// ... +// +// +// ... +// +// +// We do a string-level edit rather than full XML parsing to preserve +// formatting, comments, and surrounding whitespace exactly. + +function readPackagesProps() { + const p = join(REPO_ROOT, PACKAGES_PROPS); + if (!existsSync(p)) { + err(`Missing ${PACKAGES_PROPS} at repo root. This script requires Central Package Management.`); + process.exit(1); + } + return readFileSync(p, "utf8"); +} + +function writePackagesProps(text) { + writeFileSync(join(REPO_ROOT, PACKAGES_PROPS), text); +} + +/** + * Update an existing ```` + * entry to the new version, or insert a new one (for transitive pins) if + * the package isn't yet listed. Returns the new file text. + */ +function setPackageVersion(text, pkg, newVersion) { + // Build an Include-attribute matcher. Names are case-insensitive in + // NuGet but case-preserving in MSBuild XML, so match + // case-insensitively and preserve the existing casing. + const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp( + `()`, + "i", + ); + const m = re.exec(text); + if (m) { + // Never downgrade: if the central entry is already at or above + // the patched version (e.g. stale alert, or alert caused by a + // per-project VersionOverride rather than the central pin), + // leave the file untouched. For incomparable prereleases — + // semverGte returns false in BOTH directions, e.g. 1.2.3-alpha + // vs 1.2.3-beta — also leave it alone so we don't accidentally + // write a lower prerelease. + const current = m[2]; + if (semverGte(current, newVersion)) { + return text; + } + if (!semverGte(newVersion, current)) { + // Incomparable: refuse to modify and let the caller surface + // it as unfixable so a human can decide. + throw new Error( + `current version ${current} and patched version ${newVersion} are not comparable; refusing to modify`, + ); + } + return text.replace(re, `$1${newVersion}$3`); + } + // Insert a new transitive pin just before the closing . + // For multi-ItemGroup props we target the LAST one (where the bulk + // of entries live in convention). + const lastClose = text.lastIndexOf(""); + if (lastClose < 0) { + throw new Error( + `Cannot find in ${PACKAGES_PROPS} to insert transitive pin for ${pkg}`, + ); + } + // Discover the indentation used inside the ItemGroup by looking at + // the most recent so + // we insert at the start of its line — otherwise that leading + // whitespace becomes a prefix on our inserted comment line. + let insertAt = lastClose; + while (insertAt > 0 && (text[insertAt - 1] === " " || text[insertAt - 1] === "\t")) { + insertAt--; + } + const insertion = `${indent}${eol}${indent}${eol}${text.slice(insertAt, lastClose)}`; + return text.slice(0, insertAt) + insertion + text.slice(lastClose); +} + +function hasPackageEntry(text, pkg) { + const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp( + ` 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] = { + propsSha: 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]; + } +} + +// ── Restore + audit verification ───────────────────────────────────────── +// +// We run ``dotnet restore`` and parse the warnings stream for any +// NU1901/NU1902/NU1903/NU1904 message. Verification has two parts: +// 1. The target (package, GHSA) warning must be gone. +// 2. No NEW (package, GHSA) audit warnings appeared after the fix +// that weren't in the pre-fix baseline. This catches a bump that +// pulls in a different vulnerable transitive. + +const NU190X_RE = /warning NU190[1-4]\b/i; +// Extracts the quoted package name and the GHSA id from an audit line. +// Example line: +// warning NU1903: Package 'Microsoft.SemanticKernel' 1.68.0 has a known high severity vulnerability, https://github.com/advisories/GHSA-2ww3-72rp-wpp4 +const NU190X_PKG_RE = /'([^']+)'/; +const NU190X_GHSA_RE = /GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}/i; + +function parseAuditWarnings(text) { + const out = new Set(); + for (const line of text.split(/\r?\n/)) { + if (!NU190X_RE.test(line)) continue; + const pm = NU190X_PKG_RE.exec(line); + const gm = NU190X_GHSA_RE.exec(line); + if (!pm || !gm) continue; + out.add(`${pm[1].toLowerCase()}|${gm[0].toUpperCase()}`); + } + return out; +} + +function rawRestore() { + // Force restore to bypass the no-op short-circuit when MSBuild + // thinks the project assets are already up to date — the props + // file changed in-place and the input check doesn't catch it. + // Sanitize env: package-provided MSBuild props/targets evaluated + // during restore could leak GH_TOKEN if we didn't strip it. + return run("dotnet", ["restore", SOLUTION, "--force"], { + cwd: REPO_ROOT, + env: sanitizedEnv(), + }); +} + +/** + * Snapshot the current set of audit warnings on the tree as-is. + * Used as a baseline before applying a fix so we can detect newly + * introduced vulnerabilities afterwards. + */ +function auditBaseline() { + const r = rawRestore(); + const combined = (r.stdout || "") + "\n" + (r.stderr || ""); + if (!r.ok) { + return { + ok: false, + reason: `baseline dotnet restore failed: ${combined.split("\n").filter(Boolean).slice(-3).join(" | ")}`, + warnings: new Set(), + }; + } + return { ok: true, warnings: parseAuditWarnings(combined) }; +} + +function restoreAndAudit(pkg, ghsaIds, baseline) { + const r = rawRestore(); + const combined = (r.stdout || "") + "\n" + (r.stderr || ""); + if (!r.ok) { + return { + ok: false, + reason: `dotnet restore failed: ${combined.split("\n").filter(Boolean).slice(-3).join(" | ")}`, + }; + } + const after = parseAuditWarnings(combined); + // (1) target package + GHSA must be gone. + const pkgLc = pkg.toLowerCase(); + const targetRemaining = [...after].filter((k) => { + const [p, g] = k.split("|"); + if (p !== pkgLc) return false; + if (ghsaIds.size === 0) return true; + for (const id of ghsaIds) { + if (g === id.toUpperCase()) return true; + } + return false; + }); + if (targetRemaining.length > 0) { + return { + ok: false, + reason: `audit still reports vulnerability for ${pkg}: ${targetRemaining[0]}`, + }; + } + // (2) no NEW (pkg, ghsa) pair appeared post-fix that wasn't in + // the baseline. This catches a bump that drags in a different + // vulnerable transitive. + const baseSet = baseline?.warnings || new Set(); + const introduced = [...after].filter((k) => !baseSet.has(k)); + if (introduced.length > 0) { + return { + ok: false, + reason: `fix introduced new audit warning(s): ${introduced.slice(0, 3).join(", ")}`, + }; + } + return { ok: true }; +} + +function buildAndTest() { + const env = sanitizedEnv(); + // /warnaserror matches the CI build line. -m:1 avoids a known race + // in the examples Directory.Build.targets file copy. + const build = run( + "dotnet", + ["build", SOLUTION, "-c", "Release", "/warnaserror", "--no-restore", "-m:1"], + { cwd: REPO_ROOT, env }, + ); + if (!build.ok) { + return { + ok: false, + phase: "build", + output: (build.stdout || "") + (build.stderr || ""), + }; + } + if (SKIP_TESTS) return { ok: true }; + const test = run( + "dotnet", + ["test", TEST_PROJECT, "-c", "Release", "--no-build", "--nologo"], + { cwd: REPO_ROOT, env }, + ); + if (!test.ok) { + return { + ok: false, + phase: "test", + output: (test.stdout || "") + (test.stderr || ""), + }; + } + return { ok: true }; +} + +// ── Main fix loop ──────────────────────────────────────────────────────── + +function applyFix(group, state, baseline) { + const { pkg, minVersion, severity, ghsaIds } = group; + const key = pkg; + const currentSha = propsSha(); + + 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 Directory.Packages.props (reason: ${cooldown.reason})`, + ); + return { status: "skipped_cooldown", pkg, severity }; + } + + if (DRY_RUN) { + const text = readPackagesProps(); + const direct = hasPackageEntry(text, pkg); + log( + ` dry-run: would ${direct ? "bump existing" : "add transitive pin"} `, + ); + return { status: "would_fix", pkg, severity }; + } + + const backup = backupProps(); + const before = readFileSync(backup, "utf8"); + const direct = hasPackageEntry(before, pkg); + const method = direct ? "version_bump" : "transitive_pin"; + + let next; + try { + next = setPackageVersion(before, pkg, minVersion); + } catch (e) { + log(` ${method}: ${e.message}`); + return { status: "unfixable", pkg, severity, reason: e.message }; + } + if (next === before) { + log(` ${method}: file unchanged (already at ${minVersion}?) — skipping`); + return { + status: "unfixable", + pkg, + severity, + reason: "props file would be unchanged", + }; + } + writePackagesProps(next); + + const audit = restoreAndAudit(pkg, ghsaIds, baseline); + if (!audit.ok) { + log(` ${method}: ${audit.reason}`); + restoreProps(backup); + // Cooldown applies to ANY post-fix failure (restore, audit, + // build, or test) so we don't retry the same broken bump on + // every scheduled run. + recordRollback(state, key, `audit failed (${method}): ${audit.reason}`, currentSha); + return { + status: "unfixable", + pkg, + severity, + reason: `${method}: ${audit.reason}`, + }; + } + log(` ${method}: restore clean, no remaining audit warnings for ${pkg}`); + + const v = buildAndTest(); + if (v.ok) { + log(` ✓ verified (restore + build${SKIP_TESTS ? "" : " + test"})`); + clearRollback(state, key); + return { + status: "applied", + pkg, + severity, + method, + minVersion, + }; + } + + // The fix took (audit clean), but the workspace no longer + // builds/tests. Roll back and remember. + log(` ✗ ${v.phase} failed after ${method}; rolling back`); + if (VERBOSE && v.output) { + console.log(v.output.slice(0, 4000)); + } + restoreProps(backup); + recordRollback(state, key, `${v.phase} failed (${method})`, currentSha); + return { status: "rolled_back", pkg, severity, phase: v.phase }; +} + +// ── 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(" ") : ""}`); + } +} + +function writeStepOutputs(b, totals) { + const out = process.env.GITHUB_OUTPUT; + const transitivePins = b.applied + .filter((r) => r.method === "transitive_pin") + .map((r) => r.pkg) + .join(" "); + const lines = [ + `total_alerts=${totals.alerts}`, + `applied_count=${b.applied.length}`, + `applied_packages=${b.applied.map((r) => r.pkg).join(" ")}`, + `applied_transitive_pins=${transitivePins}`, + `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); + } +} + +// ── Entry point ────────────────────────────────────────────────────────── + +function main() { + const repo = + process.env.GITHUB_REPOSITORY || + process.env.DEP_REPO || + "microsoft/typechat.net"; + + 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 package(s)`); + + const state = loadRollbackState(); + pruneRollbacks(state); + + // Capture pre-fix audit warnings as a baseline so we can detect + // when a bump introduces a NEW vulnerable transitive. Only needed + // when we'll actually apply changes. + let baseline = { warnings: new Set() }; + if (!DRY_RUN && groups.length > 0) { + dbg("Capturing pre-fix audit baseline…"); + const b0 = auditBaseline(); + if (!b0.ok) { + warn(`Could not capture audit baseline: ${b0.reason}`); + warn(`Proceeding without new-vulnerability detection.`); + } else { + baseline = b0; + dbg(`Baseline: ${baseline.warnings.size} pre-existing audit warning(s)`); + } + } + + const results = []; + for (const g of groups) { + results.push(applyFix(g, state, baseline)); + } + + 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 }); + + 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 (likely the advisory's first_patched_version doesn't yet exist in NuGet, or the upgrade pulls in incompatible transitive constraints). Their alerts remain open.`, + ); + } +} + +main();