From 9800a86bf63c72b9c7e708835b5107c7630b18d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 08:19:08 +0000 Subject: [PATCH] Add a daily dependency audit that can cut a draft release Schedule npm and cargo audits each day. When a high or critical advisory has a non-breaking fix, apply it, bump the patch version, and dispatch the existing signed Release workflow. Allowlisted or unfixable findings do not ship; they open a tracking issue instead. Co-authored-by: James Merrix --- .github/workflows/daily-security.yml | 159 ++++++++++++ .github/workflows/release.yml | 23 +- DISTRIBUTION.md | 13 + README.md | 2 +- SECURITY.md | 6 +- package.json | 3 +- scripts/security-release.mjs | 359 +++++++++++++++++++++++++++ scripts/security-release.test.mjs | 94 +++++++ vitest.config.ts | 2 +- 9 files changed, 654 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/daily-security.yml create mode 100644 scripts/security-release.mjs create mode 100644 scripts/security-release.test.mjs diff --git a/.github/workflows/daily-security.yml b/.github/workflows/daily-security.yml new file mode 100644 index 0000000..035843b --- /dev/null +++ b/.github/workflows/daily-security.yml @@ -0,0 +1,159 @@ +name: Daily security audit + +on: + schedule: + # 06:00 UTC — after the previous day's advisory databases have settled. + - cron: "0 6 * * *" + workflow_dispatch: + +# A newer run on main cancels an in-flight audit so we never double-tag. +concurrency: + group: daily-security + cancel-in-progress: true + +permissions: + contents: write + issues: write + actions: write + +jobs: + audit: + name: Audit, remediate, release + runs-on: ubuntu-22.04 + # Schedule only fires on the default branch. Manual runs are allowed from + # any ref so the scan can be tested; tagging still requires main. + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: npm + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./src-tauri -> target" + + - name: Install frontend dependencies + run: npm ci + + - name: Install cargo-audit + uses: taiki-e/install-action@v2 + with: + tool: cargo-audit + + - name: Scan and apply safe remediations + id: scan + run: node scripts/security-release.mjs --apply + + - name: Job summary + if: always() && steps.scan.outcome == 'success' + env: + REMAINING: ${{ steps.scan.outputs.remaining }} + NOTES: ${{ steps.scan.outputs.notes }} + run: | + { + echo "## Security audit" + echo "" + echo "- remediated: \`${{ steps.scan.outputs.remediated }}\`" + echo "- audit_ok: \`${{ steps.scan.outputs.audit_ok }}\`" + echo "- version: \`${{ steps.scan.outputs.version }}\`" + if [ -n "${REMAINING}" ]; then + echo "" + echo "Outstanding:" + echo "" + echo '```' + printf '%s\n' "${REMAINING}" + echo '```' + fi + if [ -n "${NOTES}" ]; then + echo "" + echo "Release notes:" + echo "" + echo "${NOTES}" + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Unit tests + if: steps.scan.outputs.remediated == 'true' && steps.scan.outputs.audit_ok == 'true' + run: npm test + + - name: Typecheck & build frontend + if: steps.scan.outputs.remediated == 'true' && steps.scan.outputs.audit_ok == 'true' + run: npm run build + + - name: Commit, tag, and dispatch draft release + if: github.ref == 'refs/heads/main' && steps.scan.outputs.remediated == 'true' && steps.scan.outputs.audit_ok == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.scan.outputs.version }} + NOTES: ${{ steps.scan.outputs.notes }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add package.json package-lock.json \ + src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json + git status --short + git commit -m "Release ${VERSION}: security dependency updates" + git pull --rebase origin main + git tag "v${VERSION}" + git push origin HEAD:main + git push origin "v${VERSION}" + + # Pushing a tag with GITHUB_TOKEN does not trigger other workflows, + # so dispatch the existing Release workflow on the new tag. + node -e ' + const fs = require("fs"); + fs.writeFileSync("/tmp/release-dispatch.json", JSON.stringify({ + ref: "v" + process.env.VERSION, + inputs: { notes: process.env.NOTES || "" }, + })); + ' + gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/workflows/release.yml/dispatches" \ + --input /tmp/release-dispatch.json + + - name: Open or update tracking issue + if: github.ref == 'refs/heads/main' && steps.scan.outputs.audit_ok != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REMAINING: ${{ steps.scan.outputs.remaining }} + run: | + set -euo pipefail + TITLE="Outstanding dependency vulnerabilities" + BODY="$(cat < 0 && ids.every((id) => allow.has(id)); + if (known) continue; + blocking.push({ + name, + severity: vuln.severity, + ids, + fixAvailable: vuln.fixAvailable, + title: (vuln.via ?? []) + .filter((item) => item && typeof item === "object") + .map((item) => item.title) + .filter(Boolean) + .join("; "), + }); + } + return blocking; +} + +function readNpmAudit() { + const result = runAllowFail("npm audit --omit=dev --json"); + const raw = result.stdout.trim() || "{}"; + let audit; + try { + audit = JSON.parse(raw); + } catch { + throw new Error(`npm audit did not return JSON:\n${raw}\n${result.stderr}`); + } + return audit; +} + +function readCargoAudit() { + if (!commandExists("cargo-audit")) { + return { missing: true, vulns: [] }; + } + const result = runAllowFail("cargo audit --file src-tauri/Cargo.lock --json"); + const raw = (result.stdout || result.stderr).trim(); + if (!raw) { + return { missing: false, vulns: [], parseError: result.stderr }; + } + let report; + try { + report = JSON.parse(raw); + } catch { + // cargo-audit prints progress on stderr and JSON on stdout; if JSON + // parse failed, treat a zero exit as clean and a non-zero as blocking. + if (result.ok) return { missing: false, vulns: [] }; + return { + missing: false, + vulns: [ + { + crate: "unknown", + id: "cargo-audit", + title: "cargo audit failed", + detail: raw.slice(0, 2000), + }, + ], + }; + } + const list = report.vulnerabilities?.list ?? []; + return { + missing: false, + vulns: list.map((item) => ({ + crate: item.package?.name ?? item.advisory?.package ?? "unknown", + version: item.package?.version, + id: item.advisory?.id, + title: item.advisory?.title, + })), + }; +} + +function commandExists(name) { + try { + run(`command -v ${name}`); + return true; + } catch { + return false; + } +} + +function snapshotLocks() { + return { + npm: existsSync(NPM_LOCK) ? readFileSync(NPM_LOCK, "utf8") : "", + cargo: existsSync(CARGO_LOCK) ? readFileSync(CARGO_LOCK, "utf8") : "", + }; +} + +function currentVersion() { + return JSON.parse(readFileSync(PACKAGE_JSON, "utf8")).version; +} + +export function shouldCutRelease({ hadBlocking, lockfilesChanged, auditOk }) { + return Boolean(hadBlocking && lockfilesChanged && auditOk); +} + +export function bumpPatch(version) { + const parts = version.split(".").map((n) => Number(n)); + if (parts.length !== 3 || parts.some((n) => !Number.isInteger(n))) { + throw new Error(`Unsupported version: ${version}`); + } + parts[2] += 1; + return parts.join("."); +} + +function replaceVersion(path, from, to) { + const original = readFileSync(path, "utf8"); + const updated = original.split(from).join(to); + if (updated === original) { + throw new Error(`Could not bump version in ${path}`); + } + writeFileSync(path, updated); +} + +function bumpProjectVersion(next) { + const current = currentVersion(); + replaceVersion(PACKAGE_JSON, `"version": "${current}"`, `"version": "${next}"`); + replaceVersion(TAURI_CONF, `"version": "${current}"`, `"version": "${next}"`); + replaceVersion(CARGO_TOML, `version = "${current}"`, `version = "${next}"`); + const lockNeedle = `name = "markdown-viewer"\nversion = "${current}"`; + const lockNext = `name = "markdown-viewer"\nversion = "${next}"`; + replaceVersion(CARGO_LOCK, lockNeedle, lockNext); +} + +function formatNotes({ nextVersion, npmBefore, npmAfter, cargoBefore, cargoAfter }) { + const fixedNpm = npmBefore.filter( + (item) => !npmAfter.some((after) => after.name === item.name), + ); + const fixedCargo = cargoBefore.filter( + (item) => !cargoAfter.some((after) => after.id === item.id), + ); + + const lines = [ + `Security dependency updates for ${nextVersion}.`, + "", + ]; + if (fixedNpm.length) { + lines.push("npm:"); + for (const item of fixedNpm) { + const ids = item.ids.length ? ` (${item.ids.join(", ")})` : ""; + lines.push(`- ${item.name}${ids}${item.title ? ` — ${item.title}` : ""}`); + } + lines.push(""); + } + if (fixedCargo.length) { + lines.push("Rust:"); + for (const item of fixedCargo) { + lines.push( + `- ${item.crate}${item.id ? ` (${item.id})` : ""}${item.title ? ` — ${item.title}` : ""}`, + ); + } + lines.push(""); + } + if (!fixedNpm.length && !fixedCargo.length) { + lines.push("Applied lockfile remediations for outstanding advisories."); + lines.push(""); + } + lines.push("Existing installs will offer to update automatically."); + return lines.join("\n"); +} + +function formatRemaining(npmBlocking, cargoVulns) { + const lines = []; + if (npmBlocking.length) { + lines.push("npm high/critical (not allowlisted):"); + for (const item of npmBlocking) { + const fix = + item.fixAvailable === false + ? "no fix" + : typeof item.fixAvailable === "object" + ? `fix via ${item.fixAvailable.name}@${item.fixAvailable.version}${item.fixAvailable.isSemVerMajor ? " (major)" : ""}` + : "fix available"; + lines.push(`- ${item.name} (${item.severity}, ${fix}) ${item.ids.join(", ")}`); + } + } + if (cargoVulns.length) { + lines.push("Rust vulnerabilities:"); + for (const item of cargoVulns) { + lines.push(`- ${item.crate} ${item.version ?? ""} ${item.id ?? ""} ${item.title ?? ""}`.trim()); + } + } + return lines.join("\n"); +} + +function setOutput(name, value) { + const dest = process.env.GITHUB_OUTPUT; + if (!dest) return; + const text = String(value); + if (text.includes("\n")) { + const delim = `EOF_${name}_${Math.random().toString(36).slice(2)}`; + writeFileSync(dest, `${name}<<${delim}\n${text}\n${delim}\n`, { flag: "a" }); + } else { + writeFileSync(dest, `${name}=${text}\n`, { flag: "a" }); + } +} + +function main() { + const allow = allowlistedIds(); + const beforeLocks = snapshotLocks(); + + const npmBefore = collectNpmBlocking(readNpmAudit(), allow); + const cargoBefore = readCargoAudit(); + const hadBlocking = npmBefore.length > 0 || cargoBefore.vulns.length > 0; + if (apply && hadBlocking && cargoBefore.missing) { + throw new Error("cargo-audit is required to remediate Rust advisories"); + } + + // Do not run audit-fix when the only findings are allowlisted: npm audit fix + // can rewrite the lockfile without remediating anything, which would look + // like a release candidate. + if (apply && hadBlocking) { + runAllowFail("npm audit fix --omit=dev"); + if (commandExists("cargo-audit")) { + runAllowFail("cargo audit fix --file src-tauri/Cargo.lock"); + } + } + + const afterLocks = snapshotLocks(); + const lockfilesChanged = + beforeLocks.npm !== afterLocks.npm || beforeLocks.cargo !== afterLocks.cargo; + + const npmAfter = collectNpmBlocking(readNpmAudit(), allow); + const cargoAfter = readCargoAudit(); + const auditOk = npmAfter.length === 0 && (cargoAfter.missing || cargoAfter.vulns.length === 0); + const remediated = shouldCutRelease({ hadBlocking, lockfilesChanged, auditOk }); + + let version = currentVersion(); + let notes = ""; + if (apply && remediated) { + const next = bumpPatch(version); + bumpProjectVersion(next); + version = next; + notes = formatNotes({ + nextVersion: next, + npmBefore, + npmAfter, + cargoBefore: cargoBefore.vulns, + cargoAfter: cargoAfter.vulns, + }); + } + + const remaining = formatRemaining(npmAfter, cargoAfter.vulns ?? []); + + const report = [ + `version=${version}`, + `apply=${apply}`, + `remediated=${remediated}`, + `audit_ok=${auditOk}`, + remaining ? `remaining:\n${remaining}` : "remaining: none (allowlisted findings only)", + notes ? `notes:\n${notes}` : "", + ] + .filter(Boolean) + .join("\n"); + console.log(report); + + setOutput("remediated", remediated ? "true" : "false"); + setOutput("audit_ok", auditOk ? "true" : "false"); + setOutput("version", version); + setOutput("notes", notes); + setOutput("remaining", remaining); + + // Non-zero only on unexpected failure — outstanding vulns are reported via outputs. +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + try { + main(); + } catch (err) { + console.error(err instanceof Error ? err.stack || err.message : err); + process.exit(1); + } +} diff --git a/scripts/security-release.test.mjs b/scripts/security-release.test.mjs new file mode 100644 index 0000000..0dd1a6e --- /dev/null +++ b/scripts/security-release.test.mjs @@ -0,0 +1,94 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + allowlistedIdsFromConfig, + bumpPatch, + collectNpmBlocking, + shouldCutRelease, +} from "./security-release.mjs"; + +describe("bumpPatch", () => { + it("increments the patch number", () => { + expect(bumpPatch("0.7.1")).toBe("0.7.2"); + }); + + it("rejects a non-semver version", () => { + expect(() => bumpPatch("1.0")).toThrow(/Unsupported version/); + }); +}); + +describe("collectNpmBlocking", () => { + const allow = allowlistedIdsFromConfig( + readFileSync(resolve(import.meta.dirname, "../audit-ci.jsonc"), "utf8"), + ); + + it("treats allowlisted GHSA ids and wrapper packages as non-blocking", () => { + const blocking = collectNpmBlocking( + { + vulnerabilities: { + "image-size": { + severity: "high", + via: [ + { + url: "https://github.com/advisories/GHSA-w3rx-r6r6-pgpr", + title: "ICNS loop", + source: 1138808, + }, + { + url: "https://github.com/advisories/GHSA-5p2g-fcmc-qvqq", + title: "JXL loop", + source: 1138809, + }, + ], + fixAvailable: false, + }, + "remark-docx": { + severity: "high", + via: ["image-size"], + fixAvailable: { name: "remark-docx", version: "0.2.1", isSemVerMajor: true }, + }, + }, + }, + allow, + ); + expect(blocking).toEqual([]); + }); + + it("reports a high advisory that is not on the allowlist", () => { + const blocking = collectNpmBlocking( + { + vulnerabilities: { + postcss: { + severity: "high", + via: [ + { + url: "https://github.com/advisories/GHSA-xxxx-yyyy-zzzz", + title: "made up", + }, + ], + fixAvailable: true, + }, + }, + }, + allow, + ); + expect(blocking).toHaveLength(1); + expect(blocking[0].name).toBe("postcss"); + expect(blocking[0].ids).toContain("GHSA-xxxx-yyyy-zzzz"); + }); +}); + +describe("shouldCutRelease", () => { + it("releases only when a blocking advisory was actually remediable", () => { + expect(shouldCutRelease({ hadBlocking: true, lockfilesChanged: true, auditOk: true })).toBe(true); + }); + + it("does not release for lockfile churn when nothing was blocking", () => { + expect(shouldCutRelease({ hadBlocking: false, lockfilesChanged: true, auditOk: true })).toBe(false); + }); + + it("does not release when remediations did not clear the audit", () => { + expect(shouldCutRelease({ hadBlocking: true, lockfilesChanged: true, auditOk: false })).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c1433e6..9ceebcf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "scripts/**/*.test.mjs"], }, });