feat(security): add SBOM attestations, VEX document, and retire .trivyignore (#790)

* feat(security): add SBOM attestations, VEX document, and retire .trivyignore

Add OpenVEX triage document (security/vex/sencho.openvex.json) for the 5
residual CVEs vendored inside docker/compose v5.1.2 that were carried over
from the previous PR. All 5 are marked not_affected with justifications.

Configure Trivy in both CI and release workflows to consume the VEX document
via trivy.yaml so the same source of truth gates PR scans and release scans.
Delete .trivyignore, which is fully superseded by the VEX file.

Add two new release pipeline steps after image publication:
- CycloneDX 1.6 SBOM via anchore/sbom-action (also installs syft)
- SPDX 2.3 SBOM via syft directly (reuses OCI layer cache from prior step)
Both are attached as cosign OCI referrer attestations (keyless, OIDC-signed)
and uploaded as GitHub Release assets alongside the OpenVEX file.

Bump docker-publish.yml permissions from contents:read to contents:write,
required for softprops/action-gh-release to create Release assets.

Add docs/operations/verifying-images.mdx with copy-paste verification
commands for all supply-chain artifacts: signature, SLSA provenance,
CycloneDX SBOM, SPDX SBOM, OpenVEX, and Rekor entry. Update docs.json
navigation and expand the Supply chain security section in docs/security.mdx.
Add a Verifying Release Artifacts section to SECURITY.md.

* fix(vex): cover otel SDK CVE-2026-39883 in rebuilt Docker CLI binary

The rebuilt Docker CLI v29.4.0 vendors otel/sdk v1.42.0, which still
contains CVE-2026-39883 (BSD kenv PATH hijacking; fixed in v1.43.0).
docker-compose v5.1.2 vendors otel/sdk v1.38.0 separately. The original
VEX statement only covered the compose binary's location and version,
so Trivy's scan of /usr/local/bin/docker was not suppressed.

Add a second subcomponent entry for the CLI binary path with the
correct vendored version. The not_affected justification (BSD-only
code path; we ship linux/amd64 and linux/arm64 only) holds for both
binaries.

* fix(ci): use list form for vulnerability.vex in trivy.yaml

Trivy's config schema requires vulnerability.vex to be a list (mapped
to the multi-value --vex flag). The previous bare-string value was
silently dropped, so the OpenVEX document was never loaded and HIGH
findings already covered by VEX statements still failed the scan.

* fix(ci): mirror VEX CVE in .trivyignore for local-image scan

Trivy does not emit an OCI purl for locally-built images without a
RepoDigests entry (aquasecurity/trivy#9399), so OpenVEX product
matching against the CI build target sencho:pr-test resolves to no
artifact and every statement is silently dropped. The VEX document
remains the canonical triage record and is still attached as a cosign
attestation on the published image; this file just mirrors the single
CVE that surfaces on the local scan so CI does not block on a finding
already triaged in VEX. Updates the Trivy step comment to document
the relationship between the two files.
This commit is contained in:
Anso
2026-04-26 23:21:11 -04:00
committed by GitHub
parent dfa463e390
commit 3668c71860
9 changed files with 345 additions and 82 deletions
+11 -3
View File
@@ -152,15 +152,23 @@ jobs:
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
- name: Scan image for vulnerabilities (Trivy)
# Hard-fails the PR on any HIGH or CRITICAL finding that is not listed
# in .trivyignore at the repo root. To accept a CVE, add it to that
# file with a human-readable justification comment, not here.
# Hard-fails the PR on any HIGH or CRITICAL finding not suppressed by
# the OpenVEX document at security/vex/sencho.openvex.json (loaded via
# trivy.yaml) or by .trivyignore at the repo root. The VEX document is
# the canonical triage record and is also attached as a cosign
# attestation on the published image. .trivyignore exists only to
# mirror VEX entries that cannot be resolved here because Trivy does
# not emit an OCI purl for local images without a digest, so VEX
# product matching against `sencho:pr-test` is a no-op (see
# aquasecurity/trivy#9399). Add the VEX statement first, then mirror
# the CVE id in .trivyignore with a pointer to the VEX justification.
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: sencho:pr-test
exit-code: '1'
severity: 'CRITICAL,HIGH'
format: 'table'
trivy-config: trivy.yaml
# ---------------------------------------------------------------------------
# E2E Tests (PRs only, skipped for release-please PRs)
+49 -4
View File
@@ -23,8 +23,9 @@ jobs:
# a manual-approval gate without any workflow change.
environment: production
permissions:
contents: read
# Required for cosign keyless signing via GitHub OIDC.
contents: write
# Required for cosign keyless signing via GitHub OIDC and for uploading
# SBOM/VEX files to GitHub Releases via softprops/action-gh-release.
id-token: write
steps:
- name: Check out the repo
@@ -102,14 +103,16 @@ jobs:
- name: Re-scan release image for vulnerabilities (Trivy)
# Gates the release on the same HIGH/CRITICAL policy as the PR scan.
# Entries in .trivyignore at the repo root are honored so the acknowledged
# CVE list is the single source of truth across PR CI and release CI.
# CVEs suppressed via the OpenVEX document (trivy.yaml -> security/vex/
# sencho.openvex.json) are the single source of truth across PR CI and
# release CI.
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: localhost/sencho:release-scan
exit-code: '1'
severity: 'CRITICAL,HIGH'
format: 'table'
trivy-config: trivy.yaml
# Start the scanned image headless on the runner and poll /api/health
# until it returns 200. Catches entrypoint regressions, native module
@@ -168,6 +171,8 @@ jobs:
# cosign verify saelix/sencho:<tag> \
# --certificate-identity-regexp "https://github.com/AnsoCode/Sencho/.*" \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
# Each ref is pinned to the immutable digest so signatures are bound to
# the exact manifest, not the mutable tag pointer.
env:
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build.outputs.digest }}
@@ -186,3 +191,43 @@ jobs:
else
echo "No tags to sign (likely a workflow_dispatch run on a non-tag ref)."
fi
- name: Generate CycloneDX SBOM
# syft scans the published manifest by digest for richer package
# extraction than the BuildKit-native SBOM. Also installs syft into
# PATH so the SPDX step below can reuse the OCI layer cache.
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.20.2
with:
image: saelix/sencho@${{ steps.build.outputs.digest }}
format: cyclonedx-json
output-file: sbom.cdx.json
upload-artifact: false
- name: Generate SPDX SBOM
# Reuses the syft binary and OCI layer cache from the prior step.
run: |
syft saelix/sencho@${{ steps.build.outputs.digest }} \
-o spdx-json=sbom.spdx.json
- name: Attest SBOMs and VEX with cosign (keyless)
# Attaches CycloneDX SBOM, SPDX SBOM, and OpenVEX document as signed
# OCI referrer attestations on the published digest. Verification
# commands are documented in docs/operations/verifying-images.mdx.
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
IMAGE_REF="saelix/sencho@${DIGEST}"
cosign attest --yes --predicate sbom.cdx.json --type cyclonedx "${IMAGE_REF}"
cosign attest --yes --predicate sbom.spdx.json --type spdxjson "${IMAGE_REF}"
cosign attest --yes --predicate security/vex/sencho.openvex.json --type openvex "${IMAGE_REF}"
- name: Upload SBOM and VEX to GitHub Release
# Fallback for consumers who do not use cosign; files are also
# available as signed OCI attestations via the attest step above.
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.3.2
with:
files: |
sbom.cdx.json
sbom.spdx.json
security/vex/sencho.openvex.json