diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 95d8ee59..a05453b1 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,206 @@ 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: 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 }} + 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. + # + # 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. + # + # --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 \ + --format cyclonedx \ + --scanners license \ + --skip-dirs .next \ + --skip-dirs dist \ + --skip-dirs coverage \ + --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 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 - 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" "$VERSION" + + - 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): @@ -872,10 +1079,12 @@ 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, linux-packages, desktop-appimage, snap] + needs: [guard, publish, sbom, linux-packages, desktop-appimage, snap] runs-on: ubuntu-latest permissions: contents: write @@ -915,7 +1124,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/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 00000000..0d1f9775 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,564 @@ +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. +# +# 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 +# red overnight - and a secret in a diff is unambiguous and +# 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 +# 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 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: + 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. `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 + # 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="--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 + # 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, 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. + # + # `--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 + + - 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: 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: | + set -uo pipefail + report="$RUNNER_TEMP/gitleaks/gitleaks.json" + { + echo "## Committed secrets found" + echo + if [ -s "$report" ]; then + 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 + 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 "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: + 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: + # 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: | + 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' + id: gate + 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 + # 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. + # + # `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" + 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 " \`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" + + 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: 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" + # 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 \ + "$IMAGE_REF" + + - 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 + env: + IMAGE_REF: ${{ steps.image.outputs.image_ref }} + 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. + # + # 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" \ + -v "$RUNNER_TEMP/trivy-out:/out" \ + "$TRIVY_IMAGE" image \ + --format cyclonedx \ + --scanners license \ + --output /out/image.cdx.json \ + "$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: $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" + 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/.gitignore b/.gitignore index f07c6519..7ef69518 100644 --- a/.gitignore +++ b/.gitignore @@ -166,5 +166,13 @@ docs/summaries/ deploy/digitalocean/droplet/scripts/90-cleanup.sh deploy/digitalocean/droplet/scripts/99-img-check.sh +# 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 +/*.cdx.json + diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..2f9cbccd --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,34 @@ +# Secret scanning configuration for LibreDB Studio (security programme control 2.1). +# +# 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. +# +# 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" + +[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 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/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 00000000..87060357 --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,48 @@ +# 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. +# +# 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 \ +# 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/CONTRIBUTING.md b/CONTRIBUTING.md index c4c1eb35..63333e3c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,6 +133,58 @@ 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="--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, 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: + +```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 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 \ + 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..ea87c47f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -146,6 +146,73 @@ 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 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 + 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 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-.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. 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@$digest" +``` + ### Security Updates Security updates will be released as: diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 432a6418..c2d6d455 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -532,3 +532,91 @@ 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. Lettered `C` (supply **C**hain) rather than `S`: the SQL +statement-reading section above already owns `S1`-`S8`. + +### 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 +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. + +### 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 +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. + +### 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 +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. + +### 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 +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. + +### 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 +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. + +### 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. + +### 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 +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/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/gitleaks-config.test.ts b/tests/unit/gitleaks-config.test.ts new file mode 100644 index 00000000..7f148b92 --- /dev/null +++ b/tests/unit/gitleaks-config.test.ts @@ -0,0 +1,124 @@ +/** + * Threat: an allowlist that hides a real credential. + * + * 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. + */ +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("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", () => { + 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("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 + // look present while doing nothing. + for (const entry of allowlists) { + if ((entry.regexes ?? []).length > 0) { + expect(entry.regexTarget).toBe("secret"); + } + } + }); +}); diff --git a/tests/unit/gitleaksignore.test.ts b/tests/unit/gitleaksignore.test.ts new file mode 100644 index 00000000..2a016ee9 --- /dev/null +++ b/tests/unit/gitleaksignore.test.ts @@ -0,0 +1,72 @@ +/** + * 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"); + +// 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("#")); + +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/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(); + }); +}); 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..a1b0c4a0 --- /dev/null +++ b/tests/unit/release-sbom.test.ts @@ -0,0 +1,179 @@ +/** + * 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; + id?: 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 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 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")); + +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("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/); + }); + + 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 '.'", () => { + 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("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 ?? ""); + 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"); + }); + + 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"'); + }); +}); diff --git a/tests/unit/security-scan-workflow.test.ts b/tests/unit/security-scan-workflow.test.ts new file mode 100644 index 00000000..39e2c440 --- /dev/null +++ b/tests/unit/security-scan-workflow.test.ts @@ -0,0 +1,480 @@ +/** + * 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}$/; + +/** + * 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; +} + +/** + * 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 + // 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("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"); + }); + + 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("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 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, + }); + } + } + // A helper that silently found nothing to check would make every + // iteration above vacuous. + 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; + 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, 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("--diff-merges=first-parent"); + expect(resolver?.run).toContain("pull_request"); + 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"); + 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="--diff-merges=first-parent \$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 + // 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("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( + names.indexOf("Scan for committed secrets"), + ); + }); + + 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(); + }); +}); + +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"); + 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(); + 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("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. + // + // `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("failure() && 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"); + }); + + 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"); + }); +}); + +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", () => { + 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(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", () => { + // 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"); + }); +}); diff --git a/tests/unit/trivyignore-policy.test.ts b/tests/unit/trivyignore-policy.test.ts new file mode 100644 index 00000000..fa4c257b --- /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).toHaveLength(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); + } + }); +});