diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index a97c095a22..1e60025b1c 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -262,6 +262,9 @@ jobs: - name: Install cosign uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + - name: Install Syft + uses: anchore/sbom-action/download-syft@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 + - name: Wait for GitHub Release to exist env: GH_TOKEN: ${{ github.token }} @@ -307,7 +310,7 @@ jobs: # gh release upload finds draft releases (unlike GoReleaser's API). # --clobber overwrites if assets already exist (idempotent on re-run). gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber \ - cli/dist/*.tar.gz cli/dist/*.zip \ + cli/dist/*.tar.gz cli/dist/*.zip cli/dist/*.cdx.json \ cli/dist/checksums.txt cli/dist/checksums.txt.cosign.bundle - name: Attest build provenance (SLSA Level 3) @@ -421,6 +424,16 @@ jobs: --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \`\`\` CLI_SLSA_BUNDLE_DATA --> + + NOTES )" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f77cce5311..a14e9dfc52 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -224,6 +224,22 @@ jobs: subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true + - name: Generate SBOM + if: github.event_name != 'pull_request' + uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 + with: + image: ghcr.io/aureliolo/synthorg-backend@${{ steps.push.outputs.digest }} + format: cyclonedx-json + output-file: sbom-backend.cdx.json + + - name: Upload SBOM artifact + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: sbom-backend + path: sbom-backend.cdx.json + retention-days: 30 + build-web: name: Build Web needs: [version] @@ -391,6 +407,22 @@ jobs: subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true + - name: Generate SBOM + if: github.event_name != 'pull_request' + uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 + with: + image: ghcr.io/aureliolo/synthorg-web@${{ steps.push.outputs.digest }} + format: cyclonedx-json + output-file: sbom-web.cdx.json + + - name: Upload SBOM artifact + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: sbom-web + path: sbom-web.cdx.json + retention-days: 30 + build-sandbox: name: Build Sandbox needs: [version] @@ -558,6 +590,22 @@ jobs: subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true + - name: Generate SBOM + if: github.event_name != 'pull_request' + uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 + with: + image: ghcr.io/aureliolo/synthorg-sandbox@${{ steps.push.outputs.digest }} + format: cyclonedx-json + output-file: sbom-sandbox.cdx.json + + - name: Upload SBOM artifact + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: sbom-sandbox + path: sbom-sandbox.cdx.json + retention-days: 30 + # Append container image references to the GitHub Release (version tags only) update-release: name: Update Release Notes @@ -567,6 +615,13 @@ jobs: permissions: contents: write steps: + - name: Download SBOM artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: sbom-* + merge-multiple: true + path: sboms + - name: Append container images to release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -637,6 +692,13 @@ jobs: gh attestation verify oci://ghcr.io/aureliolo/synthorg-sandbox@SANDBOX_DIGEST_PH -R Aureliolo/synthorg ``` CONTAINER_VERIFICATION_DATA --> + + BLOCK ) # Substitute placeholders @@ -645,6 +707,14 @@ jobs: IMAGES=${IMAGES//WEB_DIGEST_PH/$WEB_DIGEST} IMAGES=${IMAGES//SANDBOX_DIGEST_PH/$SANDBOX_DIGEST} + # Upload container SBOMs to the draft release + if ls sboms/sbom-*.cdx.json 1>/dev/null 2>&1; then + gh release upload "$TAG" --clobber sboms/sbom-*.cdx.json + echo "Container SBOMs uploaded to release." + else + echo "::warning::No SBOM files found in sboms/ directory" + fi + # Idempotent: strip existing Container Images section before appending. # Uses awk to delete only this section (up to the next ## heading), not to EOF. CLEANED=$(echo "$EXISTING" | awk '/^## Container Images$/{skip=1; next} /^## /{skip=0} !skip') diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml index 337f6c2583..714f4ab8c2 100644 --- a/.github/workflows/finalize-release.yml +++ b/.github/workflows/finalize-release.yml @@ -122,6 +122,24 @@ jobs: fi fi + CLI_SBOM="" + if grep -q "CLI_SBOM_DATA" <<< "$BODY"; then + CLI_SBOM=$(sed -n '//p' <<< "$BODY" \ + | sed '1d;$d' | grep -E '^\- ' || true) + if [ -z "$CLI_SBOM" ]; then + echo "::warning::CLI SBOM data not found in release body" + fi + fi + + CONTAINER_SBOM="" + if grep -q "CONTAINER_SBOM_DATA" <<< "$BODY"; then + CONTAINER_SBOM=$(sed -n '//p' <<< "$BODY" \ + | sed '1d;$d' | grep -E '^\- ' || true) + if [ -z "$CONTAINER_SBOM" ]; then + echo "::warning::Container SBOM data not found in release body" + fi + fi + CONTAINER_COSIGN="" CONTAINER_SLSA="" if grep -q "CONTAINER_VERIFICATION_DATA" <<< "$BODY"; then @@ -148,7 +166,7 @@ jobs: # Build the combined Verification section VERIFICATION="" - if [ -n "$CLI_CHECKSUMS" ] || [ -n "$CLI_COSIGN" ] || [ -n "$CLI_SLSA_BUNDLE" ] || [ -n "$CONTAINER_COSIGN" ] || [ -n "$CLI_SLSA" ] || [ -n "$CONTAINER_SLSA" ]; then + if [ -n "$CLI_CHECKSUMS" ] || [ -n "$CLI_COSIGN" ] || [ -n "$CLI_SLSA_BUNDLE" ] || [ -n "$CONTAINER_COSIGN" ] || [ -n "$CLI_SLSA" ] || [ -n "$CONTAINER_SLSA" ] || [ -n "$CLI_SBOM" ] || [ -n "$CONTAINER_SBOM" ]; then VERIFICATION="$(printf '\n\n\n---\n\n## Verification\n')" if [ -n "$CLI_CHECKSUMS" ]; then @@ -186,6 +204,22 @@ jobs: if [ -n "$SLSA_BLOCK" ]; then VERIFICATION="$(printf '%s\n\n### Provenance (SLSA Level 3)\n\nVerify with the [GitHub CLI](https://cli.github.com/):\n\n%s\n' "$VERIFICATION" "$SLSA_BLOCK")" fi + + # SBOM subsection + SBOM_ITEMS="" + if [ -n "$CLI_SBOM" ]; then + SBOM_ITEMS="$(printf '**CLI binaries:**\n%s\n' "$CLI_SBOM")" + fi + if [ -n "$CONTAINER_SBOM" ]; then + if [ -n "$SBOM_ITEMS" ]; then + SBOM_ITEMS="$(printf '%s\n\n**Container images:**\n%s\n' "$SBOM_ITEMS" "$CONTAINER_SBOM")" + else + SBOM_ITEMS="$(printf '**Container images:**\n%s\n' "$CONTAINER_SBOM")" + fi + fi + if [ -n "$SBOM_ITEMS" ]; then + VERIFICATION="$(printf '%s\n\n### Software Bill of Materials (SBOM)\n\nCycloneDX JSON SBOMs are attached to this release as downloadable assets.\n\n%s\n' "$VERIFICATION" "$SBOM_ITEMS")" + fi fi # Strip and re-append only when we have a replacement. If VERIFICATION @@ -196,7 +230,9 @@ jobs: | sed '//d' \ | sed '//d' \ | sed '//d' \ + | sed '//d' \ | sed '//d' \ + | sed '//d' \ | sed '/^/,$d') FINAL="${CLEANED}${VERIFICATION}" else diff --git a/CLAUDE.md b/CLAUDE.md index 76a58b8746..ad7048820b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ curl http://localhost:3000/api/v1/health # backend (via web proxy) - **Web**: `nginxinc/nginx-unprivileged`, Vue 3 SPA (PrimeVue + Tailwind CSS), SPA routing, API/WebSocket proxy to backend - **Sandbox**: `synthorg-sandbox` — Python 3.14 + Node.js + git, non-root (UID 10001), agent code execution sandbox - **Config**: all Docker files in `docker/` — Dockerfiles, compose, `.env.example` -- **CI**: `.github/workflows/docker.yml` — build → scan → push to GHCR + cosign sign + SLSA L3 provenance via `attest-build-provenance` (images only pushed after Trivy/Grype scans pass) +- **CI**: `.github/workflows/docker.yml` -- build -> scan -> push to GHCR + cosign sign + SLSA L3 provenance via `attest-build-provenance` + Syft SBOM generation (CycloneDX JSON, one per image) attached to releases (images only pushed after Trivy/Grype scans pass) - **Verification**: CLI verifies cosign signatures + SLSA provenance at pull time (`internal/verify/`); bypass with `--skip-verify` for air-gapped environments - **Build context**: single root `.dockerignore` (all images build with `context: .`) - **Tags**: CI tags images with version from `pyproject.toml` (`[tool.commitizen].version`), semver, and SHA @@ -272,7 +272,7 @@ site/ # Astro landing page (synthorg.io) - Concurrency group cancels stale builds on rapid pushes - **Docker**: `.github/workflows/docker.yml` — builds backend + web + sandbox images, pushes to GHCR, signs with cosign. SLSA L3 provenance attestations via `actions/attest-build-provenance` (SHA-pinned, Sigstore-signed, pushed to registry). Scans: Trivy (CRITICAL = hard fail, HIGH = warn-only) + Grype (critical cutoff) + CIS Docker Benchmark v1.6.0 compliance (informational). CVE triage via `.github/.trivyignore.yaml` and `.github/.grype.yaml`. Images only pushed after scans pass. Triggers on push to main and version tags (`v*`). - **Matrix**: Python 3.14 -- **CLI**: `.github/workflows/cli.yml` — Go lint (`golangci-lint` + `go vet`) + test (`-race -coverprofile`) + build (cross-compile matrix: linux/darwin/windows × amd64/arm64) + vulnerability check (`govulncheck`) + fuzz testing (main-only, 30s/target, `continue-on-error`, matrix over 5 packages) on `cli/**` changes. `cli-pass` gate includes fuzz result as informational warning. GoReleaser release on `v*` tags (attaches assets to the draft Release Please release). Cosign keyless signing of `checksums.txt` (`.sig` + `.pem` attached to release). SLSA L3 provenance attestations via `actions/attest-build-provenance` (SHA-pinned, Sigstore-signed) for individual binaries and checksums file. Sigstore provenance bundle (`.sigstore.json`) attached to release for OpenSSF Scorecard signed-releases scoring. Post-release step appends install instructions + checksum table + cosign verification + provenance verification instructions to the draft release notes (while still in draft — before finalize-release publishes). +- **CLI**: `.github/workflows/cli.yml` — Go lint (`golangci-lint` + `go vet`) + test (`-race -coverprofile`) + build (cross-compile matrix: linux/darwin/windows × amd64/arm64) + vulnerability check (`govulncheck`) + fuzz testing (main-only, 30s/target, `continue-on-error`, matrix over 5 packages) on `cli/**` changes. `cli-pass` gate includes fuzz result as informational warning. GoReleaser release on `v*` tags (attaches assets to the draft Release Please release). Syft SBOM generation per binary archive (CycloneDX JSON, via GoReleaser `sboms:` stanza). Cosign keyless signing of `checksums.txt` (`.sig` + `.pem` attached to release). SLSA L3 provenance attestations via `actions/attest-build-provenance` (SHA-pinned, Sigstore-signed) for individual binaries and checksums file. Sigstore provenance bundle (`.sigstore.json`) attached to release for OpenSSF Scorecard signed-releases scoring. Post-release step appends install instructions + checksum table + cosign verification + provenance verification + SBOM file listings to the draft release notes (while still in draft -- before finalize-release publishes). - **Dependabot**: daily uv + github-actions + npm + pre-commit + docker + gomod updates, grouped minor/patch, no auto-merge. Use `/review-dep-pr` to review Dependabot PRs before merging - **Python audit**: `.github/workflows/python-audit.yml` — weekly pip-audit scan for Python dependency vulnerabilities (also runs per-PR via `python-audit` job in ci.yml) - **Dockerfile lint**: hadolint lints all 3 Dockerfiles (backend, web, sandbox) in CI via `dockerfile-lint` job + hadolint-docker pre-commit hook locally @@ -286,7 +286,7 @@ site/ # Astro landing page (synthorg.io) - **Socket.dev**: GitHub App — supply chain attack detection on PRs (typosquatting, malware, suspicious ownership changes, obfuscated code). No config file needed, auto-comments on PRs. - **CLA**: `.github/workflows/cla.yml` — Contributor License Agreement signature check on PRs via `contributor-assistant/github-action`. Triggers on `pull_request_target` and `issue_comment`. Skips Dependabot. Signatures stored in `.github/cla-signatures.json` on the `cla-signatures` branch (unprotected, so the action can commit directly). - **Release**: `.github/workflows/release.yml` — Release Please (Google) auto-creates a release PR on every push to main. Merging the release PR creates a **draft** GitHub Release with changelog. A pre-RP step ensures the manifest version's git tag exists (draft releases don't create tags, and `force-tag-creation` requires RP >= 17.2.0 — action v4.4.0 bundles 17.1.3; remove workaround once `googleapis/release-please-action#1187` ships). Tag push triggers Docker and CLI workflows to attach assets to the draft. Uses `RELEASE_PLEASE_TOKEN` secret (PAT/GitHub App token) so tag creation triggers downstream workflows (GITHUB_TOKEN cannot). Config in `.github/release-please-config.json` (`"draft": true`, `"force-tag-creation": true`) and `.github/.release-please-manifest.json`. After creating/updating a release PR, auto-updates the BSL Change Date in LICENSE to 3 years ahead. -- **Finalize Release**: `.github/workflows/finalize-release.yml` — publishes draft releases created by Release Please. Triggers on `workflow_run` completion of Docker and CLI workflows. Verifies that both workflows succeeded for the associated tag before publishing the draft. Extracts CLI checksums, CLI cosign verification, and container verification data from HTML comments embedded by the CLI and Docker workflows, and assembles them into a combined Verification section in the release notes. Guards against PR-triggered runs (`event != 'pull_request'`). Handles TOCTOU races (concurrent publish attempts exit cleanly). Immutable releases are enabled on the repo — once published, release assets and body cannot be modified. +- **Finalize Release**: `.github/workflows/finalize-release.yml` — publishes draft releases created by Release Please. Triggers on `workflow_run` completion of Docker and CLI workflows. Verifies that both workflows succeeded for the associated tag before publishing the draft. Extracts CLI checksums, CLI cosign verification, container verification data, and SBOM file listings from HTML comments embedded by the CLI and Docker workflows, and assembles them into a combined Verification section in the release notes. Guards against PR-triggered runs (`event != 'pull_request'`). Handles TOCTOU races (concurrent publish attempts exit cleanly). Immutable releases are enabled on the repo — once published, release assets and body cannot be modified. ## Dependencies diff --git a/cli/.goreleaser.yml b/cli/.goreleaser.yml index f29cd8f32c..81c337d2eb 100644 --- a/cli/.goreleaser.yml +++ b/cli/.goreleaser.yml @@ -30,11 +30,23 @@ archives: formats: - zip name_template: "synthorg_{{ .Os }}_{{ .Arch }}" + files: + - src: ../LICENSE + dst: . + info: + mtime: "{{ .CommitDate }}" checksum: name_template: checksums.txt algorithm: sha256 +sboms: + - artifacts: archive + cmd: syft + args: ["scan", "$artifact", "--output", "cyclonedx-json=$document"] + documents: + - "{{ .ArtifactName }}.cdx.json" + release: # Skip GoReleaser's release management — Release Please owns the GitHub # Release (created as draft). Assets are uploaded via gh release upload diff --git a/docs/architecture/tech-stack.md b/docs/architecture/tech-stack.md index 1aa003be2b..f10aaaf154 100644 --- a/docs/architecture/tech-stack.md +++ b/docs/architecture/tech-stack.md @@ -62,7 +62,7 @@ The SynthOrg engine is structured as a set of loosely coupled subsystems. Each b | **Agent Communication** | A2A Protocol compatible | Future-proof inter-agent communication. See [Industry Standards](../reference/standards.md). | | **Authentication** | PyJWT + argon2-cffi | JWT (HMAC HS256/384/512) for session tokens, Argon2id for password hashing, HMAC-SHA256 for API key storage (keyed with server secret). | | **Config Format** | YAML + Pydantic validation | Human-readable config with strict validation. | -| **CLI** | Go (Cobra + charmbracelet/huh) | Cross-platform binary for Docker lifecycle management: `init`, `start`, `stop`, `status`, `logs`, `update`, `doctor`, `uninstall`, `version`. Distributed via GoReleaser + install scripts (`curl \| bash`, `irm \| iex`). Cosign keyless signing of checksums file (`.sig` + `.pem`). SLSA Level 3 provenance attestations on all release archives. Sigstore provenance bundle (`.sigstore.json`) attached to releases. | +| **CLI** | Go (Cobra + charmbracelet/huh) | Cross-platform binary for Docker lifecycle management: `init`, `start`, `stop`, `status`, `logs`, `update`, `doctor`, `uninstall`, `version`. Distributed via GoReleaser + install scripts (`curl \| bash`, `irm \| iex`). Syft generates CycloneDX JSON SBOMs per archive (via GoReleaser `sboms:` stanza). Cosign keyless signing of checksums file (`.sig` + `.pem`). SLSA Level 3 provenance attestations on all release archives. Sigstore provenance bundle (`.sigstore.json`) attached to releases. | --- @@ -79,7 +79,7 @@ The SynthOrg engine is structured as a set of loosely coupled subsystems. Each b | Web UI | Vue 3 | React, Svelte, HTMX | Simpler than React for dashboards. | | Persistence | Pluggable protocol + repository protocols | ORM (SQLAlchemy), raw SQL, hybrid | Same frozen Pydantic models in and out (no DTOs), async throughout, backend-swappable via config. Repository protocols decouple app code from storage engine. | | Sandboxing | Layered: subprocess + Docker | Docker-only, subprocess-only, WASM | Risk-proportionate: fast subprocess for file/git, Docker isolation for code execution. Pluggable `SandboxBackend` protocol enables K8s migration later. | -| Container Packaging | Chainguard distroless + GHCR | Alpine, Debian-slim, scratch, Docker Hub | Minimal attack surface, non-root by default, continuously scanned in CI. GHCR for tighter GitHub integration. cosign keyless signing for supply-chain integrity (container images and CLI checksums file). Trivy + Grype dual scanning. SLSA L3 provenance attestations on container images and CLI binaries via `actions/attest-build-provenance`. | +| Container Packaging | Chainguard distroless + GHCR | Alpine, Debian-slim, scratch, Docker Hub | Minimal attack surface, non-root by default, continuously scanned in CI. GHCR for tighter GitHub integration. cosign keyless signing for supply-chain integrity (container images and CLI checksums file). Trivy + Grype dual scanning. SLSA L3 provenance attestations on container images and CLI binaries via `actions/attest-build-provenance`. Syft (`anchore/sbom-action`) generates CycloneDX JSON SBOMs per container image, attached to GitHub Releases. | !!! info "Design Decision: Why Litestar over FastAPI?" diff --git a/docs/security.md b/docs/security.md index 74a2171160..e3efe46a45 100644 --- a/docs/security.md +++ b/docs/security.md @@ -122,7 +122,8 @@ Resource limits (`deploy.resources.limits`) cap memory, CPU, and PIDs per contai - All base images **pinned by SHA-256 digest** (no mutable tags) - **Dependabot** auto-updates digests daily for all three Dockerfiles - **cosign keyless signing** on every pushed image (Sigstore OIDC-bound) -- **SBOM and build-level provenance** (SLSA L1) auto-generated by Docker Buildx +- **Buildx SPDX SBOMs** (SLSA L1) auto-generated and pushed to GHCR as registry attestations (inspect via `docker buildx imagetools inspect`). Standalone CycloneDX JSON SBOMs are generated separately by Syft -- see [Software Bill of Materials](#software-bill-of-materials-sbom) below. +- **Build-level provenance** (SLSA L1) auto-generated by Docker Buildx - **SLSA Level 3 provenance** for CLI binary releases and container images (generated by `actions/attest-build-provenance`, Sigstore-signed, independently verifiable) - **Client-side verification**: The CLI (`synthorg start`, `synthorg update`) automatically verifies cosign signatures and SLSA provenance for container images before pulling. Verified digests are pinned in the compose file to prevent tag mutation attacks. Bypass with `--skip-verify` or `SYNTHORG_SKIP_VERIFY=1` for air-gapped environments (not recommended). @@ -162,7 +163,19 @@ Images are **only pushed to GHCR after both vulnerability scanners pass**. - Sigstore provenance bundle (`.sigstore.json`, verify via `cosign verify-blob-attestation`) - **Git commits**: GPG/SSH signed (enforced by branch protection ruleset) - **GitHub Actions**: All actions pinned by full SHA commit hash -- **GitHub Releases**: Immutable releases enabled — once published, assets and body cannot be modified (prevents supply chain tampering). Releases are created as drafts by Release Please, finalized after all assets are attached. +- **GitHub Releases**: Immutable releases enabled -- once published, assets and body cannot be modified (prevents supply chain tampering). Releases are created as drafts by Release Please, finalized after all assets are attached. + +### Software Bill of Materials (SBOM) + +Every release includes CycloneDX JSON SBOMs for all released artifacts: + +- **Container images**: per-image SBOMs (`sbom-backend.cdx.json`, `sbom-web.cdx.json`, + `sbom-sandbox.cdx.json`) generated by [Syft](https://github.com/anchore/syft), + attached to GitHub Releases as downloadable assets +- **CLI binaries**: per-archive SBOMs (e.g. `synthorg_linux_amd64.tar.gz.cdx.json`) + generated by GoReleaser + Syft, attached to GitHub Releases +- **Registry attestations**: Buildx-generated SPDX SBOMs pushed to GHCR alongside + each image (inspect via `docker buildx imagetools inspect`) ---