diff --git a/.github/actions/rust-ci/action.yml b/.github/actions/rust-ci/action.yml index 3a209630..05568561 100644 --- a/.github/actions/rust-ci/action.yml +++ b/.github/actions/rust-ci/action.yml @@ -126,7 +126,29 @@ runs: uses: taiki-e/install-action@cargo-audit - name: Audit dependencies + # cargo audit exits 1 on ANY advisory. Keep non-blocking here; the + # follow-up step fails only when a NEW (un-ignored) advisory is found + # that is not already documented in security/risk-register.yaml. continue-on-error: true shell: bash working-directory: ${{ inputs.working-directory }} - run: cargo audit + run: cargo audit --json > /tmp/cargo-audit.json 2>/dev/null || true + + - name: Check for new Rust advisories + # Run on success or failure of the audit step, but not on cancel. + # Use --min-severity high for cargo to catch DoS-class advisories + # (RUSTSEC-2026-0194/0195 in quick-xml are severity "high"); the + # script handles missing/empty/malformed JSON and missing risk + # register gracefully (exits 0 with a warning in both cases). + # Resolve the script via $GITHUB_WORKSPACE because this composite + # action is invoked with working-directory set to a sub-crate + # (cli/, desktop/src-tauri/, libraries/droplet/), where a relative + # `scripts/check-new-vulns.cjs` would not exist. + if: success() || failure() + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + node "$GITHUB_WORKSPACE/scripts/check-new-vulns.cjs" \ + --format cargo \ + --json /tmp/cargo-audit.json \ + --min-severity high diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 00000000..e2bf1e82 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,36 @@ +# Codecov configuration for Drop monorepo. +# Current baseline: 29.32% line coverage (server backend). +# Thresholds are intentionally informational until coverage crosses 50% — +# flipping to blocking now would block PRs on coverage infrastructure noise. +# Re-evaluate when server coverage > 50% (track in dedicated issue). +coverage: + status: + project: + default: + target: auto + threshold: 2% + base: auto + informational: true + patch: + default: + target: 80% + informational: true +flag_management: + default_rules: + carryforward: true + statuses: + - type: project + target: auto + threshold: 2% + informational: true + - type: patch + target: 80% + informational: true + individual_flags: + - name: server + paths: + - server/ + carryforward: true +comment: + layout: "diff, flags, files" + behavior: default diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47d9626f..d6be2d06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: branches: - main - rebuild + - develop permissions: contents: read @@ -96,6 +97,19 @@ jobs: # Remove ignore when upstream publishes decompress@>=4.2.2. continue-on-error: true run: pnpm audit --audit-level=critical --ignore GHSA-mp2f-45pm-3cg9 + + - name: Check for new critical advisories + # Run on success or failure of the audit step, but not on cancel. + # Use --min-severity critical for pnpm to limit noise; the script + # handles missing/empty/malformed JSON gracefully. + if: success() || failure() + run: | + pnpm audit --audit-level=critical --json > /tmp/audit.json 2>/dev/null || true + node scripts/check-new-vulns.cjs \ + --format pnpm \ + --json /tmp/audit.json \ + --ignored GHSA-mp2f-45pm-3cg9 \ + --min-severity critical - name: Typecheck working-directory: server run: pnpm run typecheck @@ -174,7 +188,14 @@ jobs: fail_ci_if_error: false flags: server - name: Post coverage gaps to PR + # Skip silently when CODECOV_TOKEN is unset — the script fails + # Only run on PRs (script posts a comment); wrap with || true so a + # missing or invalid CODECOV_TOKEN does not fail the workflow. + # Step-level env is not visible in this step's own `if:` context + # so we cannot gate on env.CODECOV_TOKEN here; rely on the script + # being tolerant instead. if: github.event_name == 'pull_request' + continue-on-error: true env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -217,7 +238,6 @@ jobs: restore-keys: ${{ runner.os }}-sonar - name: SonarQube Scan id: sonar-scan - continue-on-error: true uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} @@ -244,7 +264,11 @@ jobs: name: SonarCloud PR Comment runs-on: ubuntu-latest needs: sonar - if: github.event_name == 'pull_request' + # Only run when the scan succeeded — otherwise the API has no findings + # to comment on and the script would post a confusing empty/errored + # comment. Branch protection enforces SonarCloud Scan as required, so + # a scan failure correctly blocks the merge regardless. + if: github.event_name == 'pull_request' && needs.sonar.result == 'success' permissions: contents: read pull-requests: write diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index e68270ed..504bd0e7 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -6,11 +6,15 @@ on: paths: - "cli/**" - ".github/workflows/cli-ci.yml" + - "pnpm-workspace.yaml" + - "package.json" pull_request: - branches: [rebuild] + branches: [rebuild, develop] paths: - "cli/**" - ".github/workflows/cli-ci.yml" + - "pnpm-workspace.yaml" + - "package.json" workflow_dispatch: permissions: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 449219f2..ce6b2d59 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -15,7 +15,7 @@ on: push: branches: ["rebuild"] pull_request: - branches: ["rebuild"] + branches: ["rebuild", "develop"] schedule: - cron: "39 17 * * 0" diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml index 9e30652f..3f3e39f0 100644 --- a/.github/workflows/desktop-ci.yml +++ b/.github/workflows/desktop-ci.yml @@ -6,11 +6,15 @@ on: paths: - "desktop/src-tauri/**" - ".github/workflows/desktop-ci.yml" + - "pnpm-workspace.yaml" + - "package.json" pull_request: - branches: [rebuild] + branches: [rebuild, develop] paths: - "desktop/src-tauri/**" - ".github/workflows/desktop-ci.yml" + - "pnpm-workspace.yaml" + - "package.json" workflow_dispatch: permissions: diff --git a/.github/workflows/droplet-ci.yml b/.github/workflows/droplet-ci.yml index 3d0c9d0b..3cc729b6 100644 --- a/.github/workflows/droplet-ci.yml +++ b/.github/workflows/droplet-ci.yml @@ -8,13 +8,17 @@ on: - "libraries/droplet_types/**" - "libraries/libarchive/**" - ".github/workflows/droplet-ci.yml" + - "pnpm-workspace.yaml" + - "package.json" pull_request: - branches: [rebuild] + branches: [rebuild, develop] paths: - "libraries/droplet/**" - "libraries/droplet_types/**" - "libraries/libarchive/**" - ".github/workflows/droplet-ci.yml" + - "pnpm-workspace.yaml" + - "package.json" workflow_dispatch: jobs: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1619f537..56be49f6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -11,7 +11,7 @@ on: - "pnpm-lock.yaml" - "pnpm-workspace.yaml" pull_request: - branches: [rebuild] + branches: [rebuild, develop] paths: - "server/**" - ".github/workflows/e2e.yml" diff --git a/.github/workflows/editorconfig-ci.yml b/.github/workflows/editorconfig-ci.yml index 13ba04fb..0d23511c 100644 --- a/.github/workflows/editorconfig-ci.yml +++ b/.github/workflows/editorconfig-ci.yml @@ -4,7 +4,7 @@ on: push: branches: [rebuild] pull_request: - branches: [rebuild] + branches: [rebuild, develop] workflow_dispatch: permissions: diff --git a/.github/workflows/open-code-review.yml b/.github/workflows/open-code-review.yml index 52c018be..e64d9777 100644 --- a/.github/workflows/open-code-review.yml +++ b/.github/workflows/open-code-review.yml @@ -2,7 +2,7 @@ name: OpenCodeReview PR Review on: pull_request: - branches: [main, rebuild] + branches: [main, rebuild, develop] types: [opened, synchronize, reopened, ready_for_review] permissions: diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index d836eac8..c5854db9 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -1,10 +1,16 @@ name: OSV-Scanner +# Cancel outdated in-progress runs of the same workflow on the same PR +# when a new commit is pushed. Avoids redundant scans and stale SARIF uploads. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + on: pull_request: - branches: ["rebuild"] + branches: ["rebuild", "develop"] merge_group: - branches: ["rebuild"] + branches: ["rebuild", "develop"] schedule: - cron: "26 14 * * 5" push: @@ -28,6 +34,10 @@ jobs: persist-credentials: false - name: Run OSV-Scanner + # OSV scanner exits 1 on ANY CVE in the dependency tree (including transitive). + # We keep continue-on-error: true because blocking on transitive vulns would + # create constant noise. SARIF results are still uploaded below for review + # and the scan-pr job on pull_request events catches direct deps separately. continue-on-error: true uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 with: diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index e8e6260c..bcf44c7c 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -11,7 +11,7 @@ on: - "pnpm-workspace.yaml" - ".github/workflows/server-ci.yml" pull_request: - branches: [rebuild] + branches: [rebuild, develop] paths: - "server/**" - "libraries/base/**" diff --git a/scripts/check-new-vulns.cjs b/scripts/check-new-vulns.cjs new file mode 100644 index 00000000..dd33c7a2 --- /dev/null +++ b/scripts/check-new-vulns.cjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +// Detect NEW (un-accepted) advisories in pnpm/cargo audit JSON output, +// comparing against entries in security/risk-register.yaml. +// +// Behavior: +// - Missing or unparseable audit JSON → exit 0 (assume tool flaked, do +// not fail the workflow on infra noise). A noisy log line is emitted. +// - Risk register missing or unparseable → exit 0 (same reason). This +// avoids the failure mode where an empty `known` set causes every +// advisory to be flagged as new. +// - New (un-registered) advisory found → exit 1, log each one. +// - All advisories in register or no advisories → exit 0. +// +// Usage: +// check-new-vulns.cjs --format pnpm --json /tmp/audit.json +// check-new-vulns.cjs --format cargo --json /tmp/cargo-audit.json +// +// Flags: +// --format {pnpm|cargo} audit JSON shape to parse +// --json path to audit JSON output +// --register path to risk-register.yaml (default: security/risk-register.yaml) +// --ignored comma-separated advisory IDs to ignore unconditionally +// (e.g. for pnpm audit --ignore GHSA-...) +// --min-severity minimum severity to report (pnpm: critical|high|moderate|low; +// cargo: critical|high|medium|low|informational). Default: critical. + +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const HELP_TEXT = + "Usage: check-new-vulns.cjs --format {pnpm|cargo} --json " + + "[--register ] [--ignored ] [--min-severity ]\n\n" + + "Compares pnpm/cargo audit JSON output against entries in\n" + + "security/risk-register.yaml and exits 1 only when a new (un-accepted)\n" + + "advisory is detected. Missing/empty/malformed audit JSON or register\n" + + "exits 0 (treated as infra noise)."; + +// fallow-ignore-next-line complexity +function parseArgs(argv) { + const args = {}; + // Reject values that look like another flag (start with `--`) or are + // missing entirely. Catches typos like `--format --json file.json` where + // `--json` would be silently consumed as the format value. + const nextArg = (i, flagName) => { + if (i >= argv.length) { + console.error(`[check-new-vulns] missing value for ${flagName}`); + process.exit(2); + } + const v = argv[i]; + if (v.startsWith("--")) { + console.error( + `[check-new-vulns] ${flagName} requires a value (got another flag '${v}')`, + ); + process.exit(2); + } + return v; + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--format") args.format = nextArg(++i, "--format"); + else if (a === "--json") args.json = nextArg(++i, "--json"); + else if (a === "--register") args.register = nextArg(++i, "--register"); + else if (a === "--ignored") { + const val = nextArg(++i, "--ignored"); + args.ignored = val + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } else if (a === "--min-severity") + args.minSeverity = nextArg(++i, "--min-severity"); + else if (a === "--help" || a === "-h") { + console.log(HELP_TEXT); + process.exit(0); + } + } + return args; +} + +function readJson(path) { + try { + if (!fs.existsSync(path)) return { ok: false, reason: "file missing" }; + const raw = fs.readFileSync(path, "utf8").trim(); + if (!raw) return { ok: false, reason: "file empty" }; + return { ok: true, data: JSON.parse(raw) }; + } catch (e) { + return { ok: false, reason: e.message }; + } +} + +// Parse security/risk-register.yaml by line-scanning for `advisory:` fields. +// We deliberately avoid a full YAML parser (no extra deps in CI). Format is +// stable: each entry has `advisory: GHSA-...` or `advisory: RUSTSEC-...` on +// its own line. Comment lines and unrelated fields are ignored. +// +// Returns { known, loaded } where `loaded` is false when the file is +// missing OR unparseable. A loaded-but-empty-known (file exists but no +// `advisory:` entries matched) is treated as loaded: true — the caller can +// still proceed with an empty known set and will correctly flag every +// advisory as new. +function readKnownAdvisories(path) { + let known = new Set(); + let loaded = false; + try { + if (!fs.existsSync(path)) return { known, loaded: false }; + const text = fs.readFileSync(path, "utf8"); + // Lenient: ignore any trailing content (e.g. inline `# comment`). + // Avoids super-linear backtracking and future-proofs against trailing comments. + const re = /^\s*advisory:\s*(\S+)/gm; + let m; + while ((m = re.exec(text)) !== null) { + known.add(m[1]); + } + loaded = true; + } catch (e) { + return { known: new Set(), loaded: false }; + } + return { known, loaded }; +} + +function severityRank(s) { + // Unknown / missing severity is treated as critical (highest rank) so + // we never silently filter out a vulnerability because its severity + // string was unrecognized or absent — false negatives are worse than + // false positives here. + return ( + { critical: 4, high: 3, moderate: 2, medium: 2, low: 1, informational: 0 }[ + (s || "").toLowerCase() + ] ?? 4 + ); +} + +function extractPnpm(data, minSeverity) { + const advisories = data.advisories ? Object.values(data.advisories) : []; + const minRank = severityRank(minSeverity); + return advisories + .filter((a) => severityRank(a.severity) >= minRank) + .map((a) => ({ + id: a.github_advisory_id, + module: a.module_name, + severity: a.severity, + title: a.title, + })); +} + +function extractCargo(data, minSeverity) { + const vulns = (data.vulnerabilities && data.vulnerabilities.list) || []; + const minRank = severityRank(minSeverity); + return ( + vulns + .filter((v) => v.advisory && severityRank(v.advisory.severity) >= minRank) + // fallow-ignore-next-line complexity + .map((v) => ({ + id: v.advisory?.id ?? "unknown", + module: v.package?.name ?? "unknown", + severity: v.advisory?.severity ?? "unknown", + title: v.advisory?.title ?? "unknown", + })) + ); +} + +// fallow-ignore-next-line complexity +function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.format || !args.json) { + console.error( + "Usage: check-new-vulns.cjs --format {pnpm|cargo} --json [--register ] [--ignored ] [--min-severity ]", + ); + process.exit(2); + } + if (!["pnpm", "cargo"].includes(args.format)) { + console.error(`Unsupported --format: ${args.format}`); + process.exit(2); + } + + // Default register path: GITHUB_WORKSPACE (repo root) when available, + // falling back to process.cwd()/security/. Composite actions like + // rust-ci set working-directory to a sub-crate (cli/, desktop/src-tauri/), + // so process.cwd() alone would silently miss the register and flag every + // advisory as new. + const registerPath = + args.register || + path.join( + process.env.GITHUB_WORKSPACE || process.cwd(), + "security", + "risk-register.yaml", + ); + const ignored = new Set(args.ignored || []); + + const loaded = readJson(args.json); + if (!loaded.ok) { + console.warn( + `[check-new-vulns] ${args.format} audit JSON unavailable (${loaded.reason}); treating as no advisories.`, + ); + process.exit(0); + } + + const minSeverity = args.minSeverity || "critical"; + const all = + args.format === "pnpm" + ? extractPnpm(loaded.data, minSeverity) + : extractCargo(loaded.data, minSeverity); + + // Distinguish "register loaded but empty" from "register could not be + // loaded" so we don't flag every advisory as new when the file is + // genuinely missing or unparseable (infra noise). + const { known, loaded: registerAvailable } = + readKnownAdvisories(registerPath); + + if (!registerAvailable) { + console.warn( + `[check-new-vulns] risk register unavailable at ${registerPath}; treating as no known advisories (infra noise, not a failure).`, + ); + process.exit(0); + } + + const newOnes = all.filter((a) => !ignored.has(a.id) && !known.has(a.id)); + + if (newOnes.length > 0) { + for (const a of newOnes) { + console.error( + `NEW ${args.format.toUpperCase()} ADVISORY: ${a.id} ${a.module} (${a.severity}) — ${a.title}`, + ); + } + process.exit(1); + } + + process.exit(0); +} + +main();