From 5b183c466398292e5f09024f71351829c39f66ed Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 9 Aug 2026 19:12:25 +0300 Subject: [PATCH 01/13] feat(security): classify the repository's secret-scanning history and pin the allowlist --- .gitignore | 7 +++ .gitleaks.toml | 62 ++++++++++++++++++++ tests/unit/gitleaks-config.test.ts | 94 ++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 .gitleaks.toml create mode 100644 tests/unit/gitleaks-config.test.ts diff --git a/.gitignore b/.gitignore index f07c6519..4f50bdb2 100644 --- a/.gitignore +++ b/.gitignore @@ -166,5 +166,12 @@ docs/summaries/ deploy/digitalocean/droplet/scripts/90-cleanup.sh deploy/digitalocean/droplet/scripts/99-img-check.sh +# Local output from the security scanners (see CONTRIBUTING.md). CI writes its +# reports to RUNNER_TEMP; a developer running the same commands by hand does not. +/gitleaks-report.json +/trivy-report.json +/trivy-report.sarif +/*.cdx.json + diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..4af81b8b --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,62 @@ +# Secret scanning configuration for LibreDB Studio (security programme control 2.1). +# +# The full-history sweep that produced this file found 24 matches across 753 +# commits and classified every one of them as a fabricated value: no credential +# has ever been committed to this repository and no rotation was required. The +# four allowlists below are that classification, written down, so the +# incremental scan on every pull request starts from zero and any new match is a +# real finding rather than one of the same 24 forever. +# +# Every allowlist names `targetRules`. A rule-less allowlist would exempt a path +# from ALL of gitleaks' rules, including the provider-specific ones (AWS, GCP, +# Slack, Stripe) that have no false positives here - which is how an allowlist +# written for a fake JWT ends up hiding a real AWS key. tests/unit/gitleaks-config.test.ts +# enforces that. + +title = "LibreDB Studio secret scanning" + +[extend] +# Start from gitleaks' own rule set rather than an in-repo copy: the rules move +# with the pinned scanner digest, which is what makes the verdict a pure function +# of the commit plus that digest. +useDefault = true + +[[allowlists]] +description = """ +Test fixtures. Every secret-shaped literal under tests/ is fabricated - the +jwt.io sample token, hex filler, self-describing strings such as +"winner-secret-that-is-at-least-32-chars", and an RSA key whose body is the word +"fake". Scoped to the three generic rules that match shape rather than issuer, so +a real provider token pasted into a test still fails the scan; GitHub's push +protection covers that case independently. +""" +targetRules = ["generic-api-key", "jwt", "private-key"] +paths = ['''^tests/'''] + +[[allowlists]] +description = """ +The two example passwords in the documented Helm install commands, which appear +verbatim in charts/libredb-studio/README.md, its operator copy, +docs/HELM_CHART.md and an archived planning document. Allowlisted by VALUE, not +by path, so a different literal in the same files is still reported. +""" +targetRules = ["generic-api-key"] +regexes = ['''^StrongPass\d+$'''] +regexTarget = "secret" + +[[allowlists]] +description = """ +The two PEM placeholders in the connection form's textareas ("Optional client +key...", "Paste private key here..."). They are UI copy, not keys. +""" +targetRules = ["private-key"] +paths = ['''^src/components/ConnectionModal\.tsx$'''] + +[[allowlists]] +description = """ +The Couchbase healthcheck in the local development compose file, whose curl -u +argument is "$$COUCHBASE_USER:$$COUCHBASE_PASSWORD" - Compose variable +interpolation, resolved from the same file's environment block. +""" +targetRules = ["curl-auth-user"] +paths = ['''^database-compose\.yml$'''] diff --git a/tests/unit/gitleaks-config.test.ts b/tests/unit/gitleaks-config.test.ts new file mode 100644 index 00000000..cf2b48ba --- /dev/null +++ b/tests/unit/gitleaks-config.test.ts @@ -0,0 +1,94 @@ +/** + * Threat: an allowlist that hides a real credential. + * + * The historical sweep found 24 matches and classified all 24 as fabricated, so + * .gitleaks.toml exists to make the incremental scan start from zero. The way + * that file goes wrong is not a wrong regex - it is a future maintainer + * silencing one noisy path by adding a `paths` entry with no `targetRules`, + * which exempts that path from EVERY gitleaks rule, including the AWS, GCP, + * Slack and Stripe rules that have never produced a false positive here. + * + * Parsed with Bun.TOML rather than imported: `import x from "*.toml"` is a bun + * loader feature that `tsc --noEmit` rejects, and typecheck is a required gate. + */ +import { describe, expect, test } from "bun:test"; +import * as fs from "fs"; +import * as path from "path"; + +interface Allowlist { + description?: string; + targetRules?: string[]; + paths?: string[]; + regexes?: string[]; + regexTarget?: string; +} + +const raw = fs.readFileSync(path.join(__dirname, "../../.gitleaks.toml"), "utf8"); +const config = Bun.TOML.parse(raw) as { + extend?: { useDefault?: boolean }; + allowlists?: Allowlist[]; +}; +const allowlists = config.allowlists ?? []; + +describe(".gitleaks.toml", () => { + test("extends gitleaks' own rule set instead of vendoring a copy", () => { + // A vendored rule set stops moving when the pinned scanner moves, and the + // gap is invisible: the scan still passes, it just stops looking for the + // provider tokens the new rules added. + expect(config.extend?.useDefault).toBe(true); + }); + + test("has allowlists at all - the sweep found 24 fabricated matches to classify", () => { + expect(allowlists.length).toBeGreaterThan(0); + }); + + test("every allowlist is scoped to named rules", () => { + for (const entry of allowlists) { + expect({ description: entry.description, scoped: (entry.targetRules ?? []).length > 0 }).toEqual({ + description: entry.description, + scoped: true, + }); + } + }); + + test("every allowlist explains itself", () => { + // The description is what a reviewer reads three months from now to decide + // whether the exemption is still true. + for (const entry of allowlists) { + expect((entry.description ?? "").trim().length).toBeGreaterThan(40); + } + }); + + test("no allowlist matches every path", () => { + // '.*', '^.*$', '' and '/' all exempt the whole repository. + const catchAll = new Set(["", ".*", "^.*$", "^", "/", "^/"]); + for (const entry of allowlists) { + for (const p of entry.paths ?? []) { + expect({ path: p, catchAll: catchAll.has(p.trim()) }).toEqual({ path: p, catchAll: false }); + } + } + }); + + test("no allowlist exempts the application source tree wholesale", () => { + // A single file is a reviewable exemption; the tree is not. + for (const entry of allowlists) { + for (const p of entry.paths ?? []) { + expect({ path: p, tree: /^\^?src\/?\$?$|^\^src\/(\*|\.\*)?$/.test(p.trim()) }).toEqual({ + path: p, + tree: false, + }); + } + } + }); + + test("a value-based allowlist says which part of the finding it matches", () => { + // Without regexTarget, gitleaks matches the regex against the whole line, + // so '^StrongPass\\d+$' would silently never match and the exemption would + // look present while doing nothing. + for (const entry of allowlists) { + if ((entry.regexes ?? []).length > 0) { + expect(entry.regexTarget).toBe("secret"); + } + } + }); +}); From 0af070bb82dff470719e6465621699c8d9eb9ccc Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 9 Aug 2026 19:33:49 +0300 Subject: [PATCH 02/13] feat(security): add security-scan.yml with a blocking incremental secret scan --- .github/workflows/security-scan.yml | 143 +++++++++++++++++++++ tests/unit/security-scan-workflow.test.ts | 148 ++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 .github/workflows/security-scan.yml create mode 100644 tests/unit/security-scan-workflow.test.ts diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 00000000..d5598b42 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,143 @@ +name: Security Scan + +# Supply-chain scanning (security programme controls 2.1 and 2.2). Deliberately +# NOT part of ci.yml: the dependency and image jobs depend on a vulnerability +# database that is rebuilt every six hours, so their verdict can change with no +# commit, and the repository's required checks must stay a function of the code +# under review. Same reasoning that keeps SonarCloud out of the required set. +# +# WHAT BLOCKS AND WHAT REPORTS +# +# secret-scan fails. Its verdict is a pure function of the scanned commit +# range and the pinned gitleaks digest - no feed can turn it +# red overnight - and a secret in a diff is unambiguous and +# fixable by the author. +# dependency-scan reports on a pull request (job summary only) and gates on +# main, the daily schedule and manual runs. An advisory +# published overnight must not turn a contributor's unrelated +# pull request red; the maintainer still learns within a day, +# which is what control 2.1 asks for. GitHub emails the owner +# when a scheduled run fails, so a red cron is not silent. +# image-scan never fails. Measured 2026-08-09 against +# node:24.16.0-trixie-slim: 4 critical and 18 high Debian +# CVEs, and 167 of 168 findings have no fixed package. A gate +# over that is a permanent red with nothing to do about it - +# which is how a gate becomes a continue-on-error line. +# +# None of these is a required check today. If one is ever promoted, secret-scan +# is the only candidate, for the determinism reason above. +# +# Scanners run as containers pinned by DIGEST rather than as third-party actions: +# a supply-chain workflow should not add a third-party action to its own trust +# chain, and a digest freezes the rule set together with the binary. + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: + - main + schedule: + # 03:37 UTC daily, clear of the other crons in this repository (05:23 daily, + # 06:00 and 06:41 Monday, 06:20 Friday). Daily, not weekly: the point of + # control 2.1 is to learn about a driver CVE before users do. + - cron: "37 3 * * *" + workflow_dispatch: + +concurrency: + # Keyed by ref: a shared group would let the daily cron and a pull request run + # cancel each other, leaving a cancelled check on the pull request. + group: security-scan-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # gitleaks v8.30.1. + GITLEAKS_IMAGE: zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f + # Trivy 0.73.0. + TRIVY_IMAGE: aquasec/trivy@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c + +jobs: + secret-scan: + name: Secret Scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # The scan reads git history, so the default single-commit checkout + # would make every range empty - and an empty range passes. The whole + # history is 753 commits / 14.5 MB and scans in about 1.1 seconds, so + # there is nothing here worth optimising. + fetch-depth: 0 + + - name: Resolve the commit range to scan + id: range + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "pull_request" ]; then + # Only the commits this pull request adds. Without --no-merges a + # merge commit re-presents the whole other side of the merge as a + # diff, which would report every historical match again on every + # pull request - and a check that is always red is a check nobody + # reads. + echo "log_opts=--no-merges $BASE_SHA..$HEAD_SHA" >> "$GITHUB_OUTPUT" + else + # On main, the cron and a manual run: the whole history. The + # repository authorises direct pushes to main, and at 1.1 seconds + # the full sweep is cheaper than reasoning about which range covers + # a force push. + echo "log_opts=--all" >> "$GITHUB_OUTPUT" + fi + + - name: Scan for committed secrets + env: + LOG_OPTS: ${{ steps.range.outputs.log_opts }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/gitleaks" + docker run --rm \ + -v "$GITHUB_WORKSPACE:/repo:ro" \ + -v "$RUNNER_TEMP/gitleaks:/out" \ + -w /repo \ + "$GITLEAKS_IMAGE" git \ + --no-banner \ + --redact \ + --config /repo/.gitleaks.toml \ + --log-opts="$LOG_OPTS" \ + --report-format json \ + --report-path /out/gitleaks.json + + - name: Report the findings + if: failure() + run: | + set -uo pipefail + report="$RUNNER_TEMP/gitleaks/gitleaks.json" + { + echo "## Committed secrets found" + echo + if [ -s "$report" ]; then + echo "| Rule | File | Line | Commit |" + echo "| --- | --- | --- | --- |" + jq -r '.[] | "| \(.RuleID) | \(.File) | \(.StartLine) | \(.Commit[0:8]) |"' "$report" + else + echo "The scanner failed before producing a report - read the step log." + fi + echo + echo "**A hit is an incident, not a lint failure.** Rotate the credential first, then" + echo "remove it from the working tree. Do not rewrite history: forks and every" + echo "distribution channel already carry the old objects, so rotation is the only" + echo "remedy that works." + echo + echo "If the match is fabricated - a fixture, a placeholder, documented example copy -" + echo "add a rule-scoped allowlist to \`.gitleaks.toml\` with a description that says" + echo "why. An allowlist without \`targetRules\` is rejected by" + echo "\`tests/unit/gitleaks-config.test.ts\`." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/tests/unit/security-scan-workflow.test.ts b/tests/unit/security-scan-workflow.test.ts new file mode 100644 index 00000000..8a755896 --- /dev/null +++ b/tests/unit/security-scan-workflow.test.ts @@ -0,0 +1,148 @@ +/** + * Unit tests for security-scan.yml's invariants. + * + * Why a test for YAML: every failure mode here is silent. A scanner pinned to + * `:latest` still runs, it just stops being the scanner that was reviewed. A + * `continue-on-error` on the secret scan still shows a green check. A SARIF + * upload without the fork guard fails only on a fork's pull request, which the + * maintainer never sees on their own branches. None of these breaks a run; each + * removes a guarantee. + * + * The same asymmetry as tests/unit/release-provenance.test.ts, applied to the + * scanning side. + */ +import { describe, expect, test } from "bun:test"; +import * as fs from "fs"; +import * as path from "path"; +import { parse as parseYaml } from "yaml"; + +interface Step { + name?: string; + run?: string; + if?: string; + id?: string; + uses?: string; + with?: Record; + env?: Record; + "continue-on-error"?: boolean; +} +interface Job { + name?: string; + "runs-on"?: string; + if?: string; + permissions?: Record; + "timeout-minutes"?: number; + steps?: Step[]; +} +interface Workflow { + on: Record; + env?: Record; + permissions?: Record; + concurrency?: { group?: string; "cancel-in-progress"?: boolean }; + jobs: Record; +} + +const file = path.join(__dirname, "../../.github/workflows/security-scan.yml"); +const workflow = parseYaml(fs.readFileSync(file, "utf8")) as Workflow; +const allSteps = Object.values(workflow.jobs).flatMap((j) => j.steps ?? []); + +const DIGEST = /@sha256:[0-9a-f]{64}$/; + +describe("security-scan.yml wiring", () => { + test("runs on pull requests, on main, on a schedule and on demand", () => { + // The four together are the control: pull requests get the deterministic + // scan, main and the cron get the gate, dispatch is how a maintainer + // re-checks after taking a fix. + expect(Object.keys(workflow.on).sort()).toEqual(["pull_request", "push", "schedule", "workflow_dispatch"]); + }); + + test("keys concurrency by ref", () => { + // A shared group lets the daily cron and a pull request run cancel each + // other, leaving a cancelled check on the pull request - the exact failure + // distribution-check.yml records in its own comment. + expect(workflow.concurrency?.group).toContain("github.ref"); + }); + + test("defaults to read-only permissions", () => { + expect(workflow.permissions?.contents).toBe("read"); + }); + + test("pins every scanner image by digest", () => { + const images = Object.entries(workflow.env ?? {}).filter(([k]) => k.endsWith("_IMAGE")); + expect(images.length).toBeGreaterThan(0); + for (const [key, value] of images) { + expect({ key, pinned: DIGEST.test(value) }).toEqual({ key, pinned: true }); + } + }); + + test("never runs a container by tag", () => { + // An inline `docker run some/image:tag` would bypass the env pinning above. + for (const step of allSteps) { + const run = step.run ?? ""; + expect({ name: step.name, taggedRun: /docker run[^\n]*\s[a-z0-9./-]+:[a-z0-9.-]+\s/.test(run) }).toEqual({ + name: step.name, + taggedRun: false, + }); + } + }); + + test("pins every action to a full commit SHA", () => { + for (const step of allSteps) { + if (!step.uses) continue; + if (step.uses.startsWith("./")) continue; // the local bun-install composite + expect({ uses: step.uses, pinned: /@[0-9a-f]{40}$/.test(step.uses) }).toEqual({ + uses: step.uses, + pinned: true, + }); + } + }); +}); + +describe("secret-scan is the one scan allowed to fail a check", () => { + const job = workflow.jobs["secret-scan"]; + const steps = job?.steps ?? []; + const checkout = steps.find((s) => s.uses?.startsWith("actions/checkout@")); + const scan = steps.find((s) => s.run?.includes("gitleaks") || s.run?.includes("GITLEAKS_IMAGE")); + + test("exists and is named for a human reading the checks list", () => { + expect(job).toBeDefined(); + expect(job.name).toBe("Secret Scan"); + }); + + test("checks out enough history to scan a range", () => { + // The default single-commit checkout makes `git log base..head` empty, and + // an empty range scans nothing and passes. + expect(checkout?.with?.["fetch-depth"]).toBe(0); + }); + + test("scans only the pull request's own commits, and skips merge commits", () => { + const resolver = steps.find((s) => s.id === "range"); + expect(resolver?.run).toContain("--no-merges"); + expect(resolver?.run).toContain("pull_request"); + expect(resolver?.run).toContain("--all"); + }); + + test("passes the repository's allowlist rather than gitleaks' bare defaults", () => { + expect(scan?.run).toContain(".gitleaks.toml"); + }); + + test("redacts the matched value out of the log", () => { + // The log is public on a public repository. A finding that prints the secret + // widens the incident it is reporting. + expect(scan?.run).toContain("--redact"); + }); + + test("no step in this job is advisory", () => { + for (const step of steps) { + expect({ name: step.name, advisory: step["continue-on-error"] === true }).toEqual({ + name: step.name, + advisory: false, + }); + } + }); + + test("the job itself is not conditional", () => { + // A job-level `if` here would be a way to make the one blocking scan skip. + expect(job.if).toBeUndefined(); + }); +}); From ca20160f83b56abff8c1ab701fd508df85e96f02 Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 9 Aug 2026 19:55:02 +0300 Subject: [PATCH 03/13] feat(security): scan lockfiles on every pull request and gate critical fixable advisories on main --- .github/workflows/security-scan.yml | 178 ++++++++++++++++++++++ .trivyignore.yaml | 43 ++++++ tests/unit/security-scan-workflow.test.ts | 77 ++++++++++ 3 files changed, 298 insertions(+) create mode 100644 .trivyignore.yaml diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index d5598b42..c68ccaaf 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -141,3 +141,181 @@ jobs: echo "why. An allowlist without \`targetRules\` is rejected by" echo "\`tests/unit/gitleaks-config.test.ts\`." } >> "$GITHUB_STEP_SUMMARY" + + dependency-scan: + name: Dependency Scan + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + # For the SARIF upload. A fork pull request's token is read-only and does + # not get this, which is why the upload step below carries a guard: the + # findings reach a fork contributor through the job summary instead. Same + # constraint that keeps SonarCloud out of the required check set. + security-events: write + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.14" + + - name: Install dependencies + uses: ./.github/actions/bun-install + + - name: Compute the database cache day + id: day + run: echo "value=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" + + - name: Restore the Trivy vulnerability database + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ runner.temp }}/trivy-cache + # Rotated daily: upstream rebuilds every six hours, and a stale cache + # would report yesterday's verdict as today's. restore-keys still warms + # from the previous day so the repeated same-day runs on a busy pull + # request do not re-download. + key: trivy-db-${{ steps.day.outputs.value }} + restore-keys: trivy-db- + + - name: Scan the lockfiles + env: + # GHCR rate-limits anonymous pulls per address and hosted runners share + # addresses, which is the usual cause of "failed to download + # vulnerability DB". The workflow token lifts that limit and exists on + # fork pull requests too, where read-only is enough to pull a public + # package. + TRIVY_USERNAME: ${{ github.actor }} + TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/trivy-cache" "$RUNNER_TEMP/trivy-out" + # No severity filter and nothing that can fail the job: this run only + # reports. Trivy finds three lockfiles here - bun.lock, + # packaging/windows/launcher/go.mod and desktop/src-tauri/Cargo.lock - + # so one scan covers the npm closure, the Windows launcher and the + # desktop shell. + docker run --rm \ + -e TRIVY_USERNAME -e TRIVY_PASSWORD \ + -v "$GITHUB_WORKSPACE:/repo:ro" \ + -v "$RUNNER_TEMP/trivy-cache:/root/.cache/trivy" \ + -v "$RUNNER_TEMP/trivy-out:/out" \ + -w /repo \ + "$TRIVY_IMAGE" fs \ + --scanners vuln \ + --ignorefile /repo/.trivyignore.yaml \ + --skip-dirs node_modules \ + --skip-dirs .next \ + --skip-dirs dist \ + --skip-dirs coverage \ + --format json \ + --output /out/deps.json \ + . + + - name: Render the report + run: | + set -euo pipefail + # Both renderings come from the ONE scan above, so the summary a human + # reads and the SARIF the Security tab ingests can never disagree. + docker run --rm \ + -v "$RUNNER_TEMP/trivy-out:/out" \ + "$TRIVY_IMAGE" convert --format table --output /out/deps.txt /out/deps.json + docker run --rm \ + -v "$RUNNER_TEMP/trivy-out:/out" \ + "$TRIVY_IMAGE" convert --format sarif --output /out/deps.sarif /out/deps.json + + - name: Publish the findings to the job summary + run: | + set -euo pipefail + { + echo "## Dependency findings" + echo + echo "Source: \`bun.lock\`, \`packaging/windows/launcher/go.mod\`, \`desktop/src-tauri/Cargo.lock\`." + echo "Suppressions: \`.trivyignore.yaml\`." + echo + echo '```' + cat "$RUNNER_TEMP/trivy-out/deps.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the findings to the security tab + # A fork pull request's token cannot write security events. Same guard + # shape as ci.yml's SonarCloud job, for the same reason. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: github/codeql-action/upload-sarif@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4 + with: + sarif_file: ${{ runner.temp }}/trivy-out/deps.sarif + # Without a category this run would overwrite CodeQL's own results for + # the same ref. + category: trivy-dependencies + + - name: Second opinion from bun audit + # npm's own advisory feed, refreshed continuously rather than on + # trivy-db's six-hour rebuild, so it occasionally sees an advisory a few + # hours earlier. It reports no fixed-version data at all, so there is + # nothing here to gate on - and `|| true` because it exits 1 whenever it + # finds anything, which today is 85 findings. It is also the one command + # a contributor can run locally with no container. + run: | + set -uo pipefail + bun audit > "$RUNNER_TEMP/bun-audit.txt" 2>&1 || true + { + echo "## bun audit" + echo + echo '```' + cat "$RUNNER_TEMP/bun-audit.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: "Gate: critical, fixable, unsuppressed" + # Not on a pull request: an advisory published overnight must not turn a + # contributor's unrelated change red. On main, the daily cron and a manual + # run it fails, and GitHub emails the repository owner when a scheduled + # run fails - which is how "learn before users do" reaches a human. + # + # A second `fs` run rather than a convert of the report above: verified + # 2026-08-09 that `trivy convert` does NOT honour --ignore-unfixed, so a + # convert-based gate would fail on findings nobody can fix. The + # database is already in the cache, so this costs seconds. + if: github.event_name != 'pull_request' + env: + TRIVY_USERNAME: ${{ github.actor }} + TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + docker run --rm \ + -e TRIVY_USERNAME -e TRIVY_PASSWORD \ + -v "$GITHUB_WORKSPACE:/repo:ro" \ + -v "$RUNNER_TEMP/trivy-cache:/root/.cache/trivy" \ + -w /repo \ + "$TRIVY_IMAGE" fs \ + --scanners vuln \ + --severity CRITICAL \ + --ignore-unfixed \ + --exit-code 1 \ + --ignorefile /repo/.trivyignore.yaml \ + --skip-dirs node_modules \ + --skip-dirs .next \ + --skip-dirs dist \ + --skip-dirs coverage \ + . + + - name: Explain a failed gate + if: failure() + run: | + { + echo "## The dependency gate failed" + echo + echo "A CRITICAL advisory with a fixed version is present in a lockfile and is not suppressed." + echo + echo "1. Take the fix. \`bun update \` for a compatible bump, or bump the" + echo " direct dependency that pins it. Commit \`bun.lock\`." + echo "2. If the fix cannot be taken yet, add an entry to \`.trivyignore.yaml\` with an" + echo " \`expired_at\` no more than 90 days out and a \`statement\` that names the" + echo " reachability argument or the blocking dependency. Trivy re-reports an expired" + echo " entry, so the argument has to be made again rather than inherited." + echo "3. Do not add \`continue-on-error\` to this step. If the threshold is wrong," + echo " change the threshold in review." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 00000000..c399cdc1 --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,43 @@ +# Vulnerability suppressions for the dependency gate (security programme control 2.1). +# +# WHAT THE GATE ACTUALLY FAILS ON +# +# CRITICAL severity, in a lockfile, with a fixed version available, not covered +# by an unexpired entry below. Everything else - HIGH, MEDIUM, LOW, and anything +# with no available fix - is reported to the job summary and to the Security tab, +# and fails nothing. That threshold is not leniency: measured on 2026-08-09 the +# shipped container image carries 168 OS-package findings of which 167 have no +# fixed package at all, and a gate over those would be permanently red with no +# action available to anyone here - which is the state in which somebody adds +# continue-on-error at two in the morning. +# +# SO THIS FILE SHOULD ALMOST ALWAYS BE EMPTY. +# +# It exists for the one case the threshold does not cover: a critical advisory +# with a fix this repository cannot take yet - a breaking major, a transitive +# pin held by a dependency that has not released. Reaching for it for anything +# else means the threshold is wrong; change the threshold, in review, instead. +# +# AN ENTRY MUST CARRY +# +# id the CVE or GHSA identifier exactly as Trivy reports it +# statement why this is not exploitable HERE, or why the fix cannot be taken +# yet, in one or two sentences. "Not exploitable" on its own is not +# a statement - name the reachability argument, or name the +# blocking dependency and its issue +# expired_at a date no more than 90 days out +# +# Verified behaviour (Trivy 0.73.0): an entry whose expired_at has passed is +# re-reported and the gate fails again. A suppression is therefore a decision +# with a review date attached, not a deletion. Renewing one means making the +# argument again, which is the point. +# +# Reproduce the gate locally, exactly as CI runs it: +# +# docker run --rm -v "$PWD:/repo:ro" -w /repo \ +# aquasec/trivy@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c \ +# fs --scanners vuln --severity CRITICAL --ignore-unfixed --exit-code 1 \ +# --ignorefile /repo/.trivyignore.yaml \ +# --skip-dirs node_modules --skip-dirs .next --skip-dirs dist --skip-dirs coverage . + +vulnerabilities: [] diff --git a/tests/unit/security-scan-workflow.test.ts b/tests/unit/security-scan-workflow.test.ts index 8a755896..08b99622 100644 --- a/tests/unit/security-scan-workflow.test.ts +++ b/tests/unit/security-scan-workflow.test.ts @@ -146,3 +146,80 @@ describe("secret-scan is the one scan allowed to fail a check", () => { expect(job.if).toBeUndefined(); }); }); + +describe("dependency-scan reports on pull requests and gates elsewhere", () => { + const job = workflow.jobs["dependency-scan"]; + const steps = job?.steps ?? []; + const install = steps.find((s) => s.uses === "./.github/actions/bun-install"); + const report = steps.find((s) => s.run?.includes("--output /out/deps.json")); + const sarif = steps.find((s) => s.uses?.startsWith("github/codeql-action/upload-sarif@")); + const audit = steps.find((s) => s.run?.includes("bun audit")); + const gate = steps.find((s) => s.name === "Gate: critical, fixable, unsuppressed"); + + test("exists and is named for a human reading the checks list", () => { + expect(job).toBeDefined(); + expect(job.name).toBe("Dependency Scan"); + }); + + test("installs dependencies through the composite action, never a bare bun install", () => { + // A bare `bun install` has no retry; one failed tarball download broke three + // runs in a day, one of them a release publish. + expect(install).toBeDefined(); + for (const step of steps) { + expect({ name: step.name, bare: /(^|\s)bun install(\s|$)/.test(step.run ?? "") }).toEqual({ + name: step.name, + bare: false, + }); + } + }); + + test("the reporting scan never narrows severity - the summary shows everything", () => { + expect(report).toBeDefined(); + expect(report?.run).not.toContain("--severity"); + expect(report?.run).toContain("--ignorefile /repo/.trivyignore.yaml"); + }); + + test("the reporting scan cannot fail the job", () => { + // No --exit-code anywhere in the reporting path: an advisory published + // overnight must not turn a contributor's unrelated pull request red. + expect(report?.run).not.toContain("--exit-code"); + }); + + test("the gate is the narrow, actionable set", () => { + expect(gate).toBeDefined(); + expect(gate?.run).toContain("--severity CRITICAL"); + expect(gate?.run).toContain("--ignore-unfixed"); + expect(gate?.run).toContain("--exit-code 1"); + expect(gate?.run).toContain("--ignorefile /repo/.trivyignore.yaml"); + }); + + test("the gate is a second scan, not a convert of the report", () => { + // Verified 2026-08-09: `trivy convert` does NOT honour --ignore-unfixed. A + // gate built on convert would fail on findings with no available fix, which + // is the exact permanent-red this threshold exists to avoid. + expect(gate?.run).toContain(" fs "); + expect(gate?.run).not.toContain("convert"); + }); + + test("the gate never runs on a pull request", () => { + expect(gate?.if).toBe("github.event_name != 'pull_request'"); + }); + + test("bun audit reports and cannot fail the job", () => { + expect(audit).toBeDefined(); + expect(audit?.run).toContain("|| true"); + }); + + test("the SARIF upload is guarded against fork pull requests", () => { + // A fork's GITHUB_TOKEN is read-only, so security-events: write is not + // granted and the upload would fail for every external contributor. Same + // guard shape as ci.yml's SonarCloud job. + expect(sarif).toBeDefined(); + expect(sarif?.if).toContain("head.repo.full_name == github.repository"); + expect(job.permissions?.["security-events"]).toBe("write"); + }); + + test("the SARIF upload names a category, so it does not collide with CodeQL", () => { + expect(sarif?.with?.category).toBe("trivy-dependencies"); + }); +}); From 62dbf38af63affb0a842db898e9dc068492dda0b Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 9 Aug 2026 20:09:45 +0300 Subject: [PATCH 04/13] feat(security): scan the published container image daily and report its OS findings --- .github/workflows/security-scan.yml | 120 ++++++++++++++++++++++ tests/unit/security-scan-workflow.test.ts | 44 ++++++++ 2 files changed, 164 insertions(+) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index c68ccaaf..8411f75c 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -319,3 +319,123 @@ jobs: echo "3. Do not add \`continue-on-error\` to this step. If the threshold is wrong," echo " change the threshold in review." } >> "$GITHUB_STEP_SUMMARY" + + image-scan: + name: Image Scan + # Not on a pull request: a pull request has no image of its own, and scanning + # the released :latest from a pull request would report findings that have + # nothing to do with the change under review. Not on a main push either: that + # push is racing docker-build-push.yml's own build for the same commit. The + # daily cron asks the only question worth asking here - what is in the image + # users are running right now. + if: github.event_name != 'pull_request' && github.event_name != 'push' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + # Pull the published image from GHCR. + packages: read + security-events: write + steps: + - name: Compute the database cache day + id: day + run: echo "value=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" + + - name: Restore the Trivy vulnerability database + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ runner.temp }}/trivy-cache + key: trivy-db-${{ steps.day.outputs.value }} + restore-keys: trivy-db- + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Scan the published image + env: + TRIVY_USERNAME: ${{ github.actor }} + TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/trivy-cache" "$RUNNER_TEMP/trivy-out" + # No severity filter and no exit code. This job's entire job is to make + # the OS layer visible: measured 2026-08-09, the runtime base image + # carries 4 critical and 18 high Debian CVEs of which 167 of 168 + # findings have no fixed package available. There is nothing to gate on + # and pretending otherwise ends with the workflow disabled. + # + # ~/.docker/config.json carries the GHCR credentials from the login + # step, which is what lets Trivy resolve the image inside the container. + docker run --rm \ + -e TRIVY_USERNAME -e TRIVY_PASSWORD \ + -v "$HOME/.docker/config.json:/root/.docker/config.json:ro" \ + -v "$RUNNER_TEMP/trivy-cache:/root/.cache/trivy" \ + -v "$RUNNER_TEMP/trivy-out:/out" \ + "$TRIVY_IMAGE" image \ + --scanners vuln \ + --format json \ + --output /out/image.json \ + ghcr.io/libredb/libredb-studio:latest + + - name: Render the report + run: | + set -euo pipefail + docker run --rm \ + -v "$RUNNER_TEMP/trivy-out:/out" \ + "$TRIVY_IMAGE" convert --format table --severity CRITICAL,HIGH --output /out/image.txt /out/image.json + docker run --rm \ + -v "$RUNNER_TEMP/trivy-out:/out" \ + "$TRIVY_IMAGE" convert --format sarif --output /out/image.sarif /out/image.json + + - name: Generate the image SBOM + run: | + set -euo pipefail + # Not a release asset, and it cannot be one: release-artifacts.yml + # publishes the release BEFORE dispatching docker-build-push.yml, and + # immutable releases (#154) freeze the asset set at publish time. The + # source SBOM on the release covers the application closure; this one + # adds the Debian layer, and anyone can regenerate it from the immutable + # digest at any time with the command documented in SECURITY.md. + docker run --rm \ + -v "$HOME/.docker/config.json:/root/.docker/config.json:ro" \ + -v "$RUNNER_TEMP/trivy-cache:/root/.cache/trivy" \ + -v "$RUNNER_TEMP/trivy-out:/out" \ + "$TRIVY_IMAGE" image \ + --format cyclonedx \ + --scanners license \ + --output /out/image.cdx.json \ + ghcr.io/libredb/libredb-studio:latest + + - name: Publish the findings to the job summary + run: | + set -euo pipefail + { + echo "## Published image: ghcr.io/libredb/libredb-studio:latest" + echo + echo "Critical and high findings only. Most carry no fixed package - Debian's" + echo "security team has not shipped one - which is why this job reports and never" + echo "gates. The actionable subset is the one with a Fixed Version column entry:" + echo "that is a base-image bump in \`Dockerfile\`." + echo + echo '```' + cat "$RUNNER_TEMP/trivy-out/image.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the findings to the security tab + uses: github/codeql-action/upload-sarif@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4 + with: + sarif_file: ${{ runner.temp }}/trivy-out/image.sarif + category: trivy-image + + - name: Upload the image SBOM + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: image-sbom + path: ${{ runner.temp }}/trivy-out/image.cdx.json + if-no-files-found: error + retention-days: 30 diff --git a/tests/unit/security-scan-workflow.test.ts b/tests/unit/security-scan-workflow.test.ts index 08b99622..31f3015e 100644 --- a/tests/unit/security-scan-workflow.test.ts +++ b/tests/unit/security-scan-workflow.test.ts @@ -223,3 +223,47 @@ describe("dependency-scan reports on pull requests and gates elsewhere", () => { expect(sarif?.with?.category).toBe("trivy-dependencies"); }); }); + +describe("image-scan reports and never gates", () => { + const job = workflow.jobs["image-scan"]; + const steps = job?.steps ?? []; + const scan = steps.find((s) => s.run?.includes("--output /out/image.json")); + const sarif = steps.find((s) => s.uses?.startsWith("github/codeql-action/upload-sarif@")); + + test("exists and is named for a human reading the checks list", () => { + expect(job).toBeDefined(); + expect(job.name).toBe("Image Scan"); + }); + + test("never runs on a pull request - there is no image for a pull request", () => { + // docker-build-push.yml publishes from main, feature branches and releases. + // A pull request has no image of its own, and scanning :latest from a pull + // request would report the released image against unrelated code. + expect(job.if).toContain("github.event_name != 'pull_request'"); + }); + + test("scans the image users actually run", () => { + expect(scan?.run).toContain("ghcr.io/libredb/libredb-studio:latest"); + }); + + test("cannot fail: no exit code anywhere in this job", () => { + // Measured 2026-08-09: the runtime base carries 4 critical and 18 high + // Debian CVEs, and 167 of 168 findings have no fixed package. Any + // --exit-code here is a permanent red, which ends with the workflow being + // disabled rather than the CVEs being fixed. + for (const step of steps) { + expect({ name: step.name, gates: (step.run ?? "").includes("--exit-code") }).toEqual({ + name: step.name, + gates: false, + }); + } + }); + + test("uploads under its own category so it does not overwrite the dependency results", () => { + expect(sarif?.with?.category).toBe("trivy-image"); + }); + + test("can read the image from GHCR", () => { + expect(job.permissions?.packages).toBe("read"); + }); +}); From d20acf638efbd2b71153111bded38b353f03c75d Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 9 Aug 2026 20:24:07 +0300 Subject: [PATCH 05/13] feat(security): attach an attested CycloneDX SBOM to every release --- .github/workflows/release-artifacts.yml | 118 +++++++++++++++++++++++- tests/unit/release-provenance.test.ts | 3 + tests/unit/release-sbom.test.ts | 108 ++++++++++++++++++++++ 3 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 tests/unit/release-sbom.test.ts diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 95d8ee59..cc83dd04 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -22,6 +22,13 @@ name: Release Artifacts # manifest in packaging/flatpak/ repacks the AppImage too, but only in # flatpak-smoke.yml now that the Flathub submission was declined - see that # directory's README. +# The sbom job (security programme control 2.2) generates one CycloneDX +# document from the repository's three lockfiles (bun.lock, +# packaging/windows/launcher/go.mod, desktop/src-tauri/Cargo.lock) and +# attaches it as libredb-studio-.cdx.json. It covers the dependency +# closure of every artifact this workflow builds; it does NOT cover the +# container image, which docker-build-push.yml has not built yet at this +# point in the chain - security-scan.yml generates that SBOM separately. # # DRAFT-FIRST FLOW (issue #154): the repository uses immutable releases, which # freeze a release's assets the moment it is published - post-publish uploads @@ -479,6 +486,112 @@ jobs: commit -m "libredb-studio ${VERSION}" git -c http.extraheader="$AUTH_HEADER" push origin HEAD + sbom: + name: Generate and attach the SBOM + # Its own job rather than a step inside `publish`: the SBOM needs the + # dependency tree installed, because Trivy reads licences out of + # node_modules/*/package.json and emits a licence-free document without them + # (verified: 0 of 755 components carry a licence with node_modules absent, + # 330 of 755 with it present). `publish` deliberately does not install + # anything. Isolating it also means a reviewer can reject the SBOM without + # rejecting the artifact upload. + # + # One SBOM covers everything this release ships: Trivy finds bun.lock, + # packaging/windows/launcher/go.mod and desktop/src-tauri/Cargo.lock, which + # between them are the closure of the npm package, the four standalone + # tarballs, the win32 zip, the .deb and .rpm packages, the snap, the AppImage + # and the desktop .deb - all built from these lockfiles at this commit. + # + # The container image is NOT covered here and cannot be: this workflow + # publishes the release before dispatching docker-build-push.yml, so the + # image does not exist while the release can still receive assets, and + # immutable releases freeze the set at publish time. security-scan.yml + # generates the image SBOM daily instead, and SECURITY.md documents the + # one-command regeneration from the immutable digest. + needs: [guard, draft] + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.14" + + - name: Install dependencies + uses: ./.github/actions/bun-install + + - name: Generate the CycloneDX SBOM + env: + VERSION: ${{ needs.guard.outputs.version }} + run: | + set -euo pipefail + mkdir -p sbom + # Trivy 0.73.0, pinned by digest. CycloneDX rather than SPDX: Trivy + # emits it natively for both a filesystem and an image, and it is what + # Dependency-Track and most procurement questionnaires consume. + # + # Not byte-reproducible across runs - the document carries a random + # serialNumber and a wall-clock timestamp - so it is generated exactly + # once, here, before the release is published. What is stable is the + # component set. + docker run --rm \ + -v "$GITHUB_WORKSPACE:/repo" \ + -w /repo \ + aquasec/trivy@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c fs \ + --format cyclonedx \ + --scanners license \ + --skip-dirs .next \ + --skip-dirs dist \ + --skip-dirs coverage \ + --skip-dirs sbom \ + --output "/repo/sbom/libredb-studio-${VERSION}.cdx.json" \ + . + ls -la sbom + + - name: Verify the SBOM describes something + env: + VERSION: ${{ needs.guard.outputs.version }} + run: | + set -euo pipefail + # A malformed or empty document uploads exactly as happily as a good + # one, and immutable releases mean the bad one is permanent. Assert the + # three properties that make it useful before it becomes unamendable: + # it parses, it is CycloneDX, and it found all three lockfiles. + node -e ' + const fs = require("fs"); + const file = process.argv[1]; + const doc = JSON.parse(fs.readFileSync(file, "utf8")); + if (doc.bomFormat !== "CycloneDX") throw new Error("not a CycloneDX document"); + const components = doc.components || []; + if (components.length < 100) throw new Error("only " + components.length + " components"); + const apps = components.filter((c) => c.type === "application").map((c) => c.name); + for (const lock of ["bun.lock", "packaging/windows/launcher/go.mod", "desktop/src-tauri/Cargo.lock"]) { + if (!apps.includes(lock)) throw new Error("missing ecosystem: " + lock); + } + const licensed = components.filter((c) => (c.licenses || []).length > 0).length; + if (licensed === 0) throw new Error("no licence data - were dependencies installed?"); + console.log("SBOM ok: " + components.length + " components, " + licensed + " with licences"); + ' "sbom/libredb-studio-${VERSION}.cdx.json" + + - name: Attest the SBOM + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: sbom/libredb-studio-*.cdx.json + + - name: Upload the SBOM to the draft release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.guard.outputs.version }} + run: | + gh release upload "$TAG" sbom/libredb-studio-*.cdx.json \ + --clobber --repo "$GITHUB_REPOSITORY" + linux-packages: name: Build .deb and .rpm (${{ matrix.arch }}) # Turns the linux standalone tarballs into native packages (issue #112): @@ -875,7 +988,7 @@ jobs: # `release: published` fires here, triggering npm-publish and # docker-build-push exactly as before. name: Verify assets and publish release - needs: [guard, publish, linux-packages, desktop-appimage, snap] + needs: [guard, publish, sbom, linux-packages, desktop-appimage, snap] runs-on: ubuntu-latest permissions: contents: write @@ -915,7 +1028,8 @@ jobs: "libredb-studio-desktop_${TAG}_amd64.deb" \ "libredb-studio-desktop_${TAG}_amd64.deb.sha256" \ "libredb-studio-desktop_${TAG}_arm64.deb" \ - "libredb-studio-desktop_${TAG}_arm64.deb.sha256"; do + "libredb-studio-desktop_${TAG}_arm64.deb.sha256" \ + "libredb-studio-${TAG}.cdx.json"; do if ! grep -Fqx "$asset" /tmp/assets.txt; then echo "::error::draft release '$TAG' is missing required asset '$asset' - refusing to publish an incomplete immutable release" missing=1 diff --git a/tests/unit/release-provenance.test.ts b/tests/unit/release-provenance.test.ts index 20a92e07..c33545c2 100644 --- a/tests/unit/release-provenance.test.ts +++ b/tests/unit/release-provenance.test.ts @@ -87,6 +87,9 @@ const ATTESTED_JOBS: { job: string; globs: string[] }[] = [ { job: "linux-packages", globs: ["pkgs/*.deb", "pkgs/*.rpm"] }, { job: "desktop-appimage", globs: ["dist-desktop/*.AppImage", "dist-desktop/*.deb"] }, { job: "snap", globs: ["${{ steps.snapcraft.outputs.snap }}"] }, + // An unsigned SBOM is a text file anyone can rewrite, and it is the one asset + // whose entire value is that its claims are trustworthy. + { job: "sbom", globs: ["sbom/libredb-studio-*.cdx.json"] }, ]; describe.each(ATTESTED_JOBS)("release-artifacts.yml attestation: $job", ({ job, globs }) => { diff --git a/tests/unit/release-sbom.test.ts b/tests/unit/release-sbom.test.ts new file mode 100644 index 00000000..d5b16e1d --- /dev/null +++ b/tests/unit/release-sbom.test.ts @@ -0,0 +1,108 @@ +/** + * Unit tests for the release SBOM (security programme control 2.2). + * + * Why a test for YAML: dropping the SBOM does not break a release. Every other + * asset still uploads, publish-release still flips the draft, and the missing + * document is discovered by whoever asked for it in a procurement thread months + * later - by which time immutable releases mean it can never be added to that + * release at all. That asymmetry is the same one release-provenance.test.ts + * exists for. + * + * The licence assertion is not cosmetic either: with no node_modules present + * Trivy emits the same component list with zero licence fields and logs a notice + * nobody reads, so the SBOM would look complete and answer none of the questions + * a diligence reviewer asks it. + */ +import { describe, expect, test } from "bun:test"; +import * as fs from "fs"; +import * as path from "path"; +import { parse as parseYaml } from "yaml"; + +interface Step { + name?: string; + run?: string; + if?: string; + uses?: string; + with?: Record; +} +interface Job { + name?: string; + needs?: string[]; + permissions?: Record; + steps?: Step[]; +} + +const workflow = parseYaml( + fs.readFileSync(path.join(__dirname, "../../.github/workflows/release-artifacts.yml"), "utf8"), +) as { jobs: Record }; + +const sbom = workflow.jobs.sbom; +const sbomSteps = sbom?.steps ?? []; +const generate = sbomSteps.find((s) => s.run?.includes("cyclonedx")); +const upload = sbomSteps.find((s) => s.run?.includes("gh release upload")); +const publishRelease = workflow.jobs["publish-release"]; +const verify = (publishRelease?.steps ?? []).find((s) => s.run?.includes("missing required asset")); + +describe("the release SBOM job", () => { + test("exists", () => { + expect(sbom).toBeDefined(); + }); + + test("waits for the draft, because assets can only be attached to a draft", () => { + // Immutable releases (#154): a published release's asset set is frozen. + expect(sbom.needs).toContain("draft"); + expect(sbom.needs).toContain("guard"); + }); + + test("installs dependencies, or the SBOM carries no licences", () => { + expect(sbomSteps.some((s) => s.uses === "./.github/actions/bun-install")).toBe(true); + }); + + test("never uses a bare bun install", () => { + for (const step of sbomSteps) { + expect({ name: step.name, bare: /(^|\s)bun install(\s|$)/.test(step.run ?? "") }).toEqual({ + name: step.name, + bare: false, + }); + } + }); + + test("emits CycloneDX and collects licences", () => { + expect(generate?.run).toContain("--format cyclonedx"); + expect(generate?.run).toContain("--scanners license"); + }); + + test("pins the generator by digest", () => { + expect(generate?.run).toMatch(/aquasec\/trivy@sha256:[0-9a-f]{64}/); + }); + + test("names the asset after the released version", () => { + expect(generate?.run).toContain("libredb-studio-${VERSION}.cdx.json"); + expect(upload?.run).toContain("gh release upload"); + }); + + test("does not touch SHA256SUMS", () => { + // scripts/render-homebrew-formula.mjs, the winget and Chocolatey packaging + // and the npx launcher all parse that file. It describes downloadable + // binaries; adding a document none of them fetch changes a contract three + // channels depend on for nothing. + for (const step of sbomSteps) { + expect({ name: step.name, touches: (step.run ?? "").includes("SHA256SUMS") }).toEqual({ + name: step.name, + touches: false, + }); + } + }); +}); + +describe("publish-release refuses to publish without the SBOM", () => { + test("waits for the sbom job", () => { + expect(publishRelease?.needs).toContain("sbom"); + }); + + test("requires the SBOM asset by name", () => { + // The verification list is what makes a missing asset a failed run instead + // of a published release that is quietly incomplete forever. + expect(verify?.run).toContain('"libredb-studio-${TAG}.cdx.json"'); + }); +}); From 80951f7e2ee6371339f37ae81692045ab9830c77 Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 9 Aug 2026 20:35:47 +0300 Subject: [PATCH 06/13] fix(security): type-check the production build instead of ignoring its errors Remove typescript.ignoreBuildErrors from next.config.ts (control 2.3). Measured before deleting: with the flag off, `bun run build` exits 0 today (zero hidden errors), and a deliberately injected type error in src/lib/security/headers.ts does fail the build with the expected "Type error: Type 'string' is not assignable to type 'number'" - so the check the flag was suppressing is real, not vacuous. What this buys is narrow, not "catches type errors in general" - both `bun run typecheck` and the build read the same tsconfig.json, so most errors were already caught by the required typecheck gate. The one gap is that `next build` regenerates .next/types/validator.ts from the current route tree before checking, while `tsc --noEmit` reads whatever the last build left on disk. A route added, renamed or removed can leave typecheck passing against stale generated types while the build's check would not. Removing the flag closes that window. Add tests/unit/next-config-typecheck.test.ts to guard against the flag being restored - it is a two-line block that looks harmless to re-add "just to see the build finish." --- next.config.ts | 3 --- tests/unit/next-config-typecheck.test.ts | 32 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 tests/unit/next-config-typecheck.test.ts diff --git a/next.config.ts b/next.config.ts index 6e42311f..d6001272 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,9 +12,6 @@ const nextConfig: NextConfig = { // Externalize native modules to reduce bundle size and memory usage // These packages will be loaded from node_modules at runtime serverExternalPackages: ["pg", "mysql2", "mongodb", "better-sqlite3", "ssh2"], - typescript: { - ignoreBuildErrors: true, - }, }; export default nextConfig; diff --git a/tests/unit/next-config-typecheck.test.ts b/tests/unit/next-config-typecheck.test.ts new file mode 100644 index 00000000..54429e06 --- /dev/null +++ b/tests/unit/next-config-typecheck.test.ts @@ -0,0 +1,32 @@ +/** + * Threat: type errors reaching a published artifact. + * + * `typescript.ignoreBuildErrors` was true in this repository for a long time. + * Removing it was measured first rather than assumed: with the flag off, a full + * production build exits 0 today, and a deliberately injected + * `const x: number = "s"` does fail it - so the check is both green and real. + * + * What the removal actually buys is narrow and worth naming, because a future + * reader will otherwise assume it is redundant with `bun run typecheck`: both + * read the same tsconfig.json, but `next build` REGENERATES .next/types from the + * current route tree before checking, while `tsc --noEmit` reads whatever the + * last build left on disk. After a route is added, renamed or deleted, typecheck + * can pass against stale generated types where the build would not. + * + * This test exists because the flag is two lines and comes back easily during a + * debugging session, and nothing else in the repository would notice. + * + * Sibling of tests/security/image-proxy.test.ts, which guards the same file's + * `images` key for the same reason. + */ +import { describe, expect, test } from "bun:test"; +import nextConfig from "../../next.config"; + +describe("next build type checking", () => { + test("declares no typescript configuration at all", () => { + // Not `ignoreBuildErrors === false` - the absent block IS the default, and + // asserting absence also catches `tsconfigPath` being pointed somewhere + // laxer. + expect(nextConfig.typescript).toBeUndefined(); + }); +}); From 68765153d6df8bed48295d5be4af1647517dff13 Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 9 Aug 2026 20:52:11 +0300 Subject: [PATCH 07/13] docs(security): document the supply-chain scans, the SBOM and the Phase 2 deferrals Resyncs the operator Helm mirror (bun run chart:bump, no version change): this branch forked before phase-1-hardening's own resync commit, so chart:check failed on the pre-existing README/NOTES drift before any Phase 2 edit. --- CONTRIBUTING.md | 44 +++++++++++++++++++++++++++ SECURITY.md | 55 ++++++++++++++++++++++++++++++++++ docs/BACKLOG.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4c1eb35..932ca2f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,6 +133,50 @@ bun start # Start production server bun lint # Run ESLint ``` +### Security Scanning + +Two checks run against every pull request. Both are reproducible locally, and +reproducing them is faster than waiting for CI. + +**Committed secrets.** This one can fail your pull request. It scans only the +commits your branch adds: + +```bash +docker run --rm -v "$PWD:/repo:ro" -w /repo \ + zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \ + git --no-banner --redact --config /repo/.gitleaks.toml \ + --log-opts="--no-merges origin/main..HEAD" +``` + +If it reports a real credential, rotate it first — the value is already in every +clone. If it reports a fixture or placeholder, add a rule-scoped allowlist to +`.gitleaks.toml` with a description explaining why; an allowlist that names no +`targetRules` is rejected by `tests/unit/gitleaks-config.test.ts`, because it +would exempt that path from every rule the scanner has. + +**Vulnerable dependencies.** This one reports on pull requests and never fails +them. The quickest local view needs no container: + +```bash +bun audit +``` + +`bun audit` reports every severity and does not tell you whether a fix exists, so +expect a long list; it is a starting point, not a verdict. The scan CI actually +runs, including the fixed-version column and the three non-npm lockfiles: + +```bash +docker run --rm -v "$PWD:/repo:ro" -w /repo \ + aquasec/trivy@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c \ + fs --scanners vuln --ignorefile /repo/.trivyignore.yaml \ + --skip-dirs node_modules --skip-dirs .next --skip-dirs dist --skip-dirs coverage . +``` + +Only a CRITICAL finding with an available fix gates anything, and only outside +pull requests. If you hit one, take the fix and commit `bun.lock`. Suppressing it +in `.trivyignore.yaml` is the last resort and requires a justification and an +expiry date. + ## Coding Guidelines ### TypeScript diff --git a/SECURITY.md b/SECURITY.md index 96c29951..60729be1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -146,6 +146,61 @@ When using LibreDB Studio, please follow these security best practices: - User queries sent to LLM providers may be logged by the provider - Consider privacy implications when using cloud-based LLM services +#### Supply Chain + +- Dependencies are scanned on every pull request against the lockfiles that + actually build the shipped artefacts: `bun.lock`, the Windows launcher's + `go.mod`, and the desktop shell's `Cargo.lock`. Findings appear in the run's + job summary and, for branches in this repository, in the GitHub Security tab +- The scan **fails** only for a CRITICAL advisory that has a fixed version + available and is not covered by an unexpired entry in `.trivyignore.yaml`, and + only outside pull requests. Findings with no available fix are reported and + never gate: the runtime container image inherits Debian package advisories for + which no fixed package exists, and a gate over those would be permanently red + without making anyone safer +- Every suppression in `.trivyignore.yaml` carries a written justification and an + expiry date. An expired suppression is re-reported, so a decision to accept a + risk has to be made again rather than inherited +- The published container image is scanned daily and its findings are published + to the Security tab. Most OS-package findings in any Debian-based image have no + fixed package available at the time they appear; the ones that do are taken by + bumping the base image +- Every commit is scanned for credentials. The full history was swept once and + classified: 24 matches across 753 commits, every one of them a fabricated test + fixture, a documented example password or UI placeholder copy. **No credential + has ever been committed to this repository**, and none has been rotated for that + reason. The classification is `.gitleaks.toml`, and the full sweep runs again on + every push to `main` and daily +- The production build type-checks. `next.config.ts` sets no + `typescript.ignoreBuildErrors`, so a type error fails the build rather than + shipping + +### Software Bill of Materials + +Every release carries `libredb-studio-.cdx.json`, a CycloneDX 1.7 SBOM +of the production dependency closure, attached as a release asset and signed with +a GitHub build-provenance attestation. It covers all three ecosystems the release +is built from — npm, Go and Rust — and therefore describes the npm package, the +standalone tarballs, the Windows zip, the `.deb` and `.rpm` packages, the snap, +the AppImage and the desktop package alike. + +Verify it: + +```bash +gh attestation verify libredb-studio-0.9.67.cdx.json --repo libredb/libredb-studio +``` + +The container image is not covered by that document, because the image is built +after the release is published and this project's releases are immutable. Its +SBOM is generated daily from the published image, and you can regenerate it +yourself from any digest at any time: + +```bash +trivy image --format cyclonedx --scanners license \ + --output libredb-studio-image.cdx.json \ + ghcr.io/libredb/libredb-studio:0.9.67 +``` + ### Security Updates Security updates will be released as: diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 432a6418..b4482f0a 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -532,3 +532,82 @@ to bound an attacker) is its own design problem this wave did not scope. Recorde this codebase has today, and it is still address-keyed. Done when a real, measured flood (not a hypothetical one) makes the keyed buckets' residual insufficient and a global ceiling's sizing can be grounded in that data rather than guessed. + +--- + +## Security Phase 2 deferrals + +Each of these was decided during Phase 2, not overlooked. Delete an entry when +the work lands. + +### S1. No scan check is a required check + +Branch protection requires `Lint, Typecheck and Build` and `Unit & Integration +Tests`. Phase 2 adds three scan jobs and promotes none of them, because promoting +a check is a branch-protection change the repository owner makes, and because two +of the three consult a vulnerability database that is rebuilt every six hours - +making them required would import that schedule into the merge gate. **`Secret +Scan` is the one candidate**: its verdict is a pure function of the scanned +commit range and the pinned gitleaks digest, it needs no secrets so it works +identically for fork pull requests, and it currently scans a pull request's +commits in about 75 milliseconds. Done when the owner promotes it, or when this +entry records why not. + +### S2. A failing scheduled scan notifies nobody but the owner + +`security-scan.yml`'s daily run fails when a critical fixable advisory lands, and +GitHub emails the repository owner for a failed scheduled run. That is the whole +notification path. `helm-index-check.yml` shows the alternative in this +repository - a job with `issues: write` that maintains a single rolling issue - +and it was not copied here because an auto-filed issue per advisory is how a +security label becomes noise. Done when a real missed advisory shows the email is +insufficient, at which point the rolling-issue pattern is the thing to copy. + +### S3. The image SBOM is a 30-day workflow artifact, not a durable asset + +It cannot be a release asset: `release-artifacts.yml` publishes the release +before dispatching `docker-build-push.yml`, and immutable releases (#154) freeze +the asset set at publish time. It is regenerable by anyone from an immutable +public digest with one Trivy command, documented in `SECURITY.md`, so nothing is +lost that cannot be recovered - what is missing is convenience and an attestation. +The clean fix is a buildx SBOM attestation (`sbom: true` on +`docker/build-push-action`), which attaches it to the image manifest where an +image SBOM belongs. It was not taken in Phase 2 because it adds a step, and a +failure mode, to the release-path Docker build - the most fragile CI surface in +this repository. Done when the release chain has been quiet for a few releases and +the change can be validated with a `workflow_dispatch` backfill first. + +### S4. No SBOM covers the operator image + +`operator-release.yml` builds a controller image that wraps the chart. Phase 2 +deliberately touched no release workflow other than `release-artifacts.yml`, and +the operator image has a different lifecycle and a different consumer (OpenShift +OperatorHub, which does its own scanning). Done when a certification requirement +asks for one. + +### S5. Dependabot has alerts but no version-update configuration + +The repository has Dependabot alerts and secret scanning enabled, but there is no +`.github/dependabot.yml`, so nothing opens a pull request for a bump. The +dependency gate therefore reports advisories that a human has to act on by hand. +Adding version updates is cheap and the reason it was not done here is scope, not +disagreement - it also interacts with the 100 percent coverage gate and the +required checks in ways worth thinking about once (a bot pull request must pass +the same six gates). Done when `dependabot.yml` lands with a grouping strategy +that does not produce one pull request per transitive package. + +### S6. `bun audit` cannot answer "is there a fix" + +It reports severity and vulnerable ranges and no fixed version, which is why +Trivy owns the gate and `bun audit` is a job-summary second opinion. If bun adds +fixed-version data, the container dependency in the local contributor workflow +could be dropped entirely. Done when `bun audit --json` carries a fix field. + +### S7. Seventeen HIGH fixable npm advisories, mostly `next` 16.1.6 to 16.2.x + +The dependency gate's job summary lists them; none is CRITICAL so none gates. +Deliberately not taken in Phase 2: a Next minor can change middleware and CSP +behaviour, which is exactly the surface Phase 1 just verified in a real browser, +and bumping inside this phase would invalidate that verification without +re-running it. This belongs to a separate bump pull request after the Phase 2 +chain lands, not to a supply-chain-scanning phase. From f7b8dca14cf5a53d60de1c31e14488cd33530758 Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 9 Aug 2026 21:42:50 +0300 Subject: [PATCH 08/13] fix(security): close the guard-not-scanner gaps the Phase 2 review found The review's structural finding: three of the four things it could break were guards, not scanners. Apply the same discipline to the YAML that protects the scanners' own configuration. - secret-scan resolves the commit range with `git rev-list --count` under `set -e`, so an unresolvable range (unreachable base.sha, shallow checkout, `.git` as a file) fails loudly instead of reaching gitleaks, which logs that same failure at ERROR and still exits 0. A new step asserts the scanned count is non-zero on a pull request. - the "never runs a container by tag" test matched only the physical `docker run --rm \` line; every image reference sits on a continuation line, so substituting a mutable tag for $TRIVY_IMAGE/$GITLEAKS_IMAGE stayed green. Replaced with a positive, whole-block assertion. - added tests/unit/trivyignore-policy.test.ts: mechanical backing for the suppression policy .trivyignore.yaml's own header and SECURITY.md promise (a statement of meaningful length, an expired_at that parses and is no more than 90 days out). - keyed the security-scan concurrency group by event as well as ref, so a push to main can no longer cancel the daily cron's image scan (same ref, different event). - the dependency gate's failure explainer now checks the gate step's own outcome instead of a bare `if: failure()`, so a Trivy DB timeout or a bun install flake no longer gets told a CRITICAL advisory is present. - the release sbom job checks for Docker Hub credentials and logs in when configured, and retries its trivy pull three times - it now sits on the release path (publish-release needs it) and had no retry. - the sbom's root component is renamed away from Trivy's default ".". - SECURITY.md now says the SBOM covers "the dependency closure of" the packaged artefacts, not the artefacts themselves, and names the bundled Node.js runtime (packaging/*/fetch-node.sh) as an undescribed gap. - docs/BACKLOG.md: relettered the Phase 2 deferrals S1-S7 to C1-C7 (the SQL section already owns S1-S8), amended C7 to name the three Next.js middleware/authorization-bypass CVEs against src/proxy.ts and record the 0.10.0 decision, and added C8 for the Node-runtime SBOM gap. - minor corrections: the gh attestation verify example now uses the placeholder; the security-scan.yml comment blaming GHCR rate limits for DB downloads now names Trivy's actual mirror.gcr.io source; .gitignore no longer points at CONTRIBUTING.md commands that write no report files; the publish-release comment no longer claims release:published fires npm-publish/docker-build-push directly; added a continue-on-error absence test for dependency-scan. Every guard touched here was sabotaged and confirmed red before being reverted; see .superpowers/sdd/2026-08-09-security-phase-2/fix-wave-report.md for the full record. --- .github/workflows/release-artifacts.yml | 71 ++++++++++++- .github/workflows/security-scan.yml | 65 ++++++++++-- .gitignore | 5 +- SECURITY.md | 16 ++- docs/BACKLOG.md | 66 +++++++++--- tests/unit/release-sbom.test.ts | 43 ++++++++ tests/unit/security-scan-workflow.test.ts | 118 ++++++++++++++++++++-- tests/unit/trivyignore-policy.test.ts | 84 +++++++++++++++ 8 files changed, 427 insertions(+), 41 deletions(-) create mode 100644 tests/unit/trivyignore-policy.test.ts diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index cc83dd04..986fd636 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -526,6 +526,33 @@ jobs: - name: Install dependencies uses: ./.github/actions/bun-install + - name: Check Docker Hub availability + # This job now sits on the release path: `publish-release` needs it. + # An anonymous pull of aquasec/trivy is subject to Docker Hub's + # per-address rate limit, and hosted runners share addresses - the + # same failure mode security-scan.yml documents for its own pulls. + # Optional: falls back to an anonymous pull when the repository has + # not configured Docker Hub credentials, same guard shape as + # docker-build-push.yml's own Docker Hub mirror step. + id: dockerhub + env: + DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }} + run: | + set -euo pipefail + if [ -n "$DOCKER_HUB_TOKEN" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::notice::DOCKER_HUB_TOKEN is not configured - pulling aquasec/trivy anonymously, subject to Docker Hub's per-address rate limit." + fi + + - name: Log in to Docker Hub + if: steps.dockerhub.outputs.enabled == 'true' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + username: ${{ vars.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + - name: Generate the CycloneDX SBOM env: VERSION: ${{ needs.guard.outputs.version }} @@ -540,7 +567,14 @@ jobs: # serialNumber and a wall-clock timestamp - so it is generated exactly # once, here, before the release is published. What is stable is the # component set. - docker run --rm \ + # + # Retried rather than run once: this is the most fragile CI surface + # in the repository (a failed release retries with a NEW patch + # version, never the same tag), so a transient Docker Hub rate limit + # or network blip must not be allowed to stall a release before + # publish. + attempt=0 + until docker run --rm \ -v "$GITHUB_WORKSPACE:/repo" \ -w /repo \ aquasec/trivy@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c fs \ @@ -552,8 +586,37 @@ jobs: --skip-dirs sbom \ --output "/repo/sbom/libredb-studio-${VERSION}.cdx.json" \ . + do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 3 ]; then + echo "::error::Failed to pull or run aquasec/trivy after 3 attempts." >&2 + exit 1 + fi + echo "Trivy pull/run failed (attempt $attempt/3) - retrying in 10s..." + sleep 10 + done ls -la sbom + - name: Name the SBOM's root component + env: + VERSION: ${{ needs.guard.outputs.version }} + run: | + set -euo pipefail + # Trivy names the root component after its scan target - "." for a + # filesystem scan of the repository root - so an unpatched document + # imports into Dependency-Track, or any other CycloneDX consumer, as + # a project literally named ".". This is the one field `trivy fs` + # has no flag to set. + node -e ' + const fs = require("fs"); + const file = process.argv[1]; + const doc = JSON.parse(fs.readFileSync(file, "utf8")); + if (doc.metadata && doc.metadata.component) { + doc.metadata.component.name = "libredb-studio"; + } + fs.writeFileSync(file, JSON.stringify(doc, null, 2)); + ' "sbom/libredb-studio-${VERSION}.cdx.json" + - name: Verify the SBOM describes something env: VERSION: ${{ needs.guard.outputs.version }} @@ -985,8 +1048,10 @@ jobs: # asset set on the draft, then flip it to published. Publishing is the # point of no return under immutable releases - after it, the asset set # can never be amended - so the verification runs strictly before it. - # `release: published` fires here, triggering npm-publish and - # docker-build-push exactly as before. + # `release: published` does fire here, but this runs under GITHUB_TOKEN and + # events it creates trigger no other workflow (the 0.9.46 incident) - + # npm-publish and docker-build-push are chained explicitly by the + # dispatch-downstream job below instead. name: Verify assets and publish release needs: [guard, publish, sbom, linux-packages, desktop-appimage, snap] runs-on: ubuntu-latest diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 8411f75c..082a3c18 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -11,7 +11,11 @@ name: Security Scan # secret-scan fails. Its verdict is a pure function of the scanned commit # range and the pinned gitleaks digest - no feed can turn it # red overnight - and a secret in a diff is unambiguous and -# fixable by the author. +# fixable by the author. The range is resolved with `git +# rev-list --count` under `set -e` before gitleaks ever +# runs, and the scanned count is asserted non-zero on a +# pull request, because gitleaks itself logs an +# unresolvable range at ERROR and still exits 0. # dependency-scan reports on a pull request (job summary only) and gates on # main, the daily schedule and manual runs. An advisory # published overnight must not turn a contributor's unrelated @@ -45,9 +49,13 @@ on: workflow_dispatch: concurrency: - # Keyed by ref: a shared group would let the daily cron and a pull request run - # cancel each other, leaving a cancelled check on the pull request. - group: security-scan-${{ github.ref }} + # Keyed by ref AND by event: `push` to main and the daily `schedule` share the + # same github.ref (refs/heads/main), so keying by ref alone let a push cancel + # that day's only image scan mid-run - image-scan does not run on push at all, + # so the scan simply never happened - and a cancelled run sends no failed-run + # email, the only notification path BACKLOG C2 names. pull_request events + # already have distinct refs, so this changes nothing for them. + group: security-scan-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true permissions: @@ -89,6 +97,18 @@ jobs: # pull request - and a check that is always red is a check nobody # reads. echo "log_opts=--no-merges $BASE_SHA..$HEAD_SHA" >> "$GITHUB_OUTPUT" + # `git rev-list --count` resolves the exact same range, under the + # same `set -e`. An unresolvable range - an unreachable base.sha + # after a force-push racing this run, a shallow checkout, `.git` + # as a file rather than a directory (the worktree case) - fails + # this step immediately instead of reaching the scanner below. + # Measured on the pinned digest: that scanner logs the same + # failure at ERROR ("fatal: Invalid revision range") and still + # exits 0, reporting "0 commits scanned" as "no leaks found". A + # verdict from zero scanned bytes must never look like a verdict + # from a clean scan. + count=$(git rev-list --count --no-merges "$BASE_SHA..$HEAD_SHA") + echo "commit_count=$count" >> "$GITHUB_OUTPUT" else # On main, the cron and a manual run: the whole history. The # repository authorises direct pushes to main, and at 1.1 seconds @@ -115,6 +135,22 @@ jobs: --report-format json \ --report-path /out/gitleaks.json + - name: Assert the scan covered commits + # The range step above already validated the range resolves; this + # asserts it also resolved to something. Only on pull_request: `--all` + # has no single count worth asserting, and main/the cron/dispatch are + # not the racing-synchronize case this exists for. + if: github.event_name == 'pull_request' + env: + COMMIT_COUNT: ${{ steps.range.outputs.commit_count }} + run: | + set -euo pipefail + if [ "$COMMIT_COUNT" -eq 0 ]; then + echo "::error::The resolved commit range scanned 0 commits. A pull request always adds at least one commit, so an empty range here means gitleaks verified nothing while still reporting a pass - investigate before trusting this check." >&2 + exit 1 + fi + echo "Scanned $COMMIT_COUNT commit(s)." + - name: Report the findings if: failure() run: | @@ -182,11 +218,13 @@ jobs: - name: Scan the lockfiles env: - # GHCR rate-limits anonymous pulls per address and hosted runners share - # addresses, which is the usual cause of "failed to download - # vulnerability DB". The workflow token lifts that limit and exists on - # fork pull requests too, where read-only is enough to pull a public - # package. + # Not a GHCR rate-limit workaround, despite what this comment used to + # claim: Trivy 0.73's default vulnerability-DB source is + # mirror.gcr.io, confirmed against this branch's own run logs, and + # that mirror is anonymous and needs no credential at all. These + # credentials are harmless to keep - GITHUB_TOKEN costs nothing and + # is available on fork pull requests too - but they are not what + # makes the DB download succeed. TRIVY_USERNAME: ${{ github.actor }} TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} run: | @@ -280,6 +318,7 @@ jobs: # convert-based gate would fail on findings nobody can fix. The # database is already in the cache, so this costs seconds. if: github.event_name != 'pull_request' + id: gate env: TRIVY_USERNAME: ${{ github.actor }} TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} @@ -303,7 +342,13 @@ jobs: . - name: Explain a failed gate - if: failure() + # Conditioned on the gate step's own outcome, not a bare failure() - + # which fires for ANY earlier failure in the job, including a Trivy DB + # download timeout or a `bun install` flake. Telling a contributor + # whose run died on a transient flake that a CRITICAL advisory is + # present and sending them to edit .trivyignore.yaml lands on the + # audience least able to diagnose it. + if: steps.gate.outcome == 'failure' run: | { echo "## The dependency gate failed" diff --git a/.gitignore b/.gitignore index 4f50bdb2..7ef69518 100644 --- a/.gitignore +++ b/.gitignore @@ -166,8 +166,9 @@ docs/summaries/ deploy/digitalocean/droplet/scripts/90-cleanup.sh deploy/digitalocean/droplet/scripts/99-img-check.sh -# Local output from the security scanners (see CONTRIBUTING.md). CI writes its -# reports to RUNNER_TEMP; a developer running the same commands by hand does not. +# Local output if you add report/output flags to the security scanner commands +# yourself - the copy-paste commands in CONTRIBUTING.md print to stdout and +# write nothing by default. CI writes its reports to RUNNER_TEMP. /gitleaks-report.json /trivy-report.json /trivy-report.sarif diff --git a/SECURITY.md b/SECURITY.md index 60729be1..e51048c8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -180,14 +180,22 @@ When using LibreDB Studio, please follow these security best practices: Every release carries `libredb-studio-.cdx.json`, a CycloneDX 1.7 SBOM of the production dependency closure, attached as a release asset and signed with a GitHub build-provenance attestation. It covers all three ecosystems the release -is built from — npm, Go and Rust — and therefore describes the npm package, the -standalone tarballs, the Windows zip, the `.deb` and `.rpm` packages, the snap, -the AppImage and the desktop package alike. +is built from — npm, Go and Rust — and therefore describes **the dependency +closure of** the npm package, the standalone tarballs, the Windows zip, the +`.deb` and `.rpm` packages, the snap, the AppImage and the desktop package alike. + +It does **not** describe the pinned Node.js runtime that `packaging/linux/fetch-node.sh` +and `packaging/windows/fetch-node.sh` download and bundle into every one of those +artefacts except the npm package. That runtime is the largest single binary in +most of them, it is fetched by a shell script rather than resolved from a +lockfile, and the SBOM's only `node`-named component is `pkg:npm/@types/node`, a +type-declarations package with no relationship to the runtime that actually +ships. This is a known gap, tracked in `docs/BACKLOG.md`. Verify it: ```bash -gh attestation verify libredb-studio-0.9.67.cdx.json --repo libredb/libredb-studio +gh attestation verify libredb-studio-.cdx.json --repo libredb/libredb-studio ``` The container image is not covered by that document, because the image is built diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index b4482f0a..2b71e9c0 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -538,9 +538,10 @@ be grounded in that data rather than guessed. ## Security Phase 2 deferrals Each of these was decided during Phase 2, not overlooked. Delete an entry when -the work lands. +the work lands. Lettered `C` (supply **C**hain) rather than `S`: the SQL +statement-reading section above already owns `S1`-`S8`. -### S1. No scan check is a required check +### C1. No scan check is a required check Branch protection requires `Lint, Typecheck and Build` and `Unit & Integration Tests`. Phase 2 adds three scan jobs and promotes none of them, because promoting @@ -553,7 +554,7 @@ identically for fork pull requests, and it currently scans a pull request's commits in about 75 milliseconds. Done when the owner promotes it, or when this entry records why not. -### S2. A failing scheduled scan notifies nobody but the owner +### C2. A failing scheduled scan notifies nobody but the owner `security-scan.yml`'s daily run fails when a critical fixable advisory lands, and GitHub emails the repository owner for a failed scheduled run. That is the whole @@ -563,7 +564,7 @@ and it was not copied here because an auto-filed issue per advisory is how a security label becomes noise. Done when a real missed advisory shows the email is insufficient, at which point the rolling-issue pattern is the thing to copy. -### S3. The image SBOM is a 30-day workflow artifact, not a durable asset +### C3. The image SBOM is a 30-day workflow artifact, not a durable asset It cannot be a release asset: `release-artifacts.yml` publishes the release before dispatching `docker-build-push.yml`, and immutable releases (#154) freeze @@ -577,7 +578,7 @@ failure mode, to the release-path Docker build - the most fragile CI surface in this repository. Done when the release chain has been quiet for a few releases and the change can be validated with a `workflow_dispatch` backfill first. -### S4. No SBOM covers the operator image +### C4. No SBOM covers the operator image `operator-release.yml` builds a controller image that wraps the chart. Phase 2 deliberately touched no release workflow other than `release-artifacts.yml`, and @@ -585,7 +586,7 @@ the operator image has a different lifecycle and a different consumer (OpenShift OperatorHub, which does its own scanning). Done when a certification requirement asks for one. -### S5. Dependabot has alerts but no version-update configuration +### C5. Dependabot has alerts but no version-update configuration The repository has Dependabot alerts and secret scanning enabled, but there is no `.github/dependabot.yml`, so nothing opens a pull request for a bump. The @@ -596,18 +597,53 @@ required checks in ways worth thinking about once (a bot pull request must pass the same six gates). Done when `dependabot.yml` lands with a grouping strategy that does not produce one pull request per transitive package. -### S6. `bun audit` cannot answer "is there a fix" +### C6. `bun audit` cannot answer "is there a fix" It reports severity and vulnerable ranges and no fixed version, which is why Trivy owns the gate and `bun audit` is a job-summary second opinion. If bun adds fixed-version data, the container dependency in the local contributor workflow could be dropped entirely. Done when `bun audit --json` carries a fix field. -### S7. Seventeen HIGH fixable npm advisories, mostly `next` 16.1.6 to 16.2.x - -The dependency gate's job summary lists them; none is CRITICAL so none gates. -Deliberately not taken in Phase 2: a Next minor can change middleware and CSP -behaviour, which is exactly the surface Phase 1 just verified in a real browser, -and bumping inside this phase would invalidate that verification without -re-running it. This belongs to a separate bump pull request after the Phase 2 -chain lands, not to a supply-chain-scanning phase. +### C7. Seventeen HIGH fixable npm advisories, three of them middleware/authorization bypasses against the exact surface Phase 1 hardened + +The dependency gate's job summary lists seventeen HIGH advisories, mostly `next` +16.1.6 to 16.2.x; none is CRITICAL so none gates. Three are not generic HIGH +advisories: **CVE-2026-44573, CVE-2026-44574 and CVE-2026-44575 are Next.js +middleware-bypass and authorization-bypass advisories**, and `src/proxy.ts` IS +Next 16's middleware - the exact file Phase 1 put RBAC, the Origin check, rate +limiting, the security headers and the audit emit into. Framing this as a +compatibility question alone - a Next minor can change middleware and CSP +behaviour, which is exactly the surface Phase 1 just verified in a real browser - +understated the risk; it is also a question of which known bypasses ship against +that surface today. + +Still not taken inside Phase 2, for the compatibility reason above: bumping +inside a supply-chain-scanning phase would invalidate Phase 1's browser +verification without re-running it, and this belongs to a dedicated bump pull +request instead. + +**Decision, recorded here because it is now updated by evidence**: the programme +ships one release, 0.10.0, after Phase 3. The Next bump must land BEFORE that tag +is cut, not merely "after the Phase 2 chain lands" - 0.10.0 must not ship with +known middleware-bypass and authorization-bypass advisories against the exact +layer it hardens. The bump pull request must re-run Phase 1's end-to-end +verification in a real browser, because it touches the middleware and CSP +surface that verification exists to cover. Done when that pull request lands and +0.10.0 is cut from a `main` that has it. + +### C8. The release SBOM does not describe the bundled Node.js runtime + +`packaging/linux/fetch-node.sh` and `packaging/windows/fetch-node.sh` download a +pinned Node.js build and bundle it into every packaged artefact except the npm +package itself - the standalone tarballs, the Windows zip, the `.deb` and `.rpm` +packages, the snap, the AppImage and the desktop package. That runtime is the +largest single binary in most of those artefacts, it is fetched by a shell +script rather than resolved from a lockfile, and the CycloneDX SBOM Trivy +generates from `bun.lock` never sees it - the document's only `node`-named +component is `pkg:npm/@types/node`, a type-declarations package. `SECURITY.md` +now says the SBOM covers "the dependency closure of" those artefacts rather than +the artefacts themselves, which is the honest claim; this entry is the gap +behind it. Done when the bundled runtime's version and provenance appear in the +SBOM or a sibling document - a second Trivy pass over the `fetch-node.sh` +scripts' pinned version, or a hand-maintained component entry, whichever ships +without adding a new failure mode to the release chain. diff --git a/tests/unit/release-sbom.test.ts b/tests/unit/release-sbom.test.ts index d5b16e1d..b5554025 100644 --- a/tests/unit/release-sbom.test.ts +++ b/tests/unit/release-sbom.test.ts @@ -22,6 +22,7 @@ interface Step { name?: string; run?: string; if?: string; + id?: string; uses?: string; with?: Record; } @@ -40,6 +41,9 @@ const sbom = workflow.jobs.sbom; const sbomSteps = sbom?.steps ?? []; const generate = sbomSteps.find((s) => s.run?.includes("cyclonedx")); const upload = sbomSteps.find((s) => s.run?.includes("gh release upload")); +const dockerHubCheck = sbomSteps.find((s) => s.id === "dockerhub"); +const dockerHubLogin = sbomSteps.find((s) => s.name === "Log in to Docker Hub"); +const rename = sbomSteps.find((s) => s.name === "Name the SBOM's root component"); const publishRelease = workflow.jobs["publish-release"]; const verify = (publishRelease?.steps ?? []).find((s) => s.run?.includes("missing required asset")); @@ -95,6 +99,45 @@ describe("the release SBOM job", () => { }); }); +describe("the sbom job authenticates to Docker Hub when possible, and retries the pull", () => { + // publish-release needs this job, so it now sits on the release chain - + // the same fragile path where a failed release retries with a NEW patch + // version, never the same tag. An anonymous, unretried Docker Hub pull here + // could stall a release before publish. + + test("checks whether Docker Hub credentials are configured before deciding whether to log in", () => { + expect(dockerHubCheck).toBeDefined(); + expect(dockerHubCheck?.run).toContain("DOCKER_HUB_TOKEN"); + }); + + test("logs in to Docker Hub only when the check says credentials are available", () => { + expect(dockerHubLogin).toBeDefined(); + expect(dockerHubLogin?.if).toBe("steps.dockerhub.outputs.enabled == 'true'"); + expect(dockerHubLogin?.with?.username).toBe("${{ vars.DOCKER_HUB_USERNAME }}"); + }); + + test("retries the trivy pull/run rather than failing on the first blip", () => { + expect(generate?.run).toContain("until docker run"); + expect(generate?.run).toMatch(/attempt/); + }); +}); + +describe("the sbom job names its root component, so it does not import as a project called '.'", () => { + test("patches metadata.component.name after generating, before verifying or attesting", () => { + expect(rename).toBeDefined(); + expect(rename?.run).toContain("metadata.component"); + expect(rename?.run).toContain("libredb-studio"); + }); + + test("runs after generation and before verification and attestation", () => { + const names = sbomSteps.map((s) => s.name); + const renameIndex = names.indexOf(rename?.name ?? ""); + expect(renameIndex).toBeGreaterThan(names.indexOf(generate?.name ?? "")); + expect(renameIndex).toBeLessThan(names.indexOf("Verify the SBOM describes something")); + expect(renameIndex).toBeLessThan(names.indexOf("Attest the SBOM")); + }); +}); + describe("publish-release refuses to publish without the SBOM", () => { test("waits for the sbom job", () => { expect(publishRelease?.needs).toContain("sbom"); diff --git a/tests/unit/security-scan-workflow.test.ts b/tests/unit/security-scan-workflow.test.ts index 31f3015e..101993a7 100644 --- a/tests/unit/security-scan-workflow.test.ts +++ b/tests/unit/security-scan-workflow.test.ts @@ -48,6 +48,31 @@ const allSteps = Object.values(workflow.jobs).flatMap((j) => j.steps ?? []); const DIGEST = /@sha256:[0-9a-f]{64}$/; +/** + * Splits a step's `run` script into the individual `docker run ...` + * invocations it contains, joining each one's backslash-continued lines back + * into a single block. The old single-line regex this replaces matched only + * the physical `docker run --rm \` line; every image reference in this + * workflow sits on a continuation line, so it never saw one. + */ +function dockerRunBlocks(run: string): string[] { + const blocks: string[] = []; + let current: string[] | null = null; + for (const line of run.split("\n")) { + const trimmed = line.trim(); + if (current === null) { + if (!trimmed.startsWith("docker run")) continue; + current = []; + } + current.push(line); + if (!trimmed.endsWith("\\")) { + blocks.push(current.join("\n")); + current = null; + } + } + return blocks; +} + describe("security-scan.yml wiring", () => { test("runs on pull requests, on main, on a schedule and on demand", () => { // The four together are the control: pull requests get the deterministic @@ -63,6 +88,15 @@ describe("security-scan.yml wiring", () => { expect(workflow.concurrency?.group).toContain("github.ref"); }); + test("keys concurrency by event too, so a push to main cannot cancel the daily cron", () => { + // `push` to main and the daily `schedule` share github.ref + // (refs/heads/main). Without the event in the group, a push cancels that + // day's only image scan mid-run - image-scan excludes `push` entirely - and + // a cancelled run sends no failed-run email, the only notification path + // docs/BACKLOG.md's Phase 2 deferrals name. + expect(workflow.concurrency?.group).toContain("github.event_name"); + }); + test("defaults to read-only permissions", () => { expect(workflow.permissions?.contents).toBe("read"); }); @@ -75,15 +109,28 @@ describe("security-scan.yml wiring", () => { } }); - test("never runs a container by tag", () => { - // An inline `docker run some/image:tag` would bypass the env pinning above. + test("every docker run invocation references a pinned image env var, never an inline tag", () => { + // Asserted positively over the WHOLE multi-line invocation, not by + // pattern-matching a single line for the absence of a tag: every image + // reference here sits on a continuation line after `docker run --rm \`, + // so a single-line regex never sees it. Proved by substituting a mutable + // tag for every "$TRIVY_IMAGE" and "$GITLEAKS_IMAGE" - the old version of + // this test still passed. + let checked = 0; for (const step of allSteps) { - const run = step.run ?? ""; - expect({ name: step.name, taggedRun: /docker run[^\n]*\s[a-z0-9./-]+:[a-z0-9.-]+\s/.test(run) }).toEqual({ - name: step.name, - taggedRun: false, - }); + for (const block of dockerRunBlocks(step.run ?? "")) { + checked += 1; + const pinned = /\$(TRIVY_IMAGE|GITLEAKS_IMAGE)\b/.test(block); + expect({ name: step.name, invocation: block.split("\n")[0].trim(), pinned }).toEqual({ + name: step.name, + invocation: block.split("\n")[0].trim(), + pinned: true, + }); + } } + // A helper that silently found nothing to check would make every + // iteration above vacuous. + expect(checked).toBeGreaterThan(0); }); test("pins every action to a full commit SHA", () => { @@ -122,6 +169,38 @@ describe("secret-scan is the one scan allowed to fail a check", () => { expect(resolver?.run).toContain("--all"); }); + test("resolves the pull-request range with git rev-list --count under set -e, so an unresolvable range fails loudly", () => { + // gitleaks itself logs an unresolvable range ("fatal: Invalid revision + // range") at ERROR and still exits 0 - measured on the pinned digest, and + // reproduced by `.git` being a file rather than a directory, the worktree + // case. `git rev-list --count` resolving the SAME range under the SAME + // `set -e` fails this step before gitleaks ever runs. + const resolver = steps.find((s) => s.id === "range"); + expect(resolver?.run).toContain("set -euo pipefail"); + expect(resolver?.run).toContain("git rev-list --count --no-merges"); + expect(resolver?.run).toContain("commit_count"); + }); + + test("asserts the scanned commit count is non-zero, and only on a pull request", () => { + // Resolving is not enough: an empty-but-valid range also passes gitleaks + // silently. Not asserted outside pull_request - `--all` has no single + // count worth asserting, and main/the cron/dispatch are not the + // racing-synchronize case this exists for. + const assertion = steps.find((s) => s.name === "Assert the scan covered commits"); + expect(assertion).toBeDefined(); + expect(assertion?.if).toBe("github.event_name == 'pull_request'"); + expect(assertion?.run).toContain("COMMIT_COUNT"); + expect(assertion?.run).toMatch(/-eq 0/); + expect(assertion?.run).toContain("exit 1"); + }); + + test("the commit-count assertion runs after the scan, not before it", () => { + const names = steps.map((s) => s.name); + expect(names.indexOf("Assert the scan covered commits")).toBeGreaterThan( + names.indexOf("Scan for committed secrets"), + ); + }); + test("passes the repository's allowlist rather than gitleaks' bare defaults", () => { expect(scan?.run).toContain(".gitleaks.toml"); }); @@ -155,6 +234,7 @@ describe("dependency-scan reports on pull requests and gates elsewhere", () => { const sarif = steps.find((s) => s.uses?.startsWith("github/codeql-action/upload-sarif@")); const audit = steps.find((s) => s.run?.includes("bun audit")); const gate = steps.find((s) => s.name === "Gate: critical, fixable, unsuppressed"); + const explainer = steps.find((s) => s.name === "Explain a failed gate"); test("exists and is named for a human reading the checks list", () => { expect(job).toBeDefined(); @@ -205,6 +285,30 @@ describe("dependency-scan reports on pull requests and gates elsewhere", () => { expect(gate?.if).toBe("github.event_name != 'pull_request'"); }); + test("the gate step is identifiable, so the failure explainer can target its own outcome", () => { + expect(gate?.id).toBe("gate"); + }); + + test("the failure explainer fires only when the gate step itself failed", () => { + // A bare if: failure() fires for ANY earlier failure in the job - a Trivy + // DB download timeout, a bun install flake - and tells whoever hit it that + // a CRITICAL advisory is present and sends them to edit + // .trivyignore.yaml. That lands on the audience least able to diagnose it. + expect(explainer).toBeDefined(); + expect(explainer?.if).toBe("steps.gate.outcome == 'failure'"); + }); + + test("no step in this job is advisory either", () => { + // secret-scan has this guard; dependency-scan's own gate step - the one + // scanner in this job permitted to fail anything - did not. + for (const step of steps) { + expect({ name: step.name, advisory: step["continue-on-error"] === true }).toEqual({ + name: step.name, + advisory: false, + }); + } + }); + test("bun audit reports and cannot fail the job", () => { expect(audit).toBeDefined(); expect(audit?.run).toContain("|| true"); diff --git a/tests/unit/trivyignore-policy.test.ts b/tests/unit/trivyignore-policy.test.ts new file mode 100644 index 00000000..85e2f883 --- /dev/null +++ b/tests/unit/trivyignore-policy.test.ts @@ -0,0 +1,84 @@ +/** + * Unit tests for .trivyignore.yaml's suppression policy (security programme + * control 2.1). + * + * Why a test for YAML: .trivyignore.yaml's own header and SECURITY.md both + * promise that every suppression carries a written justification and an + * expiry date no more than 90 days out. Measured on Trivy 0.73.0 against a + * real advisory, none of that is mechanically enforced by the scanner itself: + * an entry with no `expired_at` suppresses forever, an entry with no + * `statement` suppresses just as well, and a ten-year `expired_at` suppresses + * for ten years - only an already-expired entry is re-reported. The prose is + * real; nothing here made it true. + * + * The file has zero entries today, which makes every loop below vacuous - the + * same argument tests/unit/gitleaks-config.test.ts already makes for its own + * allowlists staying non-empty. Vacuous is fine: the point is that the FIRST + * entry anyone adds is checked against the policy this file's header states, + * not merely trusted to follow it. + */ +import { describe, expect, test } from "bun:test"; +import * as fs from "fs"; +import * as path from "path"; +import { parse as parseYaml } from "yaml"; + +interface Suppression { + id?: string; + statement?: string; + expired_at?: string; +} + +const file = path.join(__dirname, "../../.trivyignore.yaml"); +const doc = parseYaml(fs.readFileSync(file, "utf8")) as { vulnerabilities?: Suppression[] }; +const vulnerabilities = doc.vulnerabilities ?? []; + +const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000; + +describe(".trivyignore.yaml suppression policy", () => { + test("parses as a YAML document with a vulnerabilities list", () => { + expect(Array.isArray(vulnerabilities)).toBe(true); + }); + + test("is empty today - the CRITICAL/fixable threshold covers everything else", () => { + // Not a requirement that this file must stay empty forever. A record of + // the current, expected state, so a reader knows the loops below are + // vacuous by design rather than by accident. + expect(vulnerabilities.length).toBe(0); + }); + + test("every suppression names the advisory it suppresses", () => { + for (const entry of vulnerabilities) { + expect((entry.id ?? "").trim().length).toBeGreaterThan(0); + } + }); + + test("every suppression carries a statement of meaningful length", () => { + // "Not exploitable" on its own is not a statement - the file's own header + // and SECURITY.md both require a reachability argument or a named + // blocking dependency. A length floor cannot verify the argument is + // sound, but it is the mechanical minimum a written justification implies, + // and it is exactly what was missing before this test existed. + for (const entry of vulnerabilities) { + expect((entry.statement ?? "").trim().length).toBeGreaterThan(40); + } + }); + + test("every suppression carries an expired_at that parses as a real date", () => { + for (const entry of vulnerabilities) { + const parsed = new Date(entry.expired_at ?? ""); + expect(Number.isNaN(parsed.getTime())).toBe(false); + } + }); + + test("no suppression's expired_at is more than 90 days out", () => { + // Trivy re-reports an expired entry (verified 2026-08-09 against Trivy + // 0.73.0), but nothing mechanical stops a ten-year expired_at from + // suppressing forever until that date arrives - which is not a review + // date, it is a decision to never review again. + const now = Date.now(); + for (const entry of vulnerabilities) { + const parsed = new Date(entry.expired_at ?? ""); + expect(parsed.getTime() - now).toBeLessThanOrEqual(NINETY_DAYS_MS); + } + }); +}); From b6ea012f4a0540bf85627cd4b3c32558b824791f Mon Sep 17 00:00:00 2001 From: cevheri Date: Sun, 9 Aug 2026 22:06:43 +0300 Subject: [PATCH 09/13] fix(security): correct the round-1 explainer regression and two guard blind spots Response to the re-review of be5754d. - security-scan.yml: `if: steps.gate.outcome == 'failure'` has no status-check function, so GitHub Actions implicitly prepends `success() &&` - which is already false once the gate step has failed, so the explainer could never fire, including on a genuine CRITICAL-advisory failure on main. Fixed to `if: failure() && steps.gate.outcome == 'failure'`. - security-scan.yml: log_opts and commit_count were two independent reconstructions of "$BASE_SHA..$HEAD_SHA"; a sabotage touching only log_opts could leave commit_count non-zero while the scanner read a different, reversed range. Refactored to a single `range=` assignment that both outputs are derived from. - tests/unit/security-scan-workflow.test.ts: the pinned-image guard checked whether $TRIVY_IMAGE/$GITLEAKS_IMAGE occurred anywhere in a docker run block, so a hardcoded tag plus an unused decoy reference to the variable still read as pinned. Replaced with `imageArgument()`, which finds the token docker actually reads as the image (the first non-flag token after `docker run --rm \`) and checks that specifically. - added a general test asserting any step conditioned on another step's outcome begins with an explicit status function - the same class of bug as the explainer regression, not a patch for that one line. - added the requested one-sentence comment noting that push/schedule/ workflow_dispatch have no non-zero-scan backstop by decision (--all has no equivalent range to miscount), and a pointer to tests/unit/trivyignore-policy.test.ts from both .trivyignore.yaml's header and the dependency gate's failure explainer. Every guard touched was sabotaged and confirmed red before being reverted; see .superpowers/sdd/2026-08-09-security-phase-2/fix-wave-report.md (round 2 section) for the full record. --- .github/workflows/security-scan.yml | 52 ++++++++----- .trivyignore.yaml | 5 ++ tests/unit/security-scan-workflow.test.ts | 92 ++++++++++++++++++++--- 3 files changed, 121 insertions(+), 28 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 082a3c18..fb5ad1af 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -96,24 +96,35 @@ jobs: # diff, which would report every historical match again on every # pull request - and a check that is always red is a check nobody # reads. - echo "log_opts=--no-merges $BASE_SHA..$HEAD_SHA" >> "$GITHUB_OUTPUT" - # `git rev-list --count` resolves the exact same range, under the - # same `set -e`. An unresolvable range - an unreachable base.sha - # after a force-push racing this run, a shallow checkout, `.git` - # as a file rather than a directory (the worktree case) - fails - # this step immediately instead of reaching the scanner below. - # Measured on the pinned digest: that scanner logs the same - # failure at ERROR ("fatal: Invalid revision range") and still - # exits 0, reporting "0 commits scanned" as "no leaks found". A - # verdict from zero scanned bytes must never look like a verdict - # from a clean scan. - count=$(git rev-list --count --no-merges "$BASE_SHA..$HEAD_SHA") + # + # `range` is the ONE definition of what gets scanned. log_opts and + # commit_count are both derived from this single variable rather + # than each reconstructing "$BASE_SHA..$HEAD_SHA" separately - two + # independent constructions of the same range can diverge (a + # sabotage that flips the direction in log_opts alone would leave + # commit_count correct and non-zero while the scanner below reads + # the reversed, typically empty, direction and reports clean). One + # source cannot diverge from itself. + range="--no-merges $BASE_SHA..$HEAD_SHA" + echo "log_opts=$range" >> "$GITHUB_OUTPUT" + # `git rev-list --count` resolves that SAME range, under the same + # `set -e`. An unresolvable range - an unreachable base.sha after a + # force-push racing this run, a shallow checkout, `.git` as a file + # rather than a directory (the worktree case) - fails this step + # immediately instead of reaching the scanner below. Measured on + # the pinned digest: that scanner logs the same failure at ERROR + # ("fatal: Invalid revision range") and still exits 0, reporting + # "0 commits scanned" as "no leaks found". A verdict from zero + # scanned bytes must never look like a verdict from a clean scan. + count=$(git rev-list --count $range) echo "commit_count=$count" >> "$GITHUB_OUTPUT" else - # On main, the cron and a manual run: the whole history. The - # repository authorises direct pushes to main, and at 1.1 seconds - # the full sweep is cheaper than reasoning about which range covers - # a force push. + # On main, the cron and a manual run: the whole history, so there + # is no equivalent base..head range to resolve or miscount - `--all` + # cannot hit the unresolvable-range failure this step guards + # against, which is why there is no commit_count, and no + # "Assert the scan covered commits" check, for these events. A + # decision, not an oversight. echo "log_opts=--all" >> "$GITHUB_OUTPUT" fi @@ -348,7 +359,13 @@ jobs: # whose run died on a transient flake that a CRITICAL advisory is # present and sending them to edit .trivyignore.yaml lands on the # audience least able to diagnose it. - if: steps.gate.outcome == 'failure' + # + # `failure()` is required here, not implied: an `if:` with no status + # function gets `success() &&` prepended by GitHub Actions, and + # success() is already false once the gate step above has failed - + # `steps.gate.outcome == 'failure'` alone would never fire, on the one + # path where this explainer is the whole point. + if: failure() && steps.gate.outcome == 'failure' run: | { echo "## The dependency gate failed" @@ -361,6 +378,7 @@ jobs: echo " \`expired_at\` no more than 90 days out and a \`statement\` that names the" echo " reachability argument or the blocking dependency. Trivy re-reports an expired" echo " entry, so the argument has to be made again rather than inherited." + echo " \`tests/unit/trivyignore-policy.test.ts\` enforces both mechanically." echo "3. Do not add \`continue-on-error\` to this step. If the threshold is wrong," echo " change the threshold in review." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.trivyignore.yaml b/.trivyignore.yaml index c399cdc1..87060357 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -32,6 +32,11 @@ # with a review date attached, not a deletion. Renewing one means making the # argument again, which is the point. # +# tests/unit/trivyignore-policy.test.ts enforces the id/statement/expired_at +# rules above mechanically - an entry missing one, or with a statement too +# short or an expired_at too far out, fails that test before it ever reaches +# a gate run. +# # Reproduce the gate locally, exactly as CI runs it: # # docker run --rm -v "$PWD:/repo:ro" -w /repo \ diff --git a/tests/unit/security-scan-workflow.test.ts b/tests/unit/security-scan-workflow.test.ts index 101993a7..fcc2625b 100644 --- a/tests/unit/security-scan-workflow.test.ts +++ b/tests/unit/security-scan-workflow.test.ts @@ -73,6 +73,28 @@ function dockerRunBlocks(run: string): string[] { return blocks; } +/** + * Finds the token docker actually reads as the image to run: the first + * whitespace-separated word on the first line, after `docker run --rm \`, + * that is not itself a flag (every `-e`/`-v`/`-w` flag and its value share one + * line in this workflow's style, so skipping flag-prefixed lines skips + * exactly the flags). Presence of a pinned-image variable ANYWHERE in the + * block is not enough: a decoy reference in an unrelated `-e` flag would make + * a hardcoded tag in the real argument position look pinned under a + * presence-only check. + */ +function imageArgument(block: string): string | undefined { + const lines = block.split("\n").map((l) => l.trim()); + for (const line of lines.slice(1)) { + if (line.startsWith("-")) continue; + if (line.length === 0) continue; + return line.split(/\s+/)[0]; + } + return undefined; +} + +const PINNED_IMAGE_ARG = /^"?\$(TRIVY_IMAGE|GITLEAKS_IMAGE)"?$/; + describe("security-scan.yml wiring", () => { test("runs on pull requests, on main, on a schedule and on demand", () => { // The four together are the control: pull requests get the deterministic @@ -109,21 +131,24 @@ describe("security-scan.yml wiring", () => { } }); - test("every docker run invocation references a pinned image env var, never an inline tag", () => { - // Asserted positively over the WHOLE multi-line invocation, not by - // pattern-matching a single line for the absence of a tag: every image - // reference here sits on a continuation line after `docker run --rm \`, - // so a single-line regex never sees it. Proved by substituting a mutable - // tag for every "$TRIVY_IMAGE" and "$GITLEAKS_IMAGE" - the old version of - // this test still passed. + test("every docker run invocation runs the pinned image variable itself, not merely a block that mentions it", () => { + // Asserted positionally, not by presence anywhere in the WHOLE multi-line + // invocation: a block that hardcodes the tag as its real argument while + // carrying an unrelated, unused reference to $TRIVY_IMAGE or + // $GITLEAKS_IMAGE elsewhere - a decoy `-e` flag, a comment - would still + // read as "pinned" under a presence-only check. `imageArgument` finds the + // token docker actually reads as the image: the first non-flag word after + // `docker run --rm \`. let checked = 0; for (const step of allSteps) { for (const block of dockerRunBlocks(step.run ?? "")) { checked += 1; - const pinned = /\$(TRIVY_IMAGE|GITLEAKS_IMAGE)\b/.test(block); - expect({ name: step.name, invocation: block.split("\n")[0].trim(), pinned }).toEqual({ + const arg = imageArgument(block); + const pinned = arg !== undefined && PINNED_IMAGE_ARG.test(arg); + expect({ name: step.name, invocation: block.split("\n")[0].trim(), imageArgument: arg, pinned }).toEqual({ name: step.name, invocation: block.split("\n")[0].trim(), + imageArgument: arg, pinned: true, }); } @@ -133,6 +158,30 @@ describe("security-scan.yml wiring", () => { expect(checked).toBeGreaterThan(0); }); + test("any step conditioned on another step's own outcome begins with an explicit status function", () => { + // A bare `steps.x.outcome == 'y'` (or `.conclusion == 'y'`) has no status + // function, so GitHub Actions implicitly prepends `success() &&` - which + // is false whenever the referenced step failed, since a failed step makes + // the job's running status failed too. The exact shape of the regression + // this guards: `if: steps.gate.outcome == 'failure'` collapses to + // `success() && steps.gate.outcome == 'failure'`, which can never be true. + const STATUS_FN = /^(failure|success|always|cancelled)\(\)/; + const STEP_OUTCOME = /steps\.[A-Za-z0-9_-]+\.(outcome|conclusion)/; + let checked = 0; + for (const step of allSteps) { + if (!step.if || !STEP_OUTCOME.test(step.if)) continue; + checked += 1; + expect({ name: step.name, if: step.if, startsWithStatusFn: STATUS_FN.test(step.if) }).toEqual({ + name: step.name, + if: step.if, + startsWithStatusFn: true, + }); + } + // A step referencing another step's outcome is exactly the shape this + // guards; zero of them found would make the loop vacuous. + expect(checked).toBeGreaterThan(0); + }); + test("pins every action to a full commit SHA", () => { for (const step of allSteps) { if (!step.uses) continue; @@ -177,10 +226,25 @@ describe("secret-scan is the one scan allowed to fail a check", () => { // `set -e` fails this step before gitleaks ever runs. const resolver = steps.find((s) => s.id === "range"); expect(resolver?.run).toContain("set -euo pipefail"); - expect(resolver?.run).toContain("git rev-list --count --no-merges"); + expect(resolver?.run).toContain("git rev-list --count"); expect(resolver?.run).toContain("commit_count"); }); + test("derives commit_count from the SAME range string log_opts uses, not a second construction", () => { + // Two independent constructions of "$BASE_SHA..$HEAD_SHA" - one for + // log_opts, one for commit_count - can diverge: a sabotage that reverses + // the direction in log_opts alone would leave commit_count correct and + // non-zero, satisfying the assertion below, while gitleaks scans the + // reversed - typically empty - direction and reports clean. Asserting a + // single `range=` assignment feeding both outputs is what makes that + // impossible rather than merely absent today. + const resolver = steps.find((s) => s.id === "range"); + const run = resolver?.run ?? ""; + expect(run).toMatch(/range="--no-merges \$BASE_SHA\.\.\$HEAD_SHA"/); + expect(run).toContain('echo "log_opts=$range"'); + expect(run).toContain("git rev-list --count $range"); + }); + test("asserts the scanned commit count is non-zero, and only on a pull request", () => { // Resolving is not enough: an empty-but-valid range also passes gitleaks // silently. Not asserted outside pull_request - `--all` has no single @@ -294,8 +358,14 @@ describe("dependency-scan reports on pull requests and gates elsewhere", () => { // DB download timeout, a bun install flake - and tells whoever hit it that // a CRITICAL advisory is present and sends them to edit // .trivyignore.yaml. That lands on the audience least able to diagnose it. + // + // `failure() &&` is required, not optional: `steps.gate.outcome == + // 'failure'` alone has no status function, so GitHub Actions implicitly + // prepends `success() &&` - which is already false once the gate step has + // failed, so the bare form can never fire at all, on the one path where + // firing is the point. expect(explainer).toBeDefined(); - expect(explainer?.if).toBe("steps.gate.outcome == 'failure'"); + expect(explainer?.if).toBe("failure() && steps.gate.outcome == 'failure'"); }); test("no step in this job is advisory either", () => { From 94dfe1e66b1a2829ebe3d130ac13c93708468721 Mon Sep 17 00:00:00 2001 From: cevheri Date: Mon, 10 Aug 2026 00:51:27 +0300 Subject: [PATCH 10/13] fix(security): fingerprint the secret-scan allowlist and close two scanner gaps SonarCloud (typescript:S5906): tests/unit/trivyignore-policy.test.ts's length assertion now reports the actual length on failure. Copilot review of #322, five findings, all fixed: - .gitleaks.toml's four path-scoped allowlists exempted every future generic-api-key/jwt/private-key/curl-auth-user finding under their path, not just the 24 historical fabricated ones. Replaced with exact commit:file:rule:startline fingerprints in a new .gitleaksignore (the actual gitleaks 8.30.1 mechanism for this - its [[allowlists]] TOML schema has no fingerprint field). Verified live: a real-shaped private key and generic API key planted under tests/ are now reported; the full history still scans clean. - security-scan.yml's --no-merges left a secret introduced only while resolving a merge conflict unscanned in both parents. Switched the pull_request range to --diff-merges=first-parent, which surfaces that resolution without re-flooding with the other side's unrelated history. Verified live against a synthetic conflict-resolution commit. - The pull request and the workflow's own section header used 'blocks' for a check that is not yet a required branch-protection check. Reworded the workflow header; PR body wording proposed separately. - SECURITY.md's image-SBOM regeneration command used a mutable tag while the prose promised an immutable digest. Resolves the digest first. - CONTRIBUTING.md and SECURITY.md called bun.lock, Cargo.lock and go.mod 'three non-npm lockfiles' - bun.lock is the npm lockfile and go.mod is a manifest, not a lockfile. Named the three ecosystems directly. tests/unit/gitleaks-config.test.ts rewritten for the new split (fingerprint classification moved out; the file now guards the shape of any future value-scoped allowlist). New tests/unit/gitleaksignore.test.ts guards the fingerprint file's shape and count. --- .github/workflows/security-scan.yml | 43 +++++++++---- .gitleaks.toml | 74 +++++++-------------- .gitleaksignore | 78 +++++++++++++++++++++++ CONTRIBUTING.md | 20 ++++-- SECURITY.md | 16 +++-- tests/unit/gitleaks-config.test.ts | 46 ++++++++++--- tests/unit/gitleaksignore.test.ts | 65 +++++++++++++++++++ tests/unit/security-scan-workflow.test.ts | 13 +++- tests/unit/trivyignore-policy.test.ts | 2 +- 9 files changed, 269 insertions(+), 88 deletions(-) create mode 100644 .gitleaksignore create mode 100644 tests/unit/gitleaksignore.test.ts diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index fb5ad1af..0678fa23 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -6,7 +6,12 @@ name: Security Scan # commit, and the repository's required checks must stay a function of the code # under review. Same reasoning that keeps SonarCloud out of the required set. # -# WHAT BLOCKS AND WHAT REPORTS +# WHICH SCANS CAN FAIL, AND WHICH ONLY REPORT +# +# "Fails" below means the job goes red, not that it blocks a merge: none of +# these three is a required status check in branch protection today (see the +# note after the list), so a failed job here is visible on the pull request +# but does not by itself stop a maintainer from clicking merge. # # secret-scan fails. Its verdict is a pure function of the scanned commit # range and the pinned gitleaks digest - no feed can turn it @@ -91,11 +96,20 @@ jobs: run: | set -euo pipefail if [ "$EVENT_NAME" = "pull_request" ]; then - # Only the commits this pull request adds. Without --no-merges a - # merge commit re-presents the whole other side of the merge as a - # diff, which would report every historical match again on every - # pull request - and a check that is always red is a check nobody - # reads. + # Only the commits this pull request adds. `git log` already + # shows no diff at all for a merge commit by default - unrelated + # history is never re-presented, `--no-merges` was never needed + # for that - but a plain merge commit hides whatever its + # resolution introduced from BOTH parents, so a secret added only + # while resolving a conflict would never reach the scanner. + # `--diff-merges=first-parent` shows a merge commit's diff against + # the branch it was merged into, which is exactly the new content + # this pull request is adding, without the flood a full + # non-first-parent diff of the other side would produce. Verified + # 2026-08-09: a secret introduced only in a conflict resolution is + # invisible under `--no-merges` and reported under + # `--diff-merges=first-parent`, and a linear (no-merge) range + # scans identically either way. # # `range` is the ONE definition of what gets scanned. log_opts and # commit_count are both derived from this single variable rather @@ -105,7 +119,7 @@ jobs: # commit_count correct and non-zero while the scanner below reads # the reversed, typically empty, direction and reports clean). One # source cannot diverge from itself. - range="--no-merges $BASE_SHA..$HEAD_SHA" + range="--diff-merges=first-parent $BASE_SHA..$HEAD_SHA" echo "log_opts=$range" >> "$GITHUB_OUTPUT" # `git rev-list --count` resolves that SAME range, under the same # `set -e`. An unresolvable range - an unreachable base.sha after a @@ -171,9 +185,9 @@ jobs: echo "## Committed secrets found" echo if [ -s "$report" ]; then - echo "| Rule | File | Line | Commit |" - echo "| --- | --- | --- | --- |" - jq -r '.[] | "| \(.RuleID) | \(.File) | \(.StartLine) | \(.Commit[0:8]) |"' "$report" + echo "| Rule | File | Line | Commit | Fingerprint |" + echo "| --- | --- | --- | --- | --- |" + jq -r '.[] | "| \(.RuleID) | \(.File) | \(.StartLine) | \(.Commit[0:8]) | \(.Fingerprint) |"' "$report" else echo "The scanner failed before producing a report - read the step log." fi @@ -184,9 +198,12 @@ jobs: echo "remedy that works." echo echo "If the match is fabricated - a fixture, a placeholder, documented example copy -" - echo "add a rule-scoped allowlist to \`.gitleaks.toml\` with a description that says" - echo "why. An allowlist without \`targetRules\` is rejected by" - echo "\`tests/unit/gitleaks-config.test.ts\`." + echo "copy the Fingerprint column above (commit:file:rule:startline) into" + echo "\`.gitleaksignore\` with a comment explaining why. That suppresses exactly this" + echo "finding; a real secret added later, even the same literal in a new commit," + echo "is still reported. \`.gitleaks.toml\`'s \`[[allowlists]]\` is for a whole rule" + echo "being unconditionally noisy, not a single fixture; one without \`targetRules\`" + echo "is rejected by \`tests/unit/gitleaks-config.test.ts\`." } >> "$GITHUB_STEP_SUMMARY" dependency-scan: diff --git a/.gitleaks.toml b/.gitleaks.toml index 4af81b8b..2f9cbccd 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,17 +1,29 @@ # Secret scanning configuration for LibreDB Studio (security programme control 2.1). # -# The full-history sweep that produced this file found 24 matches across 753 -# commits and classified every one of them as a fabricated value: no credential -# has ever been committed to this repository and no rotation was required. The -# four allowlists below are that classification, written down, so the -# incremental scan on every pull request starts from zero and any new match is a -# real finding rather than one of the same 24 forever. +# The full-history sweep found 24 matches across 753 commits and classified +# every one of them as a fabricated value: no credential has ever been +# committed to this repository and no rotation was required. Each is +# suppressed by exact fingerprint in `.gitleaksignore`, not here - see that +# file for the classification and the reasoning. # -# Every allowlist names `targetRules`. A rule-less allowlist would exempt a path -# from ALL of gitleaks' rules, including the provider-specific ones (AWS, GCP, -# Slack, Stripe) that have no false positives here - which is how an allowlist -# written for a fake JWT ends up hiding a real AWS key. tests/unit/gitleaks-config.test.ts -# enforces that. +# Why fingerprints instead of a `[[allowlists]]` entry in this file: an +# allowlist scoped by `paths` exempts an entire file or directory from a rule +# forever, and one scoped by `regexes` still exempts every future occurrence +# of that literal value anywhere. A fingerprint names the one already-seen +# `commit:file:rule:startline`, so a real secret added later - even the exact +# same fabricated literal, in a new commit - produces a different fingerprint +# and is still reported. Verified 2026-08-09: a `paths = ['^tests/']` +# allowlist for the generic-api-key/jwt/private-key rules silently swallowed a +# freshly planted, real-shaped secret added to a brand-new file under tests/; +# replacing it with fingerprints in `.gitleaksignore` closed that gap while +# the full 774-commit history still scans clean. +# +# `[[allowlists]]` in this file remains available for the different problem it +# actually solves well: a rule that is unconditionally noisy for a known, +# reviewable reason (see tests/unit/gitleaks-config.test.ts for the shape +# every entry here must have - targetRules is mandatory, and `paths` is +# rejected outright in favour of `regexes` + `regexTarget` or a +# `.gitleaksignore` fingerprint). There are none of those today. title = "LibreDB Studio secret scanning" @@ -20,43 +32,3 @@ title = "LibreDB Studio secret scanning" # with the pinned scanner digest, which is what makes the verdict a pure function # of the commit plus that digest. useDefault = true - -[[allowlists]] -description = """ -Test fixtures. Every secret-shaped literal under tests/ is fabricated - the -jwt.io sample token, hex filler, self-describing strings such as -"winner-secret-that-is-at-least-32-chars", and an RSA key whose body is the word -"fake". Scoped to the three generic rules that match shape rather than issuer, so -a real provider token pasted into a test still fails the scan; GitHub's push -protection covers that case independently. -""" -targetRules = ["generic-api-key", "jwt", "private-key"] -paths = ['''^tests/'''] - -[[allowlists]] -description = """ -The two example passwords in the documented Helm install commands, which appear -verbatim in charts/libredb-studio/README.md, its operator copy, -docs/HELM_CHART.md and an archived planning document. Allowlisted by VALUE, not -by path, so a different literal in the same files is still reported. -""" -targetRules = ["generic-api-key"] -regexes = ['''^StrongPass\d+$'''] -regexTarget = "secret" - -[[allowlists]] -description = """ -The two PEM placeholders in the connection form's textareas ("Optional client -key...", "Paste private key here..."). They are UI copy, not keys. -""" -targetRules = ["private-key"] -paths = ['''^src/components/ConnectionModal\.tsx$'''] - -[[allowlists]] -description = """ -The Couchbase healthcheck in the local development compose file, whose curl -u -argument is "$$COUCHBASE_USER:$$COUCHBASE_PASSWORD" - Compose variable -interpolation, resolved from the same file's environment block. -""" -targetRules = ["curl-auth-user"] -paths = ['''^database-compose\.yml$'''] diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..3d557835 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,78 @@ +# Historical secret-scanning suppressions for LibreDB Studio (security +# programme control 2.1). +# +# The full-history sweep found 24 matches across 753 commits (grown to 774 as +# of 2026-08-09) and classified every one of them as a fabricated value: no +# credential has ever been committed to this repository and no rotation was +# required. Each line below is one finding's fingerprint - the exact +# `commit:file:rule:startline` gitleaks reports for it - not a path or a +# value, so it suppresses that one already-classified historical occurrence +# and nothing else: a real secret added later anywhere in these same files, or +# a real secret matching one of these same fabricated literals in a NEW +# commit, still produces a new finding with a different fingerprint and is +# still reported. +# +# Verified 2026-08-09 against gitleaks 8.30.1: converting the four +# `.gitleaks.toml` `paths`-scoped allowlists this file replaces (`tests/`, +# `src/components/ConnectionModal.tsx`, `database-compose.yml`) to this +# fingerprint list closed a real gap - a real-shaped secret planted in a new +# file under `tests/`, or next to the known fixture in the other two files, +# was silently swallowed by the old path-scoped allowlists and is reported +# under this one. tests/unit/gitleaksignore.test.ts enforces the shape (every +# line either a comment or a well-formed, non-wildcard fingerprint) and pins +# the count so a line silently dropped, or one silently added without review, +# both show up as a diff. + +# jwt.io's published sample token (header/payload `sub: 1234567890`), used to +# verify a failed OIDC exchange does not leak a real token. Matches both the +# jwt rule and, in the redaction test, the generic-api-key rule. +01693036a151eca81b128f03335ae7f8f687ca52:tests/security/auth-audit.test.ts:jwt:255 +01693036a151eca81b128f03335ae7f8f687ca52:tests/security/audit-redaction.test.ts:generic-api-key:103 + +# 32-char hex filler used as a placeholder JWT secret across three Helm-chart +# rendering tests, plus a 31-char boundary-length variant in the same file. +54e6b79d54532105e56eb0b06ca552828c64f874:tests/unit/helm-chart-auth-provider.test.ts:generic-api-key:28 +332ebefa913ca28a494d96edbbfdd2d974d68106:tests/unit/helm-chart-user-password.test.ts:generic-api-key:18 +332ebefa913ca28a494d96edbbfdd2d974d68106:tests/unit/helm-chart-hardening.test.ts:generic-api-key:30 +332ebefa913ca28a494d96edbbfdd2d974d68106:tests/unit/helm-chart-hardening.test.ts:generic-api-key:132 + +# Self-describing literal strings naming their own role in an auth-bootstrap +# unit test ("persisted-secret-that-is-32-chars-x", "winner-secret-that-is-at- +# least-32-chars"), repeated across two commits. +4d66b72ddd3447b3271732f9a3664269a84280ce:tests/unit/lib/auth-bootstrap.test.ts:generic-api-key:331 +4d66b72ddd3447b3271732f9a3664269a84280ce:tests/unit/lib/auth-bootstrap.test.ts:generic-api-key:353 +4d66b72ddd3447b3271732f9a3664269a84280ce:tests/unit/lib/auth-bootstrap.test.ts:generic-api-key:379 +4d66b72ddd3447b3271732f9a3664269a84280ce:tests/unit/lib/auth-bootstrap.test.ts:generic-api-key:406 +4d66b72ddd3447b3271732f9a3664269a84280ce:tests/unit/lib/auth-bootstrap.test.ts:generic-api-key:442 +095c613f317cc8792d4da1bf8d840cf58f9ed7fe:tests/unit/lib/auth-bootstrap.test.ts:generic-api-key:309 + +# tests/unit/ssh-tunnel.test.ts's stub private key, whose PEM body is +# literally the word "fake" - used with a stub SSH client to test tunnel auth +# wiring, not a real key. +7b99be66f9a77f007dc62e375ddcae4878d23c54:tests/unit/ssh-tunnel.test.ts:private-key:155 + +# The example password ("StrongPass123", "StrongPass456") in the documented +# `helm install --set secrets.adminPassword=...` command, duplicated verbatim +# across four documentation files. +b742deda754da77f89b73ea947e8fa45cbc49bc9:operator/helm-charts/libredb-studio/README.md:generic-api-key:122 +b742deda754da77f89b73ea947e8fa45cbc49bc9:operator/helm-charts/libredb-studio/README.md:generic-api-key:123 +421fddcc1e9278a2ea0c94b8c3e07a669d085755:charts/libredb-studio/README.md:generic-api-key:116 +421fddcc1e9278a2ea0c94b8c3e07a669d085755:charts/libredb-studio/README.md:generic-api-key:117 +02f8532c9e85e957297bccc5385a75aafa02263d:docs/HELM_CHART.md:generic-api-key:227 +02f8532c9e85e957297bccc5385a75aafa02263d:docs/HELM_CHART.md:generic-api-key:228 +dfb4a9af0dcf58a7042b138d9d710a011041fb1a:docs/kubernetes-helm-chart-artifacthub-plan.md:generic-api-key:331 +dfb4a9af0dcf58a7042b138d9d710a011041fb1a:docs/kubernetes-helm-chart-artifacthub-plan.md:generic-api-key:332 + +# The connection form's PEM placeholder ("Optional client key...") in the +# textarea's `placeholder` attribute. UI copy, not a key; the rule's match is +# greedy and its capture runs on for thousands of characters of surrounding +# component code, but the fingerprint pins the exact commit, file and line the +# addition landed on. +7f69b8036ebb1dc24f83f18db4174a367121fab0:src/components/ConnectionModal.tsx:private-key:648 + +# The Couchbase healthcheck in the local development compose file: curl -u +# "$$COUCHBASE_USER:$$COUCHBASE_PASSWORD" is Compose variable interpolation, +# resolved at container-run time from the same file's environment block, not a +# credential in source. Matched twice on the same line, once per half. +5135a9c606e9e42c1b4ae0bdc31725718758f2b4:database-compose.yml:curl-auth-user:83 +5135a9c606e9e42c1b4ae0bdc31725718758f2b4:database-compose.yml:curl-auth-user:122 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 932ca2f8..63333e3c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -145,14 +145,20 @@ commits your branch adds: docker run --rm -v "$PWD:/repo:ro" -w /repo \ zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \ git --no-banner --redact --config /repo/.gitleaks.toml \ - --log-opts="--no-merges origin/main..HEAD" + --log-opts="--diff-merges=first-parent origin/main..HEAD" ``` If it reports a real credential, rotate it first — the value is already in every -clone. If it reports a fixture or placeholder, add a rule-scoped allowlist to -`.gitleaks.toml` with a description explaining why; an allowlist that names no -`targetRules` is rejected by `tests/unit/gitleaks-config.test.ts`, because it -would exempt that path from every rule the scanner has. +clone. If it reports a fixture or placeholder, copy the finding's own +`Fingerprint` (`commit:file:rule:startline`, printed in the JSON report the +command above can produce with `--report-format json`) into `.gitleaksignore` +with a comment explaining why; that suppresses exactly this one finding, so a +real secret added later — even the same fabricated literal, in a new commit — +is still reported. `.gitleaks.toml`'s `[[allowlists]]` is for the narrower case +of a whole rule being unconditionally noisy for a reviewable reason, not for a +single fixture; an allowlist that names no `targetRules` is rejected by +`tests/unit/gitleaks-config.test.ts`, because it would exempt that path from +every rule the scanner has. **Vulnerable dependencies.** This one reports on pull requests and never fails them. The quickest local view needs no container: @@ -163,7 +169,9 @@ bun audit `bun audit` reports every severity and does not tell you whether a fix exists, so expect a long list; it is a starting point, not a verdict. The scan CI actually -runs, including the fixed-version column and the three non-npm lockfiles: +runs covers the npm, Rust and Go ecosystems together (`bun.lock`, +`desktop/src-tauri/Cargo.lock`, the launcher's `go.mod`) and includes the +fixed-version column `bun audit` lacks: ```bash docker run --rm -v "$PWD:/repo:ro" -w /repo \ diff --git a/SECURITY.md b/SECURITY.md index e51048c8..ea87c47f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -148,10 +148,11 @@ When using LibreDB Studio, please follow these security best practices: #### Supply Chain -- Dependencies are scanned on every pull request against the lockfiles that - actually build the shipped artefacts: `bun.lock`, the Windows launcher's - `go.mod`, and the desktop shell's `Cargo.lock`. Findings appear in the run's - job summary and, for branches in this repository, in the GitHub Security tab +- Dependencies are scanned on every pull request against the npm, Go and Rust + inputs that actually build the shipped artefacts: `bun.lock`, the Windows + launcher's `go.mod`, and the desktop shell's `Cargo.lock`. Findings appear in + the run's job summary and, for branches in this repository, in the GitHub + Security tab - The scan **fails** only for a CRITICAL advisory that has a fixed version available and is not covered by an unexpired entry in `.trivyignore.yaml`, and only outside pull requests. Findings with no available fix are reported and @@ -201,12 +202,15 @@ gh attestation verify libredb-studio-.cdx.json --repo libredb/libredb-s The container image is not covered by that document, because the image is built after the release is published and this project's releases are immutable. Its SBOM is generated daily from the published image, and you can regenerate it -yourself from any digest at any time: +yourself from any digest at any time. A tag such as `:0.9.67` is mutable - it +resolves to whatever manifest it currently points at - so resolve it to the +immutable digest first: ```bash +digest=$(docker buildx imagetools inspect ghcr.io/libredb/libredb-studio:0.9.67 --format '{{json .Manifest.Digest}}' | tr -d '"') trivy image --format cyclonedx --scanners license \ --output libredb-studio-image.cdx.json \ - ghcr.io/libredb/libredb-studio:0.9.67 + "ghcr.io/libredb/libredb-studio@$digest" ``` ### Security Updates diff --git a/tests/unit/gitleaks-config.test.ts b/tests/unit/gitleaks-config.test.ts index cf2b48ba..7f148b92 100644 --- a/tests/unit/gitleaks-config.test.ts +++ b/tests/unit/gitleaks-config.test.ts @@ -1,12 +1,27 @@ /** * Threat: an allowlist that hides a real credential. * - * The historical sweep found 24 matches and classified all 24 as fabricated, so - * .gitleaks.toml exists to make the incremental scan start from zero. The way - * that file goes wrong is not a wrong regex - it is a future maintainer - * silencing one noisy path by adding a `paths` entry with no `targetRules`, - * which exempts that path from EVERY gitleaks rule, including the AWS, GCP, - * Slack and Stripe rules that have never produced a false positive here. + * The historical sweep found 24 matches and classified all 24 as fabricated. + * That classification lives in `.gitleaksignore` as exact fingerprints + * (tests/unit/gitleaksignore.test.ts covers its shape) - not here. This file + * used to also carry the classification as `[[allowlists]]` entries, but a + * `paths` allowlist for a rule that matches by SHAPE rather than by issuer + * (generic-api-key, jwt, private-key, curl-auth-user) exempts every future + * finding of that shape anywhere under the path, not just the historical one + * it was written for. Verified live 2026-08-09 against gitleaks 8.30.1: a + * `paths = ['^tests/']` allowlist silently swallowed a freshly planted, + * real-shaped secret added to a brand-new file under tests/, and a `paths` + * entry scoped to a single known file did the same for a real-shaped secret + * added next to the known fixture in that file. A fingerprint - the exact + * `commit:file:rule:startline` gitleaks reports - does not have that failure + * mode: a real secret added later, even the exact same fabricated literal in + * a new commit, produces a different fingerprint and is still reported. + * + * `.gitleaks.toml` keeps the `[[allowlists]]` mechanism available for the + * different problem it actually solves well - a rule that is unconditionally + * noisy for a known, reviewable reason - and this file guards the shape any + * future entry there must have, so a future maintainer silencing a noisy + * path does not reopen the gap above. * * Parsed with Bun.TOML rather than imported: `import x from "*.toml"` is a bun * loader feature that `tsc --noEmit` rejects, and typecheck is a required gate. @@ -38,8 +53,12 @@ describe(".gitleaks.toml", () => { expect(config.extend?.useDefault).toBe(true); }); - test("has allowlists at all - the sweep found 24 fabricated matches to classify", () => { - expect(allowlists.length).toBeGreaterThan(0); + test("carries no allowlists today - the 24 known findings are fingerprints in .gitleaksignore", () => { + // Not a requirement that this file must stay empty forever - a record of + // the current, expected state, so a reader knows the loops below are + // vacuous by design rather than by accident (the same pattern + // tests/unit/trivyignore-policy.test.ts uses for its own empty file). + expect(allowlists).toHaveLength(0); }); test("every allowlist is scoped to named rules", () => { @@ -81,6 +100,17 @@ describe(".gitleaks.toml", () => { } }); + test("no allowlist is scoped by path - a future one should be scoped by value or fingerprint", () => { + // The gap this whole file exists to prevent: a `paths` entry exempts + // every future finding under that path, not just the one it was written + // for. `.gitleaksignore` (exact fingerprints) and `regexes` + + // `regexTarget` (exact values) do not have that failure mode; `paths` + // does, which is why every allowlist here is one of the other two shapes. + for (const entry of allowlists) { + expect(entry.paths).toBeUndefined(); + } + }); + test("a value-based allowlist says which part of the finding it matches", () => { // Without regexTarget, gitleaks matches the regex against the whole line, // so '^StrongPass\\d+$' would silently never match and the exemption would diff --git a/tests/unit/gitleaksignore.test.ts b/tests/unit/gitleaksignore.test.ts new file mode 100644 index 00000000..5702597f --- /dev/null +++ b/tests/unit/gitleaksignore.test.ts @@ -0,0 +1,65 @@ +/** + * Unit tests for `.gitleaksignore`'s shape (security programme control 2.1). + * + * Gitleaks 8.30.1 has no `[[allowlists]]` field for a single-finding + * suppression: its TOML schema only accepts `commits`, `paths`, `regexes` or + * `stopwords` (verified live 2026-08-09 - a config with any other key fails + * to load). The exact `commit:file:rule:startline` fingerprint mechanism is a + * separate file, `.gitleaksignore`, one fingerprint per line, read by + * default from the scan's working directory. This file guards its shape: a + * malformed or wildcard-ish line here would either do nothing (a typo'd + * fingerprint silently never matches, which is safe but confusing) or, if + * gitleaks is ever asked to interpret it more loosely, could over-match - so + * every line is checked to be either a full-line comment or exactly one + * well-formed fingerprint. + */ +import { describe, expect, test } from "bun:test"; +import * as fs from "fs"; +import * as path from "path"; + +const file = path.join(__dirname, "../../.gitleaksignore"); +const lines = fs.readFileSync(file, "utf8").split("\n"); + +const FINGERPRINT = /^[0-9a-f]{40}:[^:\s]+(?:\/[^:\s]+)*:[a-z0-9-]+:[0-9]+$/; + +const contentLines = lines.map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#")); + +describe(".gitleaksignore", () => { + test("has fingerprint entries - the sweep found 24 fabricated matches to classify", () => { + expect(contentLines).toHaveLength(24); + }); + + test("every non-comment, non-blank line is a well-formed fingerprint", () => { + // commit:file:rule:startline. A line that fails this is either a stray + // typo (harmless - it just never matches) or, worse, something looser + // that could match more than intended. + for (const line of contentLines) { + expect({ line, wellFormed: FINGERPRINT.test(line) }).toEqual({ line, wellFormed: true }); + } + }); + + test("every fingerprint names a full 40-character commit SHA, not an abbreviation", () => { + // A short SHA is ambiguous as history grows and is not what gitleaks + // itself prints in a finding's Fingerprint field. + for (const line of contentLines) { + const sha = line.split(":")[0]; + expect({ line, shaLength: sha.length }).toEqual({ line, shaLength: 40 }); + } + }); + + test("no fingerprint names a rule this repository has not actually seen suppressed for", () => { + // Scoped to the four rules the historical sweep classified + // (tests/unit/gitleaks-config.test.ts's own docstring: jwt, generic-api-key, + // private-key, curl-auth-user). A fingerprint for any other rule is not + // impossible in principle, but today would mean an unreviewed addition. + const knownRules = new Set(["jwt", "generic-api-key", "private-key", "curl-auth-user"]); + for (const line of contentLines) { + const rule = line.split(":")[2]; + expect({ line, knownRule: knownRules.has(rule) }).toEqual({ line, knownRule: true }); + } + }); + + test("every fingerprint is unique - a duplicate suppresses nothing extra and just hides drift", () => { + expect(new Set(contentLines).size).toBe(contentLines.length); + }); +}); diff --git a/tests/unit/security-scan-workflow.test.ts b/tests/unit/security-scan-workflow.test.ts index fcc2625b..a0716248 100644 --- a/tests/unit/security-scan-workflow.test.ts +++ b/tests/unit/security-scan-workflow.test.ts @@ -211,9 +211,16 @@ describe("secret-scan is the one scan allowed to fail a check", () => { expect(checkout?.with?.["fetch-depth"]).toBe(0); }); - test("scans only the pull request's own commits, and skips merge commits", () => { + test("scans only the pull request's own commits, diffing merges against their first parent", () => { + // Plain `git log` already shows no diff for a merge commit, so a bare + // range never re-floods with the other side's unrelated history; but it + // also means a secret introduced only while resolving a merge conflict + // reaches neither parent's diff and would go unscanned. Verified + // 2026-08-09: `--diff-merges=first-parent` is what surfaces that + // resolution while a linear (no-merge) range scans identically to a bare + // range. const resolver = steps.find((s) => s.id === "range"); - expect(resolver?.run).toContain("--no-merges"); + expect(resolver?.run).toContain("--diff-merges=first-parent"); expect(resolver?.run).toContain("pull_request"); expect(resolver?.run).toContain("--all"); }); @@ -240,7 +247,7 @@ describe("secret-scan is the one scan allowed to fail a check", () => { // impossible rather than merely absent today. const resolver = steps.find((s) => s.id === "range"); const run = resolver?.run ?? ""; - expect(run).toMatch(/range="--no-merges \$BASE_SHA\.\.\$HEAD_SHA"/); + expect(run).toMatch(/range="--diff-merges=first-parent \$BASE_SHA\.\.\$HEAD_SHA"/); expect(run).toContain('echo "log_opts=$range"'); expect(run).toContain("git rev-list --count $range"); }); diff --git a/tests/unit/trivyignore-policy.test.ts b/tests/unit/trivyignore-policy.test.ts index 85e2f883..fa4c257b 100644 --- a/tests/unit/trivyignore-policy.test.ts +++ b/tests/unit/trivyignore-policy.test.ts @@ -43,7 +43,7 @@ describe(".trivyignore.yaml suppression policy", () => { // Not a requirement that this file must stay empty forever. A record of // the current, expected state, so a reader knows the loops below are // vacuous by design rather than by accident. - expect(vulnerabilities.length).toBe(0); + expect(vulnerabilities).toHaveLength(0); }); test("every suppression names the advisory it suppresses", () => { From 4b48fc8d8c05f8fff8196d8424bf5c4912da9e51 Mon Sep 17 00:00:00 2001 From: cevheri Date: Mon, 10 Aug 2026 01:10:47 +0300 Subject: [PATCH 11/13] fix(security): resolve a ReDoS, correct BACKLOG's CVE mapping, and close two more scanner gaps CodeQL (js/redos): tests/unit/gitleaksignore.test.ts's FINGERPRINT regex had a redundant, ambiguous slash-delimited group - '[^:\\s]+' already matches '/', so '(?:\\/[^:\\s]+)*' let the same character be consumed by either alternative, exponential backtracking on a long run of '/'. Removed the redundant group; the colon delimiters between fingerprint fields already make one '[^:\\s]+' per segment unambiguous. docs/BACKLOG.md C7: resolved all five 'Middleware / Proxy bypass' GHSAs against the next advisories affecting next@16.1.6 against the GitHub advisory database directly rather than trusting a copied CVE number. CVE-2026-44573 (GHSA-36qx-fr4f-26g5) was cited as one of the three that apply here; it does not - it is a Pages Router + i18n advisory and this app has no pages/ directory. The one actually missing was CVE-2026-45109 (GHSA-26hh-7cqf-hhc6), an incomplete-fix follow-up scoped to middleware.ts. Also verified CVE-2026-64642 (GHSA-6gpp-xcg3-4w24, Turbopack + single-locale i18n) does not apply - this app has no i18n config. Recorded the full table with fixed versions so a future reader does not need to re-derive it. security-scan.yml: added a direct 'git rev-parse --is-shallow-repository' guard before the --all full-history scan. That scan skips the pull-request range's non-zero-commit assertion because there is no equivalent range to miscount, but a shallow checkout would make --all scan a truncated slice and report 'no leaks found' indistinguishable from a real clean scan. Sabotage-verified: removing the guard makes its test fail. image-scan: the vuln scan and the SBOM generation each independently resolved the mutable ':latest' tag in separate docker run invocations minutes apart; a release retagging ':latest' mid-run would make them describe two different images with no shared identity. Resolved the digest once and reused it for both. release-artifacts.yml: the SBOM root-component rename set 'name' but left 'version' empty, so a CycloneDX consumer that keys a project by name+version would collapse every release's SBOM into the same unversioned project. Now sets both from the release version. tests/unit/security-scan-workflow.test.ts and tests/unit/release-sbom.test.ts updated for the new steps; tests/unit/gitleaksignore.test.ts unchanged in shape, just its regex. --- .github/workflows/release-artifacts.yml | 16 +++++--- .github/workflows/security-scan.yml | 49 +++++++++++++++++++++-- docs/BACKLOG.md | 36 ++++++++++++----- tests/unit/gitleaksignore.test.ts | 9 ++++- tests/unit/release-sbom.test.ts | 11 ++++- tests/unit/security-scan-workflow.test.ts | 32 ++++++++++++++- 6 files changed, 132 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 986fd636..d8aabde6 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -597,25 +597,29 @@ jobs: done ls -la sbom - - name: Name the SBOM's root component + - name: Name and version the SBOM's root component env: VERSION: ${{ needs.guard.outputs.version }} run: | set -euo pipefail # Trivy names the root component after its scan target - "." for a - # filesystem scan of the repository root - so an unpatched document - # imports into Dependency-Track, or any other CycloneDX consumer, as - # a project literally named ".". This is the one field `trivy fs` - # has no flag to set. + # filesystem scan of the repository root - and leaves its version + # empty; neither is a flag `trivy fs` has. Leaving version unset + # matters beyond cosmetics: a CycloneDX consumer such as + # Dependency-Track keys a project by name+version, so successive + # releases' unpatched SBOMs would all collapse into one + # unversioned "libredb-studio" project, each overwriting the last. node -e ' const fs = require("fs"); const file = process.argv[1]; + const version = process.argv[2]; const doc = JSON.parse(fs.readFileSync(file, "utf8")); if (doc.metadata && doc.metadata.component) { doc.metadata.component.name = "libredb-studio"; + doc.metadata.component.version = version; } fs.writeFileSync(file, JSON.stringify(doc, null, 2)); - ' "sbom/libredb-studio-${VERSION}.cdx.json" + ' "sbom/libredb-studio-${VERSION}.cdx.json" "$VERSION" - name: Verify the SBOM describes something env: diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 0678fa23..0d1f9775 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -139,6 +139,20 @@ jobs: # against, which is why there is no commit_count, and no # "Assert the scan covered commits" check, for these events. A # decision, not an oversight. + # + # `--all` still has a failure mode the range branch above does + # not: a shallow checkout. `fetch-depth: 0` above makes one + # unlikely today, but nothing here re-derives that guarantee, and + # a shallow `--all` does not fail - it scans however many commits + # the shallow boundary left reachable and reports "no leaks + # found" over that truncated slice, which looks identical to a + # real clean scan of the full 753+ commits. `git rev-parse + # --is-shallow-repository` is the direct check for exactly that, + # so it is asserted here rather than trusted. + if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then + echo "::error::This checkout is shallow, so --all would scan a truncated slice of history and could report a false clean. Checkout must use fetch-depth: 0." >&2 + exit 1 + fi echo "log_opts=--all" >> "$GITHUB_OUTPUT" fi @@ -435,10 +449,26 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve :latest to one digest + id: image + run: | + set -euo pipefail + # The vuln scan and the SBOM below are two separate `docker run` + # invocations several minutes apart. Resolving `:latest` in each + # independently means a release that retags `:latest` mid-run makes + # them describe two different images with no shared identity - the + # exact failure mode a digest-pinned reference exists to rule out + # everywhere else in this workflow. Resolved once, here, and every + # step below scans image_ref, never the bare tag. + digest=$(docker buildx imagetools inspect ghcr.io/libredb/libredb-studio:latest --format '{{json .Manifest.Digest}}' | tr -d '"') + echo "digest=$digest" >> "$GITHUB_OUTPUT" + echo "image_ref=ghcr.io/libredb/libredb-studio@$digest" >> "$GITHUB_OUTPUT" + - name: Scan the published image env: TRIVY_USERNAME: ${{ github.actor }} TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + IMAGE_REF: ${{ steps.image.outputs.image_ref }} run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/trivy-cache" "$RUNNER_TEMP/trivy-out" @@ -459,7 +489,7 @@ jobs: --scanners vuln \ --format json \ --output /out/image.json \ - ghcr.io/libredb/libredb-studio:latest + "$IMAGE_REF" - name: Render the report run: | @@ -472,6 +502,8 @@ jobs: "$TRIVY_IMAGE" convert --format sarif --output /out/image.sarif /out/image.json - name: Generate the image SBOM + env: + IMAGE_REF: ${{ steps.image.outputs.image_ref }} run: | set -euo pipefail # Not a release asset, and it cannot be one: release-artifacts.yml @@ -480,6 +512,11 @@ jobs: # source SBOM on the release covers the application closure; this one # adds the Debian layer, and anyone can regenerate it from the immutable # digest at any time with the command documented in SECURITY.md. + # + # Same $IMAGE_REF the vuln scan above used, not a second `:latest` + # resolved independently - a release retagging `:latest` between the + # two `docker run` invocations would otherwise make the report and + # this SBOM describe two different images with no shared identity. docker run --rm \ -v "$HOME/.docker/config.json:/root/.docker/config.json:ro" \ -v "$RUNNER_TEMP/trivy-cache:/root/.cache/trivy" \ @@ -488,13 +525,19 @@ jobs: --format cyclonedx \ --scanners license \ --output /out/image.cdx.json \ - ghcr.io/libredb/libredb-studio:latest + "$IMAGE_REF" - name: Publish the findings to the job summary + env: + IMAGE_REF: ${{ steps.image.outputs.image_ref }} run: | set -euo pipefail { - echo "## Published image: ghcr.io/libredb/libredb-studio:latest" + echo "## Published image: $IMAGE_REF" + echo + echo "Resolved from \`ghcr.io/libredb/libredb-studio:latest\` once, at the start of" + echo "this run, so the vulnerability report and the SBOM above describe the same" + echo "bytes even if a release retags \`:latest\` while this job is running." echo echo "Critical and high findings only. Most carry no fixed package - Debian's" echo "security team has not shipped one - which is why this job reports and never" diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 2b71e9c0..a831e12b 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -607,15 +607,33 @@ could be dropped entirely. Done when `bun audit --json` carries a fix field. ### C7. Seventeen HIGH fixable npm advisories, three of them middleware/authorization bypasses against the exact surface Phase 1 hardened The dependency gate's job summary lists seventeen HIGH advisories, mostly `next` -16.1.6 to 16.2.x; none is CRITICAL so none gates. Three are not generic HIGH -advisories: **CVE-2026-44573, CVE-2026-44574 and CVE-2026-44575 are Next.js -middleware-bypass and authorization-bypass advisories**, and `src/proxy.ts` IS -Next 16's middleware - the exact file Phase 1 put RBAC, the Origin check, rate -limiting, the security headers and the audit emit into. Framing this as a -compatibility question alone - a Next minor can change middleware and CSP -behaviour, which is exactly the surface Phase 1 just verified in a real browser - -understated the risk; it is also a question of which known bypasses ship against -that surface today. +16.1.6 to 16.2.x; none is CRITICAL so none gates. Five carry the "Middleware / +Proxy bypass" title, and resolving each against the GitHub advisory database +(the authoritative source, not a copy of a CVE number) narrows to three that +actually apply to this application - App Router with no `i18n` config, no +`pages/` directory, authorization enforced in `src/proxy.ts` middleware: + +| GHSA | CVE | Fixed in | Applies here | Why | +|---|---|---|---|---| +| `GHSA-267c-6grr-h53f` | CVE-2026-44575 | 16.2.5 | yes | App Router segment-prefetch (`.rsc` / transport variants) resolves to a page middleware's matcher does not cover | +| `GHSA-26hh-7cqf-hhc6` | CVE-2026-45109 | 16.2.6 | yes | incomplete-fix follow-up to the row above, specifically for `middleware.ts` | +| `GHSA-492v-c6pp-mqqv` | CVE-2026-44574 | 16.2.5 | yes | dynamic route parameter injection bypasses a middleware path match; this app has middleware-protected dynamic routes (e.g. `/api/storage/[collection]`) | +| `GHSA-36qx-fr4f-26g5` | CVE-2026-44573 | 16.2.5 | **no** | Pages Router + `i18n` only - this app has no `pages/` directory | +| `GHSA-6gpp-xcg3-4w24` | CVE-2026-64642 | 16.2.11 | **no** | requires a single-entry `config.i18n.locales` - this app has no `i18n` config at all | + +**CVE-2026-44573 was previously cited here in place of CVE-2026-45109 - wrong +identifier, not a stale one: both were published before this branch existed.** +The two non-applicable rows are recorded so a future reader does not re-derive +"five GHSAs, three CVEs previously named" and wonder whether two were dropped by +mistake. + +`src/proxy.ts` IS Next 16's middleware - the exact file Phase 1 put RBAC, the +Origin check, rate limiting, the security headers and the audit emit into. +Framing the applicable three as a compatibility question alone - a Next minor +can change middleware and CSP behaviour, which is exactly the surface Phase 1 +just verified in a real browser - understated the risk; it is also a question +of which known bypasses ship against that surface today. `next@16.3.0` carries +zero advisories against it, so the fix is a version bump, not a patch. Still not taken inside Phase 2, for the compatibility reason above: bumping inside a supply-chain-scanning phase would invalidate Phase 1's browser diff --git a/tests/unit/gitleaksignore.test.ts b/tests/unit/gitleaksignore.test.ts index 5702597f..2a016ee9 100644 --- a/tests/unit/gitleaksignore.test.ts +++ b/tests/unit/gitleaksignore.test.ts @@ -20,7 +20,14 @@ import * as path from "path"; const file = path.join(__dirname, "../../.gitleaksignore"); const lines = fs.readFileSync(file, "utf8").split("\n"); -const FINGERPRINT = /^[0-9a-f]{40}:[^:\s]+(?:\/[^:\s]+)*:[a-z0-9-]+:[0-9]+$/; +// The file segment (`[^:\s]+`) already matches `/`, so no separate +// slash-delimited grouping is needed. A previous version had one anyway - +// `[^:\s]+(?:\/[^:\s]+)*` - which let the same slash be consumed by either +// alternative, an ambiguity CodeQL (js/redos) flagged as exponential +// backtracking on a long enough run of `/` before a non-matching suffix. The +// colon delimiters between fingerprint fields make one `[^:\s]+` per segment +// unambiguous - there is no repetition left to backtrack over. +const FINGERPRINT = /^[0-9a-f]{40}:[^:\s]+:[a-z0-9-]+:[0-9]+$/; const contentLines = lines.map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#")); diff --git a/tests/unit/release-sbom.test.ts b/tests/unit/release-sbom.test.ts index b5554025..c93bdf2e 100644 --- a/tests/unit/release-sbom.test.ts +++ b/tests/unit/release-sbom.test.ts @@ -43,7 +43,7 @@ const generate = sbomSteps.find((s) => s.run?.includes("cyclonedx")); const upload = sbomSteps.find((s) => s.run?.includes("gh release upload")); const dockerHubCheck = sbomSteps.find((s) => s.id === "dockerhub"); const dockerHubLogin = sbomSteps.find((s) => s.name === "Log in to Docker Hub"); -const rename = sbomSteps.find((s) => s.name === "Name the SBOM's root component"); +const rename = sbomSteps.find((s) => s.name === "Name and version the SBOM's root component"); const publishRelease = workflow.jobs["publish-release"]; const verify = (publishRelease?.steps ?? []).find((s) => s.run?.includes("missing required asset")); @@ -129,6 +129,15 @@ describe("the sbom job names its root component, so it does not import as a proj expect(rename?.run).toContain("libredb-studio"); }); + test("also sets metadata.component.version, so successive releases do not collapse into one project", () => { + // Dependency-Track and similar CycloneDX consumers key a project by + // name+version. Setting the name alone leaves version empty, and every + // release's SBOM would then import as the same unversioned project, + // each overwriting the last rather than recording its own release. + expect(rename?.run).toContain("metadata.component.version"); + expect(rename?.run).toContain("$VERSION"); + }); + test("runs after generation and before verification and attestation", () => { const names = sbomSteps.map((s) => s.name); const renameIndex = names.indexOf(rename?.name ?? ""); diff --git a/tests/unit/security-scan-workflow.test.ts b/tests/unit/security-scan-workflow.test.ts index a0716248..39e2c440 100644 --- a/tests/unit/security-scan-workflow.test.ts +++ b/tests/unit/security-scan-workflow.test.ts @@ -265,6 +265,20 @@ describe("secret-scan is the one scan allowed to fail a check", () => { expect(assertion?.run).toContain("exit 1"); }); + test("refuses to run --all against a shallow checkout", () => { + // A shallow `--all` does not fail the way an unresolvable pull-request + // range does - it scans however many commits the shallow boundary left + // reachable and reports "no leaks found" over that truncated slice, + // indistinguishable from a real clean scan of the full history. Verified + // live 2026-08-09: `git rev-parse --is-shallow-repository` prints + // "true" for a `--depth 1` clone and "false" for a full one. + const resolver = steps.find((s) => s.id === "range"); + const run = resolver?.run ?? ""; + expect(run).toContain("git rev-parse --is-shallow-repository"); + expect(run).toMatch(/is-shallow-repository\)"\s*=\s*"true"/); + expect(run).toContain("exit 1"); + }); + test("the commit-count assertion runs after the scan, not before it", () => { const names = steps.map((s) => s.name); expect(names.indexOf("Assert the scan covered commits")).toBeGreaterThan( @@ -408,7 +422,9 @@ describe("dependency-scan reports on pull requests and gates elsewhere", () => { describe("image-scan reports and never gates", () => { const job = workflow.jobs["image-scan"]; const steps = job?.steps ?? []; + const resolve = steps.find((s) => s.id === "image"); const scan = steps.find((s) => s.run?.includes("--output /out/image.json")); + const sbom = steps.find((s) => s.run?.includes("--output /out/image.cdx.json")); const sarif = steps.find((s) => s.uses?.startsWith("github/codeql-action/upload-sarif@")); test("exists and is named for a human reading the checks list", () => { @@ -424,7 +440,21 @@ describe("image-scan reports and never gates", () => { }); test("scans the image users actually run", () => { - expect(scan?.run).toContain("ghcr.io/libredb/libredb-studio:latest"); + expect(resolve?.run).toContain("ghcr.io/libredb/libredb-studio:latest"); + }); + + test("resolves :latest to one digest and reuses it for both the vuln scan and the SBOM", () => { + // The vuln scan and the SBOM are two separate `docker run` invocations + // minutes apart. Each independently resolving `:latest` would let a + // release that retags `:latest` mid-run make them describe two different + // images with no shared identity - the class of bug a digest-pinned + // reference exists everywhere else in this workflow to rule out. + expect(resolve?.run).toContain("imagetools inspect"); + expect(resolve?.run).toContain("image_ref=ghcr.io/libredb/libredb-studio@$digest"); + expect(scan?.env?.IMAGE_REF).toBe("${{ steps.image.outputs.image_ref }}"); + expect(sbom?.env?.IMAGE_REF).toBe("${{ steps.image.outputs.image_ref }}"); + expect(scan?.run).toContain('"$IMAGE_REF"'); + expect(sbom?.run).toContain('"$IMAGE_REF"'); }); test("cannot fail: no exit code anywhere in this job", () => { From 2c909726761c20814eed323b594f12eba845a53c Mon Sep 17 00:00:00 2001 From: cevheri Date: Mon, 10 Aug 2026 01:17:51 +0300 Subject: [PATCH 12/13] docs(security): resolve BACKLOG C7 - the next bump landed via #323 The rebase onto main pulled in #323 (next 16.1.6 -> 16.3.0), which is exactly the deferred work C7 described. All three applicable middleware- bypass advisories (CVE-2026-44575, CVE-2026-45109, CVE-2026-44574) are fixed by 16.2.5/16.2.6, both below the now-locked 16.3.0 - verified against bun.lock. Deleted the entry per this file's own convention (delete an entry when the work lands) rather than leave a corrected-but- already-resolved decision record; #323's own post-merge correction note carries the CVE/GHSA mapping history. Renumbered the former C8 to C7. --- docs/BACKLOG.md | 47 +---------------------------------------------- 1 file changed, 1 insertion(+), 46 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index a831e12b..c2d6d455 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -604,52 +604,7 @@ Trivy owns the gate and `bun audit` is a job-summary second opinion. If bun adds fixed-version data, the container dependency in the local contributor workflow could be dropped entirely. Done when `bun audit --json` carries a fix field. -### C7. Seventeen HIGH fixable npm advisories, three of them middleware/authorization bypasses against the exact surface Phase 1 hardened - -The dependency gate's job summary lists seventeen HIGH advisories, mostly `next` -16.1.6 to 16.2.x; none is CRITICAL so none gates. Five carry the "Middleware / -Proxy bypass" title, and resolving each against the GitHub advisory database -(the authoritative source, not a copy of a CVE number) narrows to three that -actually apply to this application - App Router with no `i18n` config, no -`pages/` directory, authorization enforced in `src/proxy.ts` middleware: - -| GHSA | CVE | Fixed in | Applies here | Why | -|---|---|---|---|---| -| `GHSA-267c-6grr-h53f` | CVE-2026-44575 | 16.2.5 | yes | App Router segment-prefetch (`.rsc` / transport variants) resolves to a page middleware's matcher does not cover | -| `GHSA-26hh-7cqf-hhc6` | CVE-2026-45109 | 16.2.6 | yes | incomplete-fix follow-up to the row above, specifically for `middleware.ts` | -| `GHSA-492v-c6pp-mqqv` | CVE-2026-44574 | 16.2.5 | yes | dynamic route parameter injection bypasses a middleware path match; this app has middleware-protected dynamic routes (e.g. `/api/storage/[collection]`) | -| `GHSA-36qx-fr4f-26g5` | CVE-2026-44573 | 16.2.5 | **no** | Pages Router + `i18n` only - this app has no `pages/` directory | -| `GHSA-6gpp-xcg3-4w24` | CVE-2026-64642 | 16.2.11 | **no** | requires a single-entry `config.i18n.locales` - this app has no `i18n` config at all | - -**CVE-2026-44573 was previously cited here in place of CVE-2026-45109 - wrong -identifier, not a stale one: both were published before this branch existed.** -The two non-applicable rows are recorded so a future reader does not re-derive -"five GHSAs, three CVEs previously named" and wonder whether two were dropped by -mistake. - -`src/proxy.ts` IS Next 16's middleware - the exact file Phase 1 put RBAC, the -Origin check, rate limiting, the security headers and the audit emit into. -Framing the applicable three as a compatibility question alone - a Next minor -can change middleware and CSP behaviour, which is exactly the surface Phase 1 -just verified in a real browser - understated the risk; it is also a question -of which known bypasses ship against that surface today. `next@16.3.0` carries -zero advisories against it, so the fix is a version bump, not a patch. - -Still not taken inside Phase 2, for the compatibility reason above: bumping -inside a supply-chain-scanning phase would invalidate Phase 1's browser -verification without re-running it, and this belongs to a dedicated bump pull -request instead. - -**Decision, recorded here because it is now updated by evidence**: the programme -ships one release, 0.10.0, after Phase 3. The Next bump must land BEFORE that tag -is cut, not merely "after the Phase 2 chain lands" - 0.10.0 must not ship with -known middleware-bypass and authorization-bypass advisories against the exact -layer it hardens. The bump pull request must re-run Phase 1's end-to-end -verification in a real browser, because it touches the middleware and CSP -surface that verification exists to cover. Done when that pull request lands and -0.10.0 is cut from a `main` that has it. - -### C8. The release SBOM does not describe the bundled Node.js runtime +### C7. The release SBOM does not describe the bundled Node.js runtime `packaging/linux/fetch-node.sh` and `packaging/windows/fetch-node.sh` download a pinned Node.js build and bundle it into every packaged artefact except the npm From 7aa94c8e122e2a7387f9f18487cbcac4da67ea8d Mon Sep 17 00:00:00 2001 From: cevheri Date: Mon, 10 Aug 2026 02:12:22 +0300 Subject: [PATCH 13/13] fix(security): run the release SBOM's Trivy container as the invoking user, not root Copilot found a real defect that would have broken the first 0.10.0 release attempt: aquasec/trivy runs as root by default, so the SBOM it writes into the bind-mounted workspace lands on the host owned by root, mode 644. The very next step patches that same file in place with Node's fs.writeFileSync, which for an existing file opens it for write - permission a non-root runner does not have on a root-owned file. release-artifacts.yml never runs on a pull request, so none of this branch's 18 green checks exercised this path; the first execution would have been the real release, and a failed release burns a patch version rather than retrying the same tag. Reproduced against the real pinned image and real repository lockfiles (bun.lock, Cargo.lock, go.mod): the unpatched command produces a root-owned SBOM, and the exact 'Name and version the SBOM's root component' step's fs.writeFileSync then fails EACCES. Fixed with --user "$(id -u):$(id -g)" on the Trivy invocation - this scan uses --scanners license only, so it needs no vulnerability-DB cache and has no root-owned-cache fallout to work around, unlike the /root/.cache/ trivy-mounted scanners elsewhere in this repository. Verified end-to-end by extracting the actual committed step scripts from the YAML and running them in sequence against the real image: output is now owned by the invoking user, and the patch step succeeds and sets name and version correctly. Comment explains why the other two shapes from review were not taken: sudo chown is reactive and assumes passwordless sudo a self-hosted runner may not grant; a write-sibling-then-rename in the Node step needs no privilege either, but moves the fix somewhere a future editor could 'simplify' back into an in-place write without realising a permission fix depends on it. Audited all docker run invocations across security-scan.yml and release-artifacts.yml for the same class of defect (output written by a container later modified by a host-side step). This is the only one: every other scanner's output is either uploaded/cat'd read-only downstream, rewritten by another container rather than the host, or produces no file at all. tests/unit/release-sbom.test.ts: new guard test, matching the flag immediately after 'docker run --rm' rather than a bare substring - this step's own comment also contains the literal flag text in prose, so a substring check alone would still pass with the flag removed from the actual command. Sabotage-verified: removing the flag from the command (leaving the comment) fails the test; restoring it passes. --- .github/workflows/release-artifacts.yml | 27 +++++++++++++++++++++++++ tests/unit/release-sbom.test.ts | 19 +++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index d8aabde6..a05453b1 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -573,8 +573,35 @@ jobs: # version, never the same tag), so a transient Docker Hub rate limit # or network blip must not be allowed to stall a release before # publish. + # + # --user "$(id -u):$(id -g)": aquasec/trivy runs as root by default, + # so without this the bind-mounted output file lands on the host + # owned by root, mode 644. Reproduced 2026-08-09: the very next step + # patches this same file in place with Node's `fs.writeFileSync`, + # which for an EXISTING file opens it for write - permission this + # runner's own non-root user does not have on a root-owned file, so + # that step fails EACCES. This scanner needs no vulnerability-DB + # cache (`--scanners license` only), so running as the invoking user + # has no cache-ownership fallout to work around, unlike the + # `/root/.cache/trivy`-mounted scanners elsewhere in this + # repository, which stay root and are not touched by a host step + # afterward. + # + # Two other shapes were considered and rejected. `sudo chown` the + # file back after generation is reactive rather than preventing the + # wrong owner in the first place, and assumes passwordless sudo, + # which a self-hosted runner is not guaranteed to grant. Writing a + # sibling file and renaming over the original needs no privilege + # either (rename only needs write on the directory), but it moves + # the fix into the Node patch step where it looks like an + # unrelated stylistic choice - exactly the shape a future editor + # "simplifies" back into an in-place write without realising a + # permission fix depends on it. Fixing it here, on the line that + # creates the wrong owner, is the one shape where the fix and the + # defect it prevents stay next to each other. attempt=0 until docker run --rm \ + --user "$(id -u):$(id -g)" \ -v "$GITHUB_WORKSPACE:/repo" \ -w /repo \ aquasec/trivy@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c fs \ diff --git a/tests/unit/release-sbom.test.ts b/tests/unit/release-sbom.test.ts index c93bdf2e..a1b0c4a0 100644 --- a/tests/unit/release-sbom.test.ts +++ b/tests/unit/release-sbom.test.ts @@ -120,6 +120,25 @@ describe("the sbom job authenticates to Docker Hub when possible, and retries th expect(generate?.run).toContain("until docker run"); expect(generate?.run).toMatch(/attempt/); }); + + test("runs trivy as the invoking user, not root, since the next step patches this file in place", () => { + // aquasec/trivy runs as root by default, so a bind-mounted output file + // lands on the host owned by root, mode 644. Reproduced 2026-08-09 + // against the real pinned image: the "Name and version the SBOM's root + // component" step's `fs.writeFileSync` on that same, already-existing + // file needs write permission on the file itself, which the runner's own + // non-root user does not have on a root-owned one - EACCES. Verified the + // fix the same way: with --user "$(id -u):$(id -g)" the output is owned + // by the invoking user and the patch step succeeds. + // + // Asserted as the flag immediately following `docker run --rm`, not a + // bare substring match: this step's own comment above the command + // explains the flag in prose and so also contains the literal text + // `--user "$(id -u):$(id -g)"` - a substring check alone would still + // pass with the flag removed from the actual command. + const run = generate?.run ?? ""; + expect(run).toMatch(/docker run --rm \\\s*\n\s*--user "\$\(id -u\):\$\(id -g\)" \\/); + }); }); describe("the sbom job names its root component, so it does not import as a project called '.'", () => {