Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 122 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ on:
branches: [main]
pull_request:
branches: [main]
# Weekly schedule keeps the vuln-DB scans honest even on quiet weeks
# where no PRs land. Sunday 06:00 UTC = light load on GitHub runners.
# The security-scan job below gates on github.event_name to stay
# cheap on the non-security jobs.
schedule:
- cron: '0 6 * * 0'

# Stop in-flight runs when a new commit pushes to the same PR.
concurrency:
Expand Down Expand Up @@ -287,10 +293,125 @@ jobs:
--next ../../apps/admin/.next \
--summary "$GITHUB_STEP_SUMMARY"

# Security scanners — issues #190 and #196.
#
# Five tools, grouped into one job so a green check covers them all
# for branch protection:
#
# - govulncheck (#190): Go vuln DB, looks at imports + reachability.
# - osv-scanner (#190): Google's cross-language vuln DB (Go, npm, etc).
# - semgrep (#190): rules-based static analysis. OWASP Top 10 +
# security-audit registry packs.
# - gosec (#196): Go-specific security linter (G-rules).
# - gitleaks (#196): secret scanning across the working tree.
#
# Fail-fast: any one of these returning non-zero fails the job. The
# scanners are NOT advisory — a known CVE landing in go.mod should
# block the merge. (The acceptable-CVE list lives in osv-scanner's
# config + .semgrepignore + .gitleaks.toml; raise findings via PR.)
#
# Runs on PR + the weekly schedule above. Pull requests get the same
# full sweep as the cron run so a freshly-disclosed CVE in main
# doesn't sit unaddressed until the next Sunday.
security-scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gitleaks needs history for the full sweep
- uses: actions/setup-go@v5
with:
go-version-file: 'go.work'
cache: true
- name: go work sync
run: go work sync

# govulncheck — Go's first-party vulnerability scanner. Walks the
# workspace modules; reports CVEs that are reachable from at least
# one entrypoint. We iterate the workspace `use` list (same
# pattern as lint-go) so transitive non-workspace modules don't
# noisy up the report.
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: govulncheck (workspace modules)
run: |
for dir in $(go work edit -json | python3 -c "import json,sys; [print(u['DiskPath']) for u in json.load(sys.stdin)['Use']]"); do
echo "::group::govulncheck $dir"
(cd "$dir" && govulncheck ./...)
echo "::endgroup::"
done

# osv-scanner — cross-language vuln DB (covers npm + Go + container
# images). Runs from the repo root with -r so it finds every
# manifest (go.mod, pnpm-lock.yaml, package.json, ...).
- name: Install osv-scanner
run: go install github.com/google/osv-scanner/cmd/osv-scanner@latest
- name: osv-scanner (recursive)
run: osv-scanner -r .

# semgrep — rules-based SAST. OWASP Top 10 catches the obvious
# injection / authz patterns; security-audit is a broader sweep
# of misuse patterns Semgrep has community rules for.
- name: Install semgrep
run: |
python3 -m pip install --quiet --upgrade pip
python3 -m pip install --quiet semgrep
- name: semgrep (OWASP + security-audit)
run: |
semgrep \
--config=p/owasp-top-ten \
--config=p/security-audit \
--error \
--metrics=off \
./apps ./packages

# gosec — Go-specific security linter (G101 hardcoded creds, G104
# ignored errors on security calls, G304 file inclusion, etc.).
# Iterates the workspace `use` list like govulncheck for the
# same reason.
- name: Install gosec
run: go install github.com/securego/gosec/v2/cmd/gosec@latest
- name: gosec (workspace modules)
run: |
for dir in $(go work edit -json | python3 -c "import json,sys; [print(u['DiskPath']) for u in json.load(sys.stdin)['Use']]"); do
echo "::group::gosec $dir"
(cd "$dir" && gosec ./...)
echo "::endgroup::"
done

# gitleaks — secret scanning. --no-banner keeps the log tight;
# --redact strips matched secrets from the report (we never want
# a leaked token mirrored into a public CI log); --report-format
# json so a follow-up step (or human reviewer) can ingest the
# findings cleanly. The non-zero exit on findings is the gate.
- name: Install gitleaks
run: |
GITLEAKS_VERSION=8.21.2
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /tmp gitleaks
sudo mv /tmp/gitleaks /usr/local/bin/gitleaks
- name: gitleaks detect
run: |
gitleaks detect \
--no-banner \
--redact \
--report-format json \
--report-path gitleaks-report.json
- name: Upload gitleaks report
if: always()
uses: actions/upload-artifact@v4
with:
name: gitleaks-report
path: gitleaks-report.json
if-no-files-found: ignore

# Aggregate gate. Required check on the branch protection rule.
# bundle-budget is intentionally NOT in `needs:` while it remains advisory.
ci:
needs: [lint-docs, lint-go, test-go, lint-web, test-web, lint-dashboards, lint-openapi]
needs: [lint-docs, lint-go, test-go, lint-web, test-web, lint-dashboards, lint-openapi, security-scan]
if: always()
runs-on: ubuntu-latest
steps:
Expand Down
112 changes: 112 additions & 0 deletions .github/workflows/sbom.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: SBOM

# Software Bill of Materials generation (issue #142).
#
# Triggers:
# - tag push (v*): publish CycloneDX SBOMs as release artifacts.
# - pull_request: upload SBOMs as workflow artifacts only (no release).
# - workflow_dispatch: manual dry-run from the Actions tab.
#
# Scope (one SBOM per shipped surface):
# - apps/api — Go module (cmd/server binary)
# - apps/web — Next.js bundle (transitive npm tree)
# - cli/gonext — Go module (gonext CLI binary)
# - examples/plugins/seo — WASM plugin (.gnplugin) Go module
#
# Tool choice: anchore/syft. CycloneDX 1.5 JSON is the lingua franca our
# downstream SOC2 evidence pipeline expects. We do NOT use SPDX here
# because the dependency-track instance the security team runs ingests
# CycloneDX natively; pivoting later is one --output flag away.

on:
push:
tags:
- 'v*'
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: write # required to attach SBOMs to the GitHub Release on tag push
pull-requests: read

concurrency:
group: sbom-${{ github.ref }}
cancel-in-progress: true

jobs:
sbom:
name: Generate SBOM (${{ matrix.target.name }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target:
- name: api
path: apps/api
kind: go
- name: web
path: apps/web
kind: npm
- name: cli-gonext
path: cli/gonext
kind: go
- name: plugin-seo
path: examples/plugins/seo
kind: go
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1

# Syft is fetched as a static binary instead of relying on the
# action's docker image — this keeps the job cold-start under 15s
# and avoids the action's implicit privileges on the workflow.
- name: Install syft
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin v1.18.1
syft version

- name: Generate CycloneDX SBOM
run: |
set -euo pipefail
mkdir -p sbom-out
syft scan "dir:${{ matrix.target.path }}" \
-o cyclonedx-json="sbom-out/sbom-${{ matrix.target.name }}.cdx.json" \
-o spdx-json="sbom-out/sbom-${{ matrix.target.name }}.spdx.json"
echo "::group::SBOM summary"
jq '{component: .metadata.component.name, components: (.components|length)}' \
"sbom-out/sbom-${{ matrix.target.name }}.cdx.json"
echo "::endgroup::"

- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
name: sbom-${{ matrix.target.name }}
path: sbom-out/
retention-days: 30
if-no-files-found: error

# On tag push, attach every SBOM to the GitHub Release. We collect the
# matrix outputs into a single release-upload step so the operator sees
# one job in the release timeline rather than four.
release:
name: Attach SBOMs to release
needs: sbom
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Download all SBOMs
uses: actions/download-artifact@v4
with:
pattern: sbom-*
path: sbom-out
merge-multiple: true

- name: Attach SBOMs to release
uses: softprops/action-gh-release@v2
with:
files: sbom-out/*.json
fail_on_unmatched_files: true
generate_release_notes: false
127 changes: 127 additions & 0 deletions .github/workflows/zap-dast.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
name: ZAP DAST

# OWASP ZAP baseline scan against the running compose stack (issue #204).
#
# Triggers:
# - cron nightly 03:00 UTC (off-peak so the scan never collides with
# the daily release window)
# - workflow_dispatch (manual runs from the Actions tab)
#
# We intentionally do NOT trigger on pull_request: ZAP runs take ~5
# minutes and the false-positive rate on a CMS API would noise the PR
# checkers. The nightly cadence matches the security team's review SLO.
#
# Pipeline:
# 1. Boot the dev compose stack via `make up`.
# 2. Poll /readyz until the API is healthy (compose --wait covers the
# depends_on healthchecks, but we re-confirm against the public
# readiness endpoint).
# 3. Run zap-baseline.py against http://api:8080 from inside the
# compose network — the ZAP container joins the same network so
# DNS for "api" resolves.
# 4. Upload the HTML + JSON reports as a workflow artifact.
# 5. Tear down the stack (always, even on failure).
#
# Why we don't fail the job on findings: DAST has a high false-positive
# rate, especially against a CMS that ships permissive media endpoints
# and a versioned plugin sandbox. The triage happens off-line against
# the uploaded artifact; the workflow's job is to KEEP RUNNING the scan,
# not to block deploys on it. The security team owns weekly triage.

on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
target_url:
description: Target URL to scan (defaults to the in-network API).
required: false
default: 'http://api:8080'

permissions:
contents: read
issues: write # ZAP action opens an issue when run on a public repo; we keep the permission narrow.

concurrency:
group: zap-dast
cancel-in-progress: false

jobs:
baseline:
name: ZAP baseline scan
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- name: Boot compose stack
run: |
set -euo pipefail
make up
docker compose ps

# The compose 'depends_on: condition: service_healthy' fence is
# the source of truth, but we still wait against /readyz so the
# job log shows a clean ready line before ZAP fires.
- name: Wait for API /readyz
run: |
set -euo pipefail
for i in $(seq 1 60); do
if curl -fsS http://localhost:8080/readyz >/dev/null 2>&1; then
echo "API ready after ${i}s"
exit 0
fi
sleep 1
done
echo "API never became ready" >&2
docker compose logs api | tail -100 >&2
exit 1

- name: Create ZAP working dir
run: |
mkdir -p zap-out
chmod 777 zap-out

# We run ZAP inside the compose network so it can reach "api" by
# service name and so its outbound HTTP traffic stays off the
# host's localhost. zap-baseline.py is the recommended entrypoint
# for "passive scan + safe active checks" — full active scans live
# in a separate, hand-triggered workflow.
- name: Run ZAP baseline
run: |
set +e
docker run --rm \
--network "${COMPOSE_PROJECT_NAME:-gonext}_default" \
-v "${{ github.workspace }}/zap-out:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py \
-t "${{ github.event.inputs.target_url || 'http://api:8080' }}" \
-r zap-report.html \
-J zap-report.json \
-m 5 \
-T 10
zap_status=$?
# zap-baseline returns:
# 0 = no warnings, 1 = warnings, 2 = false positives only,
# 3 = scan failed. We surface a non-zero only on 3 because
# warnings are expected (see workflow header note).
if [ "$zap_status" -eq 3 ]; then
echo "ZAP scan itself failed (exit 3); failing job."
exit 3
fi
echo "ZAP exit: $zap_status (treated as success — findings triaged offline)."
exit 0

- name: Upload ZAP report
if: always()
uses: actions/upload-artifact@v4
with:
name: zap-report-${{ github.run_id }}
path: zap-out/
retention-days: 30
if-no-files-found: warn

- name: Tear down compose stack
if: always()
run: |
docker compose down --volumes --remove-orphans || true
Loading
Loading