Files
certctl/.github/workflows/release.yml
T
shankar0123 e720474fb7 Bundle D: Documentation & transparency sweep — 8 findings closed
Closes H-009 + L-001 + L-007 + L-008 + L-016 + L-017 + L-018 + M-027
from comprehensive-audit-2026-04-25.

H-009 — README JWT verified-already-clean
  README has zero JWT mentions at audit time. docs/architecture.md
  correctly documents JWT/OIDC integration via authenticating-gateway
  pattern (line 905-912).
  .github/workflows/ci.yml: new step
    'Forbidden README JWT advertising regression guard (H-009)'
    greps README for JWT-as-supported phrasing; passes verbatim
    (gateway / pre-G-1) but fails build on net-new advertising.

L-001 (CWE-295) — InsecureSkipVerify per-site justification
  Audit count was 8; recon found 13 production sites.
  docs/tls.md: new 'InsecureSkipVerify justifications' table
    enumerates each site by file:line with per-site rationale.
  cmd/agent/verify.go:78, internal/tlsprobe/probe.go:54,
  internal/service/network_scan.go:460: each previously-bare
    InsecureSkipVerify: true now carries //nolint:gosec.
  .github/workflows/ci.yml: new step
    'Forbidden bare InsecureSkipVerify regression guard (L-001)'
    fails build if any net-new ISV lands in non-test .go without
    nolint:gosec on the same or preceding line.

L-007 — README dependency-audit commands
  README.md: new Dependencies section with go list -m all | wc -l,
    go mod why, govulncheck ./.... Honors operating-rules invariant.

L-008 — Release-time govulncheck gate
  .github/workflows/release.yml: new 'Install govulncheck' +
    'Run govulncheck (release gate)' steps in the matrix job.
    Pinned to same install path as ci.yml. Default exit code
    semantics (fail on called-vuln only, deferred-call advisories
    tracked on master via L-021) keeps the gate appropriate.

L-016 — architecture.md drift fixes
  docs/architecture.md: system-components diagram's '21 tables'
    annotation removed (current 23; replaced with TEXT-keys
    descriptor); connector-architecture '9 connectors' prose
    replaced with grep ref + current 12-issuer list (added
    Entrust/GlobalSign/EJBCA which were missing); API-design
    '97 operations / 107 total' replaced with grep commands.
  Connector subgraphs verified-current at 12/13/6.

L-017 — workspace CLAUDE.md verified-already-clean
  Bundle B's pre-commit-gate refactor already converted current-
  state numeric claims to grep commands. Phase 0 recon confirmed
  zero remaining hardcoded counts.

L-018 — Defect age table
  cowork/comprehensive-audit-2026-04-25/defect-age.md (NEW):
    Tabulates all 9 High findings with first-mentioned commit,
    closing bundle, days-open. Methodology snippet for re-running.
    Key finding: 8 of 9 closed within 24h of audit publication.

M-027 — OpenAPI parity verified-already-clean
  Audit's 'router 121 vs OpenAPI 125 — 4-op gap' was wrong
  methodology. The 4-op 'gap' was exactly the 4 routes registered
  via r.mux.Handle (auth-exempt allowlist) instead of r.Register.
  When you count both dispatch shapes the totals match exactly.
  internal/api/router/openapi_parity_test.go (NEW):
    TestRouter_OpenAPIParity AST-walks router.go for both
    Register and mux.Handle calls + walks api/openapi.yaml's
    path/method nesting + asserts the sets match. Adding a route
    without updating the spec fails CI permanently.

Audit deliverables:
  audit-report.md: score 38/55 -> 46/55 closed
    (High 7/9 -> 8/9; Medium 20/27 -> 21/27; Low 8/19 -> 14/19)
  findings.yaml: 8 status flips open -> closed
  defect-age.md: new file
  certctl/CHANGELOG.md: Bundle D section

Verification:
  TestRouter_OpenAPIParity                                   PASS
  L-001 grep guard self-test (after //nolint:gosec adds)     PASS
  H-009 grep guard self-test                                 PASS
  go test -count=1 -short on changed packages                green
2026-04-27 00:47:15 +00:00

478 lines
18 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Release
on:
push:
tags:
- 'v*'
env:
REGISTRY: ghcr.io
# Keep in lock-step with .github/workflows/ci.yml (M-3).
GO_VERSION: '1.25.9'
IMAGE_NAMESPACE: shankar0123
jobs:
# ----------------------------------------------------------------------
# build-binaries (M-3): matrix build every (binary × OS × arch) tuple.
# For each tuple we produce: the binary, a SPDX-JSON SBOM, a keyless
# Cosign signature + certificate bundle, and a single-line sha256sum
# file. All artefacts are uploaded to a workflow-scoped artifact; the
# aggregate-checksums job fans them back in for release upload.
# ----------------------------------------------------------------------
build-binaries:
name: Build ${{ matrix.binary }} (${{ matrix.os }}/${{ matrix.arch }})
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Cosign keyless OIDC identity token
strategy:
fail-fast: false
matrix:
binary: [agent, server, cli, mcp-server]
os: [linux, darwin]
arch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Install govulncheck
# Bundle D / Audit L-008: release.yml previously had no vulnerability
# scan, so a release tag could in principle ship a binary with a
# known CVE in transitive deps that ci.yml's govulncheck would have
# caught on master. Pre-build scan blocks the release if anything
# surfaced post-merge. Pinned to the same major as ci.yml.
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck (release gate)
# govulncheck distinguishes called-vs-uncalled vulnerable functions.
# Default exit code (0 unless an actual call site lands in a vuln
# function) is the right gate for release; deferred-call advisories
# are tracked separately on master via L-021. If a release-time
# scan surfaces a NEW called-vuln, the release is blocked until the
# bump lands on master and a new tag is cut.
run: govulncheck ./...
- name: Build binary
id: build
env:
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
CGO_ENABLED: '0'
VERSION: ${{ steps.version.outputs.VERSION }}
run: |
set -euo pipefail
OUTPUT_NAME="certctl-${{ matrix.binary }}-${{ matrix.os }}-${{ matrix.arch }}"
mkdir -p dist
go build \
-trimpath \
-ldflags="-w -s -X main.Version=${VERSION}" \
-o "dist/${OUTPUT_NAME}" \
"./cmd/${{ matrix.binary }}"
ls -lh "dist/${OUTPUT_NAME}"
echo "output_name=${OUTPUT_NAME}" >> "$GITHUB_OUTPUT"
- name: Generate SBOM (SPDX-JSON)
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
file: dist/${{ steps.build.outputs.output_name }}
format: spdx-json
output-file: dist/${{ steps.build.outputs.output_name }}.sbom.spdx.json
upload-artifact: false
upload-release-assets: false
- name: Install Cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Keyless-sign binary with Cosign
env:
OUTPUT_NAME: ${{ steps.build.outputs.output_name }}
run: |
set -euo pipefail
# Cosign v3.0 (shipped by cosign-installer@v4.1.1 default
# cosign-release=v3.0.5) removed --output-signature/--output-certificate
# on sign-blob. The replacement is --bundle, which emits a unified
# Sigstore bundle (signature + cert chain + Rekor inclusion proof) as
# a single .sigstore.json artefact. M-11.
cosign sign-blob \
--yes \
--bundle "dist/${OUTPUT_NAME}.sigstore.json" \
"dist/${OUTPUT_NAME}"
- name: Compute SHA-256 sidecar
env:
OUTPUT_NAME: ${{ steps.build.outputs.output_name }}
run: |
set -euo pipefail
cd dist
sha256sum "${OUTPUT_NAME}" > "${OUTPUT_NAME}.sha256"
cat "${OUTPUT_NAME}.sha256"
- name: Upload build artefacts
uses: actions/upload-artifact@v4
with:
name: binary-${{ steps.build.outputs.output_name }}
path: |
dist/${{ steps.build.outputs.output_name }}
dist/${{ steps.build.outputs.output_name }}.sigstore.json
dist/${{ steps.build.outputs.output_name }}.sbom.spdx.json
dist/${{ steps.build.outputs.output_name }}.sha256
if-no-files-found: error
retention-days: 7
# ----------------------------------------------------------------------
# aggregate-checksums (M-3): fan in every matrix artefact, produce a
# single checksums.txt (sha256sum format, compatible with `sha256sum
# -c`), sign it with Cosign, upload everything to the GitHub Release,
# and emit a base64-encoded hash manifest for the SLSA generator.
# ----------------------------------------------------------------------
aggregate-checksums:
name: Aggregate checksums & sign
runs-on: ubuntu-latest
needs: [build-binaries]
permissions:
contents: write
id-token: write # Cosign keyless OIDC identity token
outputs:
hashes: ${{ steps.hashes.outputs.hashes }}
steps:
- name: Download binary artefacts
uses: actions/download-artifact@v4
with:
pattern: binary-*
path: artifacts
merge-multiple: true
- name: Aggregate SHA-256 sums
id: hashes
run: |
set -euo pipefail
cd artifacts
: > checksums.txt
for f in certctl-*; do
case "$f" in
*.sigstore.json|*.sbom.spdx.json|*.sha256|checksums.txt)
continue ;;
esac
sha256sum "$f" >> checksums.txt
done
echo "=== checksums.txt ==="
cat checksums.txt
# base64 hashes (single line, no wrapping) for SLSA generator.
HASHES=$(base64 -w0 < checksums.txt)
echo "hashes=${HASHES}" >> "$GITHUB_OUTPUT"
- name: Install Cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Keyless-sign checksums.txt
run: |
set -euo pipefail
cd artifacts
# Cosign v3.0 --bundle replaces the removed v2 flag pair
# --output-signature / --output-certificate. See M-11.
cosign sign-blob \
--yes \
--bundle checksums.txt.sigstore.json \
checksums.txt
- name: Upload artefacts to GitHub Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
artifacts/certctl-*
artifacts/checksums.txt
artifacts/checksums.txt.sigstore.json
# ----------------------------------------------------------------------
# provenance-binaries (M-3): SLSA Level 3 provenance for every binary.
# The SLSA generic generator reusable workflow runs in a hermetic
# workflow run, producing multiple.intoto.jsonl from the base64 hash
# manifest and uploading it as a release asset.
# ----------------------------------------------------------------------
provenance-binaries:
name: SLSA provenance (binaries)
needs: [aggregate-checksums]
permissions:
actions: read
id-token: write
contents: write
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
with:
base64-subjects: "${{ needs.aggregate-checksums.outputs.hashes }}"
upload-assets: true
provenance-name: multiple.intoto.jsonl
# ----------------------------------------------------------------------
# build-and-push-docker: push container images to GHCR with native
# SLSA L3 provenance (mode=max) and SBOM attestations emitted by
# docker/build-push-action@v6, plus a keyless Cosign signature on the
# image digest for identity-bound verification. The M-4 proxy-propagation
# build-args block is retained verbatim — M-3 only adds supply-chain
# steps; it never touches M-4 wiring.
# ----------------------------------------------------------------------
build-and-push-docker:
name: Build & Push Docker Images
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
id-token: write # Cosign keyless OIDC identity token
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install Cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Build and push server image
id: server-push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-server:${{ steps.version.outputs.VERSION }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-server:latest
# Proxy propagation (M-4, Issue #9) — forwards runner-level proxy
# secrets into the Docker build so self-hosted runners behind
# corporate proxies can reach public registries. GitHub-hosted
# runners don't need proxies, so the secrets are optional and
# resolve to empty strings when unset — byte-identical to the
# pre-fix behaviour for the public-runner path.
build-args: |
HTTP_PROXY=${{ secrets.HTTP_PROXY }}
HTTPS_PROXY=${{ secrets.HTTPS_PROXY }}
NO_PROXY=${{ secrets.NO_PROXY }}
# Supply-chain hardening (M-3): emit native SLSA L3 provenance
# and SBOM attestations bound to the image manifest.
provenance: mode=max
sbom: true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Keyless-sign server image with Cosign
env:
DIGEST: ${{ steps.server-push.outputs.digest }}
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-server
run: |
set -euo pipefail
cosign sign --yes "${IMAGE}@${DIGEST}"
- name: Build and push agent image
id: agent-push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.agent
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-agent:${{ steps.version.outputs.VERSION }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-agent:latest
# Proxy propagation (M-4, Issue #9) — see server-image step for
# rationale. Empty secrets resolve to empty build args, leaving
# the un-proxied code path byte-identical to the pre-fix tree.
build-args: |
HTTP_PROXY=${{ secrets.HTTP_PROXY }}
HTTPS_PROXY=${{ secrets.HTTPS_PROXY }}
NO_PROXY=${{ secrets.NO_PROXY }}
# Supply-chain hardening (M-3): emit native SLSA L3 provenance
# and SBOM attestations bound to the image manifest.
provenance: mode=max
sbom: true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Keyless-sign agent image with Cosign
env:
DIGEST: ${{ steps.agent-push.outputs.digest }}
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-agent
run: |
set -euo pipefail
cosign sign --yes "${IMAGE}@${DIGEST}"
# ----------------------------------------------------------------------
# create-release: stamp the release body. The actual asset uploads are
# handled by aggregate-checksums (binaries, SBOMs, sigs, certs,
# checksums.txt + signature) and the SLSA generator (multiple.intoto.jsonl).
# ----------------------------------------------------------------------
create-release:
name: Create Release Notes
runs-on: ubuntu-latest
needs: [build-binaries, aggregate-checksums, provenance-binaries, build-and-push-docker]
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Create release with notes
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
body: |
## Installation
### Quick Install (Linux/macOS)
```bash
curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash
```
### Manual Binary Download
Download the appropriate binary for your OS and architecture:
- **Linux x86_64**: `certctl-agent-linux-amd64`
- **Linux ARM64**: `certctl-agent-linux-arm64`
- **macOS x86_64**: `certctl-agent-darwin-amd64`
- **macOS ARM64 (Apple Silicon)**: `certctl-agent-darwin-arm64`
Then make it executable and start the service:
```bash
chmod +x certctl-agent-linux-amd64
sudo mv certctl-agent-linux-amd64 /usr/local/bin/certctl-agent
```
## Docker Images
Pull pre-built Docker images for server and agent:
```bash
docker pull ghcr.io/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
docker pull ghcr.io/shankar0123/certctl-agent:${{ steps.version.outputs.VERSION }}
```
Or use the latest tag:
```bash
docker pull ghcr.io/shankar0123/certctl-server:latest
docker pull ghcr.io/shankar0123/certctl-agent:latest
```
## Docker Compose Quick Start
```bash
git clone https://github.com/shankar0123/certctl.git
cd certctl
cp deploy/.env.example deploy/.env
docker compose -f deploy/docker-compose.yml up -d
```
## Server Binaries
Pre-compiled server binaries are also available for direct installation:
- **Linux x86_64**: `certctl-server-linux-amd64`
- **Linux ARM64**: `certctl-server-linux-arm64`
- **macOS x86_64**: `certctl-server-darwin-amd64`
- **macOS ARM64 (Apple Silicon)**: `certctl-server-darwin-arm64`
## CLI & MCP Server Binaries
The `certctl-cli` (REST API wrapper) and `certctl-mcp-server` (Model Context
Protocol bridge) binaries ship for all four platforms as well:
- `certctl-cli-{linux,darwin}-{amd64,arm64}`
- `certctl-mcp-server-{linux,darwin}-{amd64,arm64}`
## Verifying this release
Every binary, `checksums.txt`, and container image is signed with Cosign
keyless OIDC. Each binary ships with a SPDX-JSON SBOM. Binaries are covered
by SLSA Level 3 provenance; container images carry native SLSA L3 provenance
and SBOM attestations (docker/build-push-action `provenance: mode=max`,
`sbom: true`) in addition to a Cosign signature on the digest.
**1. Verify SHA-256 checksums:**
```bash
sha256sum -c checksums.txt
```
**2. Verify the Cosign signature on checksums.txt (keyless OIDC):**
```bash
cosign verify-blob \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
checksums.txt
```
Replace `checksums.txt` with any individual binary name to verify that
artefact directly (each binary ships with its own `.sigstore.json`
bundle, e.g. `cosign verify-blob --bundle certctl-agent-linux-amd64.sigstore.json …`).
**3. Verify SLSA Level 3 provenance (binaries):**
```bash
slsa-verifier verify-artifact \
--provenance-path multiple.intoto.jsonl \
--source-uri github.com/shankar0123/certctl \
--source-tag ${{ steps.version.outputs.VERSION }} \
certctl-agent-linux-amd64
```
**4. Verify container image signature and attestations:**
```bash
IMAGE=ghcr.io/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
cosign verify \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/\.github/workflows/release\.yml@refs/tags/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$IMAGE"
# SBOM attestation (SPDX-JSON) emitted by docker/build-push-action
cosign verify-attestation --type spdxjson \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$IMAGE"
# SLSA provenance attestation (mode=max)
cosign verify-attestation --type slsaprovenance \
--certificate-identity-regexp '^https://github\.com/shankar0123/certctl/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$IMAGE"
```
## Helm Chart
Deploy certctl to Kubernetes using Helm:
```bash
helm repo add certctl https://github.com/shankar0123/certctl/tree/master/deploy/helm
helm repo update
helm install certctl certctl/certctl
```
See `deploy/helm/certctl/` for values customization.