Skip to content

security: scan the supply chain, publish an SBOM, and type-check the build (Phase 2) - #322

Merged
cevheri merged 13 commits into
mainfrom
security/phase-2-supply-chain
Aug 9, 2026
Merged

security: scan the supply chain, publish an SBOM, and type-check the build (Phase 2)#322
cevheri merged 13 commits into
mainfrom
security/phase-2-supply-chain

Conversation

@cevheri

@cevheri cevheri commented Aug 9, 2026

Copy link
Copy Markdown
Member

Phase 2 of the security maturity programme: the supply chain.

Stacked after Phase 0 (#320) and Phase 1 (#321), both merged. Phase 3 — credential encryption at rest and a published security posture — follows on its own branch and carries the version bump to 0.10.0.

What this adds

Secret scanning .gitleaks.toml and a fingerprint allowlist, plus an incremental scan that fails a pull request, and a full-history sweep on main and a schedule
Dependency scanning Reports on pull requests; gates on main only, and only on a CRITICAL advisory that has a fix and is not suppressed
Image scanning Reports daily. Never gates — see below
Release SBOM CycloneDX 1.7, attested, required by name before a draft release is published
Type-checked builds typescript.ignoreBuildErrors removed, with a guard test

The secret-scanning result

The sweep was run against this repository's real history before any configuration was written: 753 commits, 24 matches, every one read and classified as fabricated — jwt.io's published sample token, hex filler, StrongPass123 documentation examples, PEM bodies whose content literally reads fake, and $$COUCHBASE_USER:$$COUCHBASE_PASSWORD compose interpolation.

No credential has ever been committed to this repository, and no rotation is required.

Each of those 24 is suppressed by its fingerprint — the exact commit:file:rule:startline gitleaks reports — in .gitleaksignore. A fingerprint suppresses one already-classified historical occurrence and nothing else: a real secret added later in the same file, or one matching the same fabricated literal in a new commit, produces a different fingerprint and is still reported. .gitleaks.toml carries no allowlist entries at all, and a paths-based one is now rejected outright by its test.

This is the second design. The first used four rule-scoped paths = ^tests/ allowlists, and review caught what that costs: it would have covered every future generic-credential, JWT and private-key finding anywhere under tests/, not only the fabricated fixtures it was written for. That gap was not argued about but reproduced — a real-shaped secret planted in a brand-new file under tests/ was silently swallowed by the old configuration, and is reported by the new one. The sweep still scans clean across the full history, now 774 commits.

Worth recording plainly, because this pull request's own text asserted the principle and the implementation then broke it: a broad path exclusion that happens to cover today's findings will cover tomorrow's real one. Stating a rule is not the same as enforcing it, which is why the test now rejects the shape rather than trusting the comment.

What gates and what does not

Only one thing in this pull request can fail a merge that could not before: the production build now type-checks, because typescript.ignoreBuildErrors is gone.

Of the scanners:

  • Secret scan fails loudly on every pull request including from forks, and needs no secrets and no registry credentials to do it. It is not yet a required check in branch protection — promoting it is tracked as BACKLOG C1 — so today a failed run is visible on the pull request but does not by itself stop a maintainer from merging.
  • Dependency scan reports on pull requests and never fails them. It gates only outside pull requests, and only on a CRITICAL finding that has a fixed version and is not suppressed.
  • Image scan never gates, and that is deliberate. The runtime base image carries 172 findings of which exactly one is fixable — the rest are Debian base-layer advisories with no patched version. Gating on findings nobody can act on produces a permanently red check, and a permanently red check is how a gate becomes a continue-on-error line. The scan exists for trend and evidence, not enforcement.

Suppressions live in .trivyignore.yaml and must carry a written statement and an expiry no more than 90 days out. An expired entry is re-reported — verified, not assumed — and a policy test enforces both requirements.

The SBOM, and what it does not cover

Every release from the next tag onward carries libredb-studio-<version>.cdx.json: CycloneDX 1.7, 755 components, attested, built from the three ecosystem inputs — bun.lock, desktop/src-tauri/Cargo.lock and the launcher's go.mod.

It describes the dependency closure of the npm package, the tarballs, the Windows zip, the deb and rpm, the snap, the AppImage and the desktop package. It does not describe the pinned Node runtime those artefacts bundle, which appears in no lockfile, and it is not the container image's SBOM.

The image cannot have a release asset: release-artifacts.yml publishes the release before it dispatches the image build, and releases here are immutable. The image SBOM is regenerable from any immutable digest with the command documented in SECURITY.md.

publish-release now requires the SBOM asset by name before flipping the draft to published. That is deliberate: a release without its bill of materials should not exist.

The Next.js advisories this branch deferred, and no longer defers

This section originally deferred the next advisories to a separate pull request, on the grounds that a Next minor can change middleware and CSP behaviour — exactly the surface Phase 1 verified in a real browser. That happened: #323 landed the bump to next 16.3.0 and this branch is rebased onto it, so the deferral is resolved rather than outstanding, and BACKLOG C7 has been deleted (the later entries renumbered).

Two claims in the original wording were also wrong, and are corrected here rather than quietly deleted.

The middleware bypasses were counted as three. Resolved against the GitHub advisory database, five high-severity Middleware/Proxy bypass advisories affect next@16.1.6, all published before this branch existed — so the count was wrong when written, not overtaken by a later disclosure. Of the five, CVE-2026-44573 (GHSA-36qx-fr4f-26g5) is scoped to the Pages Router with i18n and does not apply here: this application has no pages/ directory. The four that do apply are CVE-2026-44574, CVE-2026-44575, CVE-2026-45109 and CVE-2026-64642.

CVE-2026-45109 is the one worth naming, because the original text did not have it: it is the incomplete-fix follow-up scoped to middleware, and src/proxy.ts is Next 16's middleware — the single file Phase 1 put RBAC, the Origin check, rate limiting, security-header delivery and audit emission into.

next@16.3.0 carries zero advisories, so the bump closes all of them regardless of how they were counted.

For contributors

  • The secret scan can fail your pull request. It reads only the commits your branch adds; the one-line local reproduction is in CONTRIBUTING.md.
  • A real match is an incident: rotate first, never rewrite history. A fabricated match gets a fingerprint in .gitleaksignore — the exact commit:file:rule:startline gitleaks reports — which suppresses that one classified occurrence and nothing else. .gitleaks.toml carries no allowlist entries at all today, and paths-based ones are rejected outright by tests/unit/gitleaks-config.test.ts.
  • The dependency scan reports and will not fail your pull request. bun audit is the local zero-container view, but it carries no fixed-version data, so it cannot tell you whether a fix exists.
  • SARIF upload is skipped on fork pull requests by design — a token limitation, not a rejection. Your findings are in the job summary rather than the Security tab.

Verification

CI is green at 2c90972, the head of this branch after the rebase onto main: 18 checks pass and one is skipped. Among the 18 are Unit & Integration Tests, which is where the 100 percent line-coverage gate lives, and Lint, Typecheck and Build. The skipped one is Image Scan, which does not run on a pull request by design.

The ten local gates were run in the foreground before each push and were green: format, lint (0 errors, 76 pre-existing warnings), typecheck, knip, test (23/23 groups), build, test:coverage + coverage:check (100.00 percent), build:lib + attw, readme:check, and chart:check (chart 0.1.30, appVersion 0.9.67, in sync).

And the phase's own claims were re-run rather than restated: the full-history sweep found zero findings over 694 commits; the CRITICAL gate exited 0 while the same scan one severity lower exited 1 with 17 findings, proving the scanner is live rather than silently vacuous; and the SBOM regenerated at 755 components across three ecosystems.

A note on how this phase was reviewed

The branch review's structural finding was that three of the four things it could break were guards, not scanners — the mechanisms protecting the scanners' configuration rather than the scanners themselves. The secret scan exited 0 when a commit range failed to resolve. The container-tag guard stayed green when every image was switched to a mutable tag. The suppression policy's statement-and-expiry rules had no mechanical backing at all.

All three are fixed, and each fix was proved by sabotage: reverting it turns a specific test red. That habit is now the phase's standard — a control is not delivered until something fails when it is broken.

@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Phase 2 supply-chain security controls, release SBOM generation, and production build type-checking.

Changes:

  • Adds secret, dependency, and container-image scanning workflows.
  • Generates, attests, and requires a CycloneDX release SBOM.
  • Enables Next.js build type-checking with policy tests and documentation.

Reviewed changes

Copilot reviewed 14 out of 15 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
.github/workflows/security-scan.yml Adds supply-chain scanning jobs.
.github/workflows/release-artifacts.yml Adds the release SBOM gate.
.gitleaks.toml Configures secret-scan allowlists.
.trivyignore.yaml Defines vulnerability-suppression policy.
.gitignore Ignores local scanner outputs.
next.config.ts Enables build-time type-checking.
SECURITY.md Documents supply-chain controls and SBOMs.
CONTRIBUTING.md Documents local scanner usage.
docs/BACKLOG.md Records deferred security work.
tests/unit/gitleaks-config.test.ts Tests Gitleaks policy invariants.
tests/unit/trivyignore-policy.test.ts Tests suppression requirements.
tests/unit/security-scan-workflow.test.ts Tests scan-workflow invariants.
tests/unit/release-sbom.test.ts Tests SBOM release wiring.
tests/unit/release-provenance.test.ts Requires SBOM attestation.
tests/unit/next-config-typecheck.test.ts Guards build type-checking.
Suppressed comments (3)

.gitleaks.toml:44

  • This regex allows every future StrongPass value followed by digits anywhere in the repository, rather than only the documented examples that produced the historical findings. Enumerate the exact known literals so a newly committed credential such as StrongPass999 is still reported.
regexes = ['''^StrongPass\d+$''']

.gitleaks.toml:53

  • Scoping by file still hides any real private key later committed to ConnectionModal.tsx; it does not limit the suppression to the two placeholder strings. Remove the PEM-shaped placeholder match or suppress the exact extracted placeholder values instead.
paths = ['''^src/components/ConnectionModal\.tsx$''']

.gitleaks.toml:62

  • This path exemption hides any future real curl -u credential in the compose file, not only the intended Compose-variable placeholder. Narrow the suppression to the exact known finding (for example with a match-level regex or fingerprint) so other basic-auth credentials remain reportable.
paths = ['''^database-compose\.yml$''']

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .gitleaks.toml Outdated
Comment thread .github/workflows/security-scan.yml Outdated
Comment thread CONTRIBUTING.md Outdated
Comment thread .github/workflows/security-scan.yml
Comment thread SECURITY.md Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 15 changed files in this pull request and generated no new comments.

Suppressed comments (9)

.gitleaks.toml:34

  • This path allowlist suppresses the generic-api-key, jwt, and private-key rules for every future file under tests/. A real non-provider JWT, private key, or generic credential accidentally committed as a fixture would therefore pass the new blocking scan. Scope these exceptions to the exact 13 historical fabricated values/locations rather than the entire test tree, and regression-test with a generic secret as well as a provider-specific token.
targetRules = ["generic-api-key", "jwt", "private-key"]
paths = ['''^tests/''']

.gitleaks.toml:53

  • Allowlisting this whole source file for private-key means a real key later pasted into ConnectionModal.tsx is invisible to the blocking scan. Match only the two placeholder findings (by exact value/line) so unrelated private-key findings in this file remain actionable.
targetRules = ["private-key"]
paths = ['''^src/components/ConnectionModal\.tsx$''']

.gitleaks.toml:62

  • This exempts every future curl-auth-user finding in database-compose.yml, not only the Compose interpolation described above it. A newly hard-coded curl -u user:password credential in this file would pass. Allowlist the exact interpolation value instead of the whole file.
targetRules = ["curl-auth-user"]
paths = ['''^database-compose\.yml$''']

.github/workflows/security-scan.yml:108

  • --no-merges drops conflict-resolution additions made in a merge commit. Such content can exist in neither parent, so this removes the only patch containing it while commit_count can remain nonzero from other commits; Gitleaks then reports clean. Preserve combined merge diffs (or add a current-tree scan) while avoiding the duplicated parent-side changes this comment is concerned about, and update the workflow guard test accordingly.
            range="--no-merges $BASE_SHA..$HEAD_SHA"

SECURITY.md:209

  • The text promises regeneration from an immutable digest, but the command uses a mutable tag. Retagging can make this produce an SBOM for different bytes than the intended release. Show a digest-qualified reference (ghcr.io/libredb/libredb-studio@sha256:…) instead.
trivy image --format cyclonedx --scanners license \
  --output libredb-studio-image.cdx.json \
  ghcr.io/libredb/libredb-studio:0.9.67

.gitleaks.toml:45

  • ^StrongPass\d+$ is a pattern family, not an allowlist of the historical examples: it silently suppresses any future StrongPass plus digits credential in any file. Enumerate the exact fabricated literals found by the sweep so a new value still fails the scan.
targetRules = ["generic-api-key"]
regexes = ['''^StrongPass\d+$''']
regexTarget = "secret"

.github/workflows/security-scan.yml:445

  • The vulnerability report and image SBOM resolve mutable :latest in separate Trivy invocations. If a release retags latest between them, this run uploads a report and SBOM for different images and records neither shared identity. Resolve latest to one digest once, then pass that digest-qualified reference to both scans (and include it in the summary/artifact metadata).
            "$TRIVY_IMAGE" image \
              --scanners vuln \
              --format json \
              --output /out/image.json \
              ghcr.io/libredb/libredb-studio:latest

.github/workflows/release-artifacts.yml:616

  • Only changing the root component's name leaves its version empty. CycloneDX consumers such as Dependency-Track key projects by name/version, so successive release SBOMs can collapse into the same unversioned libredb-studio project. Set metadata.component.version from VERSION and verify both root fields before attesting.
            if (doc.metadata && doc.metadata.component) {
              doc.metadata.component.name = "libredb-studio";
            }

tests/unit/trivyignore-policy.test.ts:47

  • This assertion enforces that suppressions remain empty even though the comment and .trivyignore.yaml explicitly permit a justified, expiring suppression. The first legitimate entry will fail here before the policy checks below can validate it. Remove this current-state assertion and leave the per-entry policy tests as the durable contract.
  test("is empty today - the CRITICAL/fixable threshold covers everything else", () => {
    // Not a requirement that this file must stay empty forever. A record of
    // the current, expected state, so a reader knows the loops below are
    // vacuous by design rather than by accident.
    expect(vulnerabilities.length).toBe(0);

cevheri added a commit that referenced this pull request Aug 9, 2026
…anner gaps

SonarCloud (typescript:S5906): tests/unit/trivyignore-policy.test.ts's
length assertion now reports the actual length on failure.

Copilot review of #322, five findings, all fixed:

- .gitleaks.toml's four path-scoped allowlists exempted every future
  generic-api-key/jwt/private-key/curl-auth-user finding under their path,
  not just the 24 historical fabricated ones. Replaced with exact
  commit:file:rule:startline fingerprints in a new .gitleaksignore (the
  actual gitleaks 8.30.1 mechanism for this - its [[allowlists]] TOML
  schema has no fingerprint field). Verified live: a real-shaped private
  key and generic API key planted under tests/ are now reported; the full
  history still scans clean.
- security-scan.yml's --no-merges left a secret introduced only while
  resolving a merge conflict unscanned in both parents. Switched the
  pull_request range to --diff-merges=first-parent, which surfaces that
  resolution without re-flooding with the other side's unrelated history.
  Verified live against a synthetic conflict-resolution commit.
- The pull request and the workflow's own section header used 'blocks' for
  a check that is not yet a required branch-protection check. Reworded the
  workflow header; PR body wording proposed separately.
- SECURITY.md's image-SBOM regeneration command used a mutable tag while
  the prose promised an immutable digest. Resolves the digest first.
- CONTRIBUTING.md and SECURITY.md called bun.lock, Cargo.lock and go.mod
  'three non-npm lockfiles' - bun.lock is the npm lockfile and go.mod is a
  manifest, not a lockfile. Named the three ecosystems directly.

tests/unit/gitleaks-config.test.ts rewritten for the new split (fingerprint
classification moved out; the file now guards the shape of any future
value-scoped allowlist). New tests/unit/gitleaksignore.test.ts guards the
fingerprint file's shape and count.
Comment thread tests/unit/gitleaksignore.test.ts Fixed
cevheri added 12 commits August 10, 2026 01:10
…s errors

Remove typescript.ignoreBuildErrors from next.config.ts (control 2.3).

Measured before deleting: with the flag off, `bun run build` exits 0
today (zero hidden errors), and a deliberately injected type error in
src/lib/security/headers.ts does fail the build with the expected
"Type error: Type 'string' is not assignable to type 'number'" - so
the check the flag was suppressing is real, not vacuous.

What this buys is narrow, not "catches type errors in general" - both
`bun run typecheck` and the build read the same tsconfig.json, so most
errors were already caught by the required typecheck gate. The one gap
is that `next build` regenerates .next/types/validator.ts from the
current route tree before checking, while `tsc --noEmit` reads
whatever the last build left on disk. A route added, renamed or
removed can leave typecheck passing against stale generated types
while the build's check would not. Removing the flag closes that
window.

Add tests/unit/next-config-typecheck.test.ts to guard against the
flag being restored - it is a two-line block that looks harmless to
re-add "just to see the build finish."
…se 2 deferrals

Resyncs the operator Helm mirror (bun run chart:bump, no version change):
this branch forked before phase-1-hardening's own resync commit, so
chart:check failed on the pre-existing README/NOTES drift before any
Phase 2 edit.
The review's structural finding: three of the four things it could break
were guards, not scanners. Apply the same discipline to the YAML that
protects the scanners' own configuration.

- secret-scan resolves the commit range with `git rev-list --count` under
  `set -e`, so an unresolvable range (unreachable base.sha, shallow
  checkout, `.git` as a file) fails loudly instead of reaching gitleaks,
  which logs that same failure at ERROR and still exits 0. A new step
  asserts the scanned count is non-zero on a pull request.
- the "never runs a container by tag" test matched only the physical
  `docker run --rm \` line; every image reference sits on a continuation
  line, so substituting a mutable tag for $TRIVY_IMAGE/$GITLEAKS_IMAGE
  stayed green. Replaced with a positive, whole-block assertion.
- added tests/unit/trivyignore-policy.test.ts: mechanical backing for the
  suppression policy .trivyignore.yaml's own header and SECURITY.md
  promise (a statement of meaningful length, an expired_at that parses
  and is no more than 90 days out).
- keyed the security-scan concurrency group by event as well as ref, so a
  push to main can no longer cancel the daily cron's image scan (same
  ref, different event).
- the dependency gate's failure explainer now checks the gate step's own
  outcome instead of a bare `if: failure()`, so a Trivy DB timeout or a
  bun install flake no longer gets told a CRITICAL advisory is present.
- the release sbom job checks for Docker Hub credentials and logs in when
  configured, and retries its trivy pull three times - it now sits on the
  release path (publish-release needs it) and had no retry.
- the sbom's root component is renamed away from Trivy's default ".".
- SECURITY.md now says the SBOM covers "the dependency closure of" the
  packaged artefacts, not the artefacts themselves, and names the bundled
  Node.js runtime (packaging/*/fetch-node.sh) as an undescribed gap.
- docs/BACKLOG.md: relettered the Phase 2 deferrals S1-S7 to C1-C7 (the
  SQL section already owns S1-S8), amended C7 to name the three Next.js
  middleware/authorization-bypass CVEs against src/proxy.ts and record
  the 0.10.0 decision, and added C8 for the Node-runtime SBOM gap.
- minor corrections: the gh attestation verify example now uses the
  <version> placeholder; the security-scan.yml comment blaming GHCR rate
  limits for DB downloads now names Trivy's actual mirror.gcr.io source;
  .gitignore no longer points at CONTRIBUTING.md commands that write no
  report files; the publish-release comment no longer claims
  release:published fires npm-publish/docker-build-push directly; added
  a continue-on-error absence test for dependency-scan.

Every guard touched here was sabotaged and confirmed red before being
reverted; see .superpowers/sdd/2026-08-09-security-phase-2/fix-wave-report.md
for the full record.
… blind spots

Response to the re-review of be5754d.

- security-scan.yml: `if: steps.gate.outcome == 'failure'` has no status-check
  function, so GitHub Actions implicitly prepends `success() &&` - which is
  already false once the gate step has failed, so the explainer could never
  fire, including on a genuine CRITICAL-advisory failure on main. Fixed to
  `if: failure() && steps.gate.outcome == 'failure'`.
- security-scan.yml: log_opts and commit_count were two independent
  reconstructions of "$BASE_SHA..$HEAD_SHA"; a sabotage touching only
  log_opts could leave commit_count non-zero while the scanner read a
  different, reversed range. Refactored to a single `range=` assignment that
  both outputs are derived from.
- tests/unit/security-scan-workflow.test.ts: the pinned-image guard checked
  whether $TRIVY_IMAGE/$GITLEAKS_IMAGE occurred anywhere in a docker run
  block, so a hardcoded tag plus an unused decoy reference to the variable
  still read as pinned. Replaced with `imageArgument()`, which finds the
  token docker actually reads as the image (the first non-flag token after
  `docker run --rm \`) and checks that specifically.
- added a general test asserting any step conditioned on another step's
  outcome begins with an explicit status function - the same class of bug
  as the explainer regression, not a patch for that one line.
- added the requested one-sentence comment noting that push/schedule/
  workflow_dispatch have no non-zero-scan backstop by decision (--all has no
  equivalent range to miscount), and a pointer to
  tests/unit/trivyignore-policy.test.ts from both .trivyignore.yaml's header
  and the dependency gate's failure explainer.

Every guard touched was sabotaged and confirmed red before being reverted;
see .superpowers/sdd/2026-08-09-security-phase-2/fix-wave-report.md (round 2
section) for the full record.
…anner gaps

SonarCloud (typescript:S5906): tests/unit/trivyignore-policy.test.ts's
length assertion now reports the actual length on failure.

Copilot review of #322, five findings, all fixed:

- .gitleaks.toml's four path-scoped allowlists exempted every future
  generic-api-key/jwt/private-key/curl-auth-user finding under their path,
  not just the 24 historical fabricated ones. Replaced with exact
  commit:file:rule:startline fingerprints in a new .gitleaksignore (the
  actual gitleaks 8.30.1 mechanism for this - its [[allowlists]] TOML
  schema has no fingerprint field). Verified live: a real-shaped private
  key and generic API key planted under tests/ are now reported; the full
  history still scans clean.
- security-scan.yml's --no-merges left a secret introduced only while
  resolving a merge conflict unscanned in both parents. Switched the
  pull_request range to --diff-merges=first-parent, which surfaces that
  resolution without re-flooding with the other side's unrelated history.
  Verified live against a synthetic conflict-resolution commit.
- The pull request and the workflow's own section header used 'blocks' for
  a check that is not yet a required branch-protection check. Reworded the
  workflow header; PR body wording proposed separately.
- SECURITY.md's image-SBOM regeneration command used a mutable tag while
  the prose promised an immutable digest. Resolves the digest first.
- CONTRIBUTING.md and SECURITY.md called bun.lock, Cargo.lock and go.mod
  'three non-npm lockfiles' - bun.lock is the npm lockfile and go.mod is a
  manifest, not a lockfile. Named the three ecosystems directly.

tests/unit/gitleaks-config.test.ts rewritten for the new split (fingerprint
classification moved out; the file now guards the shape of any future
value-scoped allowlist). New tests/unit/gitleaksignore.test.ts guards the
fingerprint file's shape and count.
…ose two more scanner gaps

CodeQL (js/redos): tests/unit/gitleaksignore.test.ts's FINGERPRINT regex had
a redundant, ambiguous slash-delimited group - '[^:\\s]+' already matches
'/', so '(?:\\/[^:\\s]+)*' let the same character be consumed by either
alternative, exponential backtracking on a long run of '/'. Removed the
redundant group; the colon delimiters between fingerprint fields already
make one '[^:\\s]+' per segment unambiguous.

docs/BACKLOG.md C7: resolved all five 'Middleware / Proxy bypass' GHSAs
against the next advisories affecting next@16.1.6 against the GitHub
advisory database directly rather than trusting a copied CVE number.
CVE-2026-44573 (GHSA-36qx-fr4f-26g5) was cited as one of the three that
apply here; it does not - it is a Pages Router + i18n advisory and this
app has no pages/ directory. The one actually missing was CVE-2026-45109
(GHSA-26hh-7cqf-hhc6), an incomplete-fix follow-up scoped to middleware.ts.
Also verified CVE-2026-64642 (GHSA-6gpp-xcg3-4w24, Turbopack + single-locale
i18n) does not apply - this app has no i18n config. Recorded the full table
with fixed versions so a future reader does not need to re-derive it.

security-scan.yml: added a direct 'git rev-parse --is-shallow-repository'
guard before the --all full-history scan. That scan skips the pull-request
range's non-zero-commit assertion because there is no equivalent range to
miscount, but a shallow checkout would make --all scan a truncated slice
and report 'no leaks found' indistinguishable from a real clean scan.
Sabotage-verified: removing the guard makes its test fail.

image-scan: the vuln scan and the SBOM generation each independently
resolved the mutable ':latest' tag in separate docker run invocations
minutes apart; a release retagging ':latest' mid-run would make them
describe two different images with no shared identity. Resolved the
digest once and reused it for both.

release-artifacts.yml: the SBOM root-component rename set 'name' but left
'version' empty, so a CycloneDX consumer that keys a project by
name+version would collapse every release's SBOM into the same
unversioned project. Now sets both from the release version.

tests/unit/security-scan-workflow.test.ts and tests/unit/release-sbom.test.ts
updated for the new steps; tests/unit/gitleaksignore.test.ts unchanged in
shape, just its regex.
The rebase onto main pulled in #323 (next 16.1.6 -> 16.3.0), which is
exactly the deferred work C7 described. All three applicable middleware-
bypass advisories (CVE-2026-44575, CVE-2026-45109, CVE-2026-44574) are
fixed by 16.2.5/16.2.6, both below the now-locked 16.3.0 - verified
against bun.lock. Deleted the entry per this file's own convention
(delete an entry when the work lands) rather than leave a corrected-but-
already-resolved decision record; #323's own post-merge correction note
carries the CVE/GHSA mapping history. Renumbered the former C8 to C7.
@cevheri

cevheri commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Follow-up round covering findings not tied to a postable review thread, plus a few items surfaced independently.

Fixed, from Copilot's second review (suppressed comments, no thread to reply on):

  • security-scan.yml image-scan job: the vulnerability scan and the SBOM generation each resolved the mutable :latest tag independently, in separate docker run invocations minutes apart. A release retagging :latest between them could make the two describe different images with no shared identity. Now resolves the digest once (docker buildx imagetools inspect) and reuses it for both.
  • release-artifacts.yml's SBOM root-component patch set metadata.component.name but left metadata.component.version empty. A CycloneDX consumer that keys a project by name+version would collapse every release's SBOM into the same unversioned project. Now sets both from the release version.

Declined, with reasons:

  • tests/unit/trivyignore-policy.test.ts:47 ("is empty today"). This is not a permanent constraint on the file - the test's own comment says so ("Not a requirement that this file must stay empty forever. A record of the current, expected state..."). It is a deliberate trip-wire: the first legitimate suppression is expected to touch this specific assertion in the same PR that adds it, so a reviewer sees that the vacuous-by-design premise changed rather than trusting it silently. Removing it would weaken that signal for a hypothetical inconvenience. Left as is; the per-entry policy tests below it are already the durable contract, as intended.
  • Scanner digest versus its version comment (# gitleaks v8.30.1. above a pinned digest, no test that the comment matches). The digest is what actually runs; the comment is a human hint. A desync misleads a reader but cannot weaken the pin itself. Verifying it mechanically needs a registry call at test time, which is exactly the kind of external trust this digest-pinning approach exists to avoid.
  • SBOM could be "partially corrupt" and still pass verification. The verify step already asserts five properties: valid JSON, bomFormat is CycloneDX, at least 100 components, all three lockfiles present by name, and licence data non-empty. Declining absent a concrete corruption shape that survives all five.
  • imageArgument() needs a comment warning future editors about its one-flag-per-line assumption. It already has one, directly above the function.
  • image-scan not running on pull requests, and the --all full-history scan choice for the recurring job: both already documented with their reasoning in the workflow's own header comment. No change.
  • SECURITY.md documents unencrypted credentials at rest. Correct and deliberate - that is Phase 3's scope. An accurate disclosure of a real, current exposure is the document doing its job, not a defect in this one.

Also fixed this round:

  • Added a direct git rev-parse --is-shallow-repository guard before the --all full-history scan. That path skips the pull-request range's non-zero-commit assertion because there is no equivalent range to miscount, but a shallow checkout would make --all scan a truncated slice of history and report "no leaks found" indistinguishable from a real clean scan. Sabotage-verified: removing the guard makes its test fail.
  • CodeQL (js/redos, high): the new .gitleaksignore fingerprint-shape regex had a redundant, ambiguous slash-delimited group. Fixed - replied on the code-scanning thread with detail.
  • docs/BACKLOG.md C7: resolved every "Middleware / Proxy bypass" GHSA affecting next@16.1.6 against the GitHub advisory database directly. The entry had cited CVE-2026-44573 as one of three that apply here; it does not (Pages Router + i18n only, and this app has no pages/ directory) - the one actually missing was CVE-2026-45109. Also confirmed CVE-2026-64642 does not apply (requires a single-entry i18n.locales config; this app has none). Since the rebase onto main pulled in fix(deps): raise the next floor past the three middleware-bypass advisories #323's next 16.1.6 → 16.3.0 bump - the exact work C7 was tracking - the entry is now resolved and has been deleted per this file's own convention, rather than left as a corrected-but-already-done decision record.

All of the above is on top of the rebase onto main (post-#323), fingerprint-based .gitleaks.toml/.gitleaksignore split, and --diff-merges=first-parent fix from the earlier round.

@cevheri
cevheri force-pushed the security/phase-2-supply-chain branch from 4228c46 to 2c90972 Compare August 9, 2026 22:23
@cevheri
cevheri requested a balanced review from Copilot August 9, 2026 22:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 17 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

SECURITY.md:173

  • The historical classifications now live as exact fingerprints in .gitleaksignore; .gitleaks.toml only enables the default rules and contains no classifications. Pointing readers to the TOML file sends them to the wrong audit record.
  reason. The classification is `.gitleaks.toml`, and the full sweep runs again on

.github/workflows/security-scan.yml:122

  • For a branch-update merge (feature merges current main), the feature branch is the first parent, so this emits the merge result versus the old feature tip—including every newly merged main change. Those base changes are therefore rescanned under the merge commit and can produce new fingerprints for already-classified findings, failing an unrelated pull request. Use a combined merge diff (--cc/--diff-merges=combined) that exposes conflict-resolution-only additions, and cover the branch-update case while keeping the local command and guard test synchronized.
            range="--diff-merges=first-parent $BASE_SHA..$HEAD_SHA"

Comment thread .github/workflows/release-artifacts.yml
… user, not root

Copilot found a real defect that would have broken the first 0.10.0
release attempt: aquasec/trivy runs as root by default, so the SBOM it
writes into the bind-mounted workspace lands on the host owned by
root, mode 644. The very next step patches that same file in place
with Node's fs.writeFileSync, which for an existing file opens it for
write - permission a non-root runner does not have on a root-owned
file.

release-artifacts.yml never runs on a pull request, so none of this
branch's 18 green checks exercised this path; the first execution
would have been the real release, and a failed release burns a patch
version rather than retrying the same tag.

Reproduced against the real pinned image and real repository lockfiles
(bun.lock, Cargo.lock, go.mod): the unpatched command produces a
root-owned SBOM, and the exact 'Name and version the SBOM's root
component' step's fs.writeFileSync then fails EACCES. Fixed with
--user "$(id -u):$(id -g)" on the Trivy invocation - this scan uses
--scanners license only, so it needs no vulnerability-DB cache and has
no root-owned-cache fallout to work around, unlike the /root/.cache/
trivy-mounted scanners elsewhere in this repository. Verified
end-to-end by extracting the actual committed step scripts from the
YAML and running them in sequence against the real image: output is
now owned by the invoking user, and the patch step succeeds and sets
name and version correctly.

Comment explains why the other two shapes from review were not taken:
sudo chown is reactive and assumes passwordless sudo a self-hosted
runner may not grant; a write-sibling-then-rename in the Node step
needs no privilege either, but moves the fix somewhere a future editor
could 'simplify' back into an in-place write without realising a
permission fix depends on it.

Audited all docker run invocations across security-scan.yml and
release-artifacts.yml for the same class of defect (output written by
a container later modified by a host-side step). This is the only one:
every other scanner's output is either uploaded/cat'd read-only
downstream, rewritten by another container rather than the host, or
produces no file at all.

tests/unit/release-sbom.test.ts: new guard test, matching the flag
immediately after 'docker run --rm' rather than a bare substring -
this step's own comment also contains the literal flag text in prose,
so a substring check alone would still pass with the flag removed from
the actual command. Sabotage-verified: removing the flag from the
command (leaving the comment) fails the test; restoring it passes.
@sonarqubecloud

sonarqubecloud Bot commented Aug 9, 2026

Copy link
Copy Markdown

@cevheri
cevheri requested a balanced review from Copilot August 9, 2026 23:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 17 changed files in this pull request and generated no new comments.

Suppressed comments (5)

SECURITY.md:173

  • This points readers to the wrong file: .gitleaks.toml contains no classifications; the 24 classified findings are the exact fingerprints and explanations in .gitleaksignore. Name that file so the security documentation matches the implemented suppression mechanism.
  reason. The classification is `.gitleaks.toml`, and the full sweep runs again on

.github/workflows/release-artifacts.yml:27

  • go.mod is a Go module manifest, not a lockfile. Calling all three inputs lockfiles repeats the terminology error already corrected in CONTRIBUTING.md and SECURITY.md; describe them as ecosystem/dependency inputs instead.
# 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

.github/workflows/release-artifacts.yml:503

  • This second comment again calls go.mod a lockfile and says the inputs themselves "are the closure." They are manifests/lockfiles from which Trivy derives an inventory. Reword this to avoid documenting the same inaccurate model beside the release-critical implementation.
    # 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.

tests/unit/trivyignore-policy.test.ts:16

  • “non-empty” contradicts both this paragraph and gitleaks-config.test.ts, which assert that the allowlist is empty today.
 * 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

SECURITY.md:185

  • This is not a production-only dependency closure. The scanned bun.lock explicitly contains devDependencies (including @types/node, which this document later acknowledges is present), and the workflow performs a full bun install before scanning. Describe it as the repository's resolved dependency inventory, or generate a production-only SBOM, so consumers do not mistake build/test packages for shipped runtime dependencies; update the wording quoted in docs/BACKLOG.md at the same time.
Every release carries `libredb-studio-<version>.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

@cevheri
cevheri merged commit 4544301 into main Aug 9, 2026
20 checks passed
@cevheri
cevheri deleted the security/phase-2-supply-chain branch August 9, 2026 23:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants