Security: harden Content-Security-Policy and verify report pipeline #3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | ||
|
Check failure on line 1 in .github/workflows/ci.yml
|
||
| on: | ||
| push: | ||
| branches: [main, develop] | ||
| pull_request: | ||
| branches: [main, develop] | ||
| workflow_dispatch: | ||
| inputs: | ||
| force_full_run: | ||
| description: 'Force a full CI run' | ||
| type: boolean | ||
| default: false | ||
| jobs: | ||
| # ────────────────────────────────────────────── | ||
| # Job 0: Detect changes | ||
| # ────────────────────────────────────────────── | ||
| changes: | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| backend: ${{ steps.filter.outputs.backend }} | ||
| client: ${{ steps.filter.outputs.client }} | ||
| sdk: ${{ steps.filter.outputs.sdk }} | ||
| shared: ${{ steps.filter.outputs.shared }} | ||
| force_full_run: ${{ github.event.inputs.force_full_run || 'false' }} | ||
| steps: | ||
| - uses: actions/checkout@v7 | ||
| - uses: dorny/paths-filter@v4 | ||
| id: filter | ||
| with: | ||
| filters: | | ||
| backend: | ||
| - 'backend/**' | ||
| - 'shared/**' | ||
| - 'package.json' | ||
| - 'package-lock.json' | ||
| client: | ||
| - 'client/**' | ||
| - 'shared/**' | ||
| - 'package.json' | ||
| - 'package-lock.json' | ||
| sdk: | ||
| - 'sdk/**' | ||
| - 'shared/**' | ||
| - 'package.json' | ||
| - 'package-lock.json' | ||
| shared: | ||
| - 'shared/**' | ||
| - 'package.json' | ||
| - 'package-lock.json' | ||
| # ────────────────────────────────────────────── | ||
| # Job 1: Validate environment variables | ||
| # Must pass before the build job starts. | ||
| # ────────────────────────────────────────────── | ||
| validate-env: | ||
| name: Validate Environment Variables | ||
| runs-on: ubuntu-latest | ||
| needs: changes | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.client == 'true' || needs.changes.outputs.sdk == 'true' }} | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v7 | ||
| - name: Set up Node.js | ||
| uses: actions/setup-node@v7 | ||
| with: | ||
| node-version: "20" | ||
| # ── Structural check (always — PRs + pushes) ────────────────────────── | ||
| # Secret-free: asserts manifests and .env.example files are in sync. | ||
| # Fails fast on config drift without needing real secrets, so forked / | ||
| # contributor PRs are not broken by absent secrets. | ||
| - name: Structural env check (manifests ↔ .env.example) | ||
| run: | | ||
| node scripts/check-env-docs.js | ||
| node backend/scripts/validate-env.js --structural | ||
| node client/scripts/validate-env.js --structural | ||
| # ── Strict presence check (pushes to protected branches only) ───────── | ||
| # Real secrets are injected here; the validators hard-fail if any | ||
| # REQUIRED var is missing (issue #601 — CI fails fast). | ||
| - name: Validate client environment variables (strict) | ||
| if: github.event_name == 'push' | ||
| env: | ||
| NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} | ||
| NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} | ||
| NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} | ||
| STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} | ||
| NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} | ||
| run: node client/scripts/validate-env.js | ||
| - name: Validate backend environment variables (strict) | ||
| if: github.event_name == 'push' | ||
| env: | ||
| SUPABASE_URL: ${{ secrets.SUPABASE_URL }} | ||
| SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} | ||
| SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} | ||
| JWT_SECRET: ${{ secrets.JWT_SECRET }} | ||
| ADMIN_API_KEY: ${{ secrets.ADMIN_API_KEY }} | ||
| SMTP_HOST: ${{ secrets.SMTP_HOST }} | ||
| SMTP_PORT: ${{ secrets.SMTP_PORT }} | ||
| SMTP_USER: ${{ secrets.SMTP_USER }} | ||
| SMTP_PASS: ${{ secrets.SMTP_PASS }} | ||
| SOROBAN_CONTRACT_ADDRESS: ${{ secrets.SOROBAN_CONTRACT_ADDRESS }} | ||
| STELLAR_NETWORK_URL: ${{ secrets.STELLAR_NETWORK_URL }} | ||
| run: node backend/scripts/validate-env.js | ||
| # ────────────────────────────────────────────── | ||
| # Job 2: Validate dependencies | ||
| # Ensures no 'latest' versions and lockfiles are up to date | ||
| # ────────────────────────────────────────────── | ||
| validate-dependencies: | ||
| name: Validate Dependencies | ||
| runs-on: ubuntu-latest | ||
| needs: [changes, validate-env] | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.client == 'true' || needs.changes.outputs.sdk == 'true' }} | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v7 | ||
| - name: Reject non-npm lockfiles (pnpm-lock.yaml, yarn.lock) | ||
| # This repo standardises on npm. Committing pnpm or yarn lockfiles | ||
| # causes non-reproducible installs (issue #557). | ||
| run: | | ||
| bad_files=$(find . -not -path './.git/*' \( -name 'pnpm-lock.yaml' -o -name 'yarn.lock' \)) | ||
| if [ -n "$bad_files" ]; then | ||
| echo "❌ Non-npm lockfile(s) found — only package-lock.json is permitted:" | ||
| echo "$bad_files" | ||
| echo "" | ||
| echo "Remove the file(s) and add them to .gitignore. See issue #557." | ||
| exit 1 | ||
| fi | ||
| echo "✅ No pnpm or yarn lockfiles found" | ||
| - name: Set up Node.js | ||
| uses: actions/setup-node@v7 | ||
| with: | ||
| node-version: "20" | ||
| - name: Validate intentional package names | ||
| run: node scripts/check-package-names.js | ||
| - name: Check for 'latest' in client package.json | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.client == 'true' }} | ||
| run: | | ||
| if grep -q '"latest"' client/package.json; then | ||
| echo "❌ Error: 'latest' version found in client/package.json" | ||
| echo "Please specify an exact version or semver range." | ||
| exit 1 | ||
| fi | ||
| echo "✅ No 'latest' versions found in client/package.json" | ||
| - name: Check for 'latest' in backend package.json | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.backend == 'true' }} | ||
| run: | | ||
| if grep -q '"latest"' backend/package.json; then | ||
| echo "❌ Error: 'latest' version found in backend/package.json" | ||
| echo "Please specify an exact version or semver range." | ||
| exit 1 | ||
| fi | ||
| echo "✅ No 'latest' versions found in backend/package.json" | ||
| - name: Validate client dependencies | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.client == 'true' }} | ||
| working-directory: client | ||
| run: node scripts/validate-deps.js | ||
| - name: Verify client lockfile is committed | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.client == 'true' }} | ||
| run: | | ||
| if [ ! -f "client/package-lock.json" ]; then | ||
| echo "❌ Error: client/package-lock.json not found" | ||
| echo "Please commit the lockfile to ensure reproducible builds." | ||
| exit 1 | ||
| fi | ||
| echo "✅ Client lockfile is committed" | ||
| - name: Verify backend lockfile is committed | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.backend == 'true' }} | ||
| run: | | ||
| if [ ! -f "backend/package-lock.json" ]; then | ||
| echo "❌ Error: backend/package-lock.json not found" | ||
| echo "Please commit the lockfile to ensure reproducible builds." | ||
| exit 1 | ||
| fi | ||
| echo "✅ Backend lockfile is committed" | ||
| - name: Verify exactly one Next.js config exists (client/next.config.mjs) | ||
| run: | | ||
| count=$(find . -maxdepth 1 -name "next.config.*" | wc -l) | ||
| if [ "$count" -ne 0 ]; then | ||
| echo "❌ Stale root-level next.config.* file(s) detected:" | ||
| find . -maxdepth 1 -name "next.config.*" | ||
| echo "Only client/next.config.mjs should exist." | ||
| exit 1 | ||
| fi | ||
| if [ ! -f "client/next.config.mjs" ]; then | ||
| echo "❌ client/next.config.mjs is missing." | ||
| exit 1 | ||
| fi | ||
| client_count=$(find client -maxdepth 1 -name "next.config.*" | wc -l) | ||
| if [ "$client_count" -ne 1 ]; then | ||
| echo "❌ Expected exactly 1 Next.js config under client/, found $client_count:" | ||
| find client -maxdepth 1 -name "next.config.*" | ||
| exit 1 | ||
| fi | ||
| echo "✅ Exactly one Next.js config found: client/next.config.mjs" | ||
| - name: Install client dependencies and verify lockfile | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.client == 'true' }} | ||
| working-directory: client | ||
| run: | | ||
| npm ci | ||
| if ! git diff --exit-code package-lock.json; then | ||
| echo "❌ Error: package-lock.json is out of sync" | ||
| echo "Please run 'npm install' and commit the updated lockfile." | ||
| exit 1 | ||
| fi | ||
| echo "✅ Client lockfile is up to date" | ||
| - name: Install backend dependencies and verify lockfile | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.backend == 'true' }} | ||
| working-directory: backend | ||
| run: | | ||
| npm ci | ||
| if ! git diff --exit-code package-lock.json; then | ||
| echo "❌ Error: package-lock.json is out of sync" | ||
| echo "Please run 'npm install' and commit the updated lockfile." | ||
| exit 1 | ||
| fi | ||
| echo "✅ Backend lockfile is up to date" | ||
| # ────────────────────────────────────────────── | ||
| # Job 2b: Validate issue governance (labels, triage policy, templates) | ||
| # Runs unconditionally — governance files must always be consistent. | ||
| # Refs: #114, docs/issue-triage-policy.md, .github/labels.yml | ||
| # ────────────────────────────────────────────── | ||
| validate-governance: | ||
| name: Validate Issue Governance | ||
| runs-on: ubuntu-latest | ||
| needs: changes | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v7 | ||
| - name: Set up Node.js | ||
| uses: actions/setup-node@v7 | ||
| with: | ||
| node-version: "20" | ||
| - name: Validate labels taxonomy, triage policy, and issue templates | ||
| run: node scripts/check-issue-governance.js | ||
| - name: Run governance unit tests | ||
| run: node --test scripts/check-issue-governance.test.js | ||
| - name: Check label drift against GitHub | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| GITHUB_REPOSITORY: ${{ github.repository }} | ||
| run: node scripts/check-label-drift.js | ||
| - name: Validate CONTRIBUTING.md onboarding guide | ||
| run: node scripts/check-contributing.js | ||
| - name: Run CONTRIBUTING guide unit tests | ||
| run: node --test scripts/check-contributing.test.js | ||
| # ────────────────────────────────────────────── | ||
| # Job 3: Dependency vulnerability scan (issue #1079) | ||
| # Re-establishes the audit gate that pr-728 removed. Runs once per | ||
| # npm workspace and gates on scripts/security-audit-gate.js, which | ||
| # reads .github/dependency-audit-allowlist.json and docs/security/ | ||
| # dependency-triage.md. Drops --omit=dev so that dev-time CVEs | ||
| # (e.g. compromised eslint plugins) are surfaced. | ||
| # | ||
| # Skip-propagation: when no workspace files change, every matrix entry | ||
| # is skipped, so the aggregate `security-audit` is `skipped`. Downstream | ||
| # `build-client`, `test-client`, `test-backend`, `test-sdk` each declare | ||
| # `needs: security-audit`, so they will also be skipped. This is NOT a | ||
| # regression: each downstream already has its own workspace-gated `if:` | ||
| # and would skip in that scenario anyway. Do NOT remove the element-level | ||
| # `if:` on this matrix, that creates a real regression where downstream | ||
| # tests run without their own workspace being touched. | ||
| # ────────────────────────────────────────────── | ||
| security-audit: | ||
| name: Security Audit (${{ matrix.workspace }}) | ||
| runs-on: ubuntu-latest | ||
| needs: changes | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs[matrix.workspace] == 'true' }} | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| workspace: [backend, client, sdk, shared] | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v7 | ||
| - name: Set up Node.js | ||
| uses: actions/setup-node@v7 | ||
| with: | ||
| node-version: "20" | ||
| cache: "npm" | ||
| cache-dependency-path: ${{ matrix.workspace }}/package-lock.json | ||
| - name: Install ${{ matrix.workspace }} dependencies | ||
| working-directory: ${{ matrix.workspace }} | ||
| run: npm ci --ignore-scripts | ||
| - name: Run dependency audit gate | ||
| working-directory: ${{ matrix.workspace }} | ||
| run: node ../scripts/security-audit-gate.js ${{ matrix.workspace }} ${{ matrix.workspace }} | ||
| # ────────────────────────────────────────────── | ||
| # Job 4: Build the client | ||
| # Only runs if validate-env passes. | ||
| # ────────────────────────────────────────────── | ||
| build-client: | ||
| name: Build Client | ||
| runs-on: ubuntu-latest | ||
| needs: [changes, validate-dependencies, security-audit] | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.client == 'true' }} | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v7 | ||
| - name: Set up Node.js | ||
| uses: actions/setup-node@v7 | ||
| with: | ||
| node-version: "20" | ||
| cache: "npm" | ||
| cache-dependency-path: client/package-lock.json | ||
| - name: Install client dependencies | ||
| working-directory: client | ||
| run: npm ci | ||
| - name: Build client | ||
| working-directory: client | ||
| env: | ||
| NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} | ||
| NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} | ||
| SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} | ||
| NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} | ||
| STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} | ||
| NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} | ||
| run: npm run build | ||
| # ────────────────────────────────────────────── | ||
| # Job 4: SDK — install, build, test | ||
| # Runs in parallel with build-client and test-backend. | ||
| # Blocks merges on any SDK regression. | ||
| # ────────────────────────────────────────────── | ||
| test-sdk: | ||
| name: SDK — Build & Test | ||
| runs-on: ubuntu-latest | ||
| needs: [changes, validate-env, security-audit] | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.sdk == 'true' }} | ||
| defaults: | ||
| run: | ||
| working-directory: sdk | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v7 | ||
| - name: Set up Node.js | ||
| uses: actions/setup-node@v7 | ||
| with: | ||
| # Matches sdk/.tool-versions (nodejs 23.4.0) | ||
| node-version: "23.4.0" | ||
| cache: "npm" | ||
| cache-dependency-path: sdk/package-lock.json | ||
| - name: Install dependencies | ||
| run: npm ci | ||
| - name: Run typecheck | ||
| run: npm run typecheck | ||
| - name: Run lint | ||
| run: npm run lint | ||
| - name: Build | ||
| run: npm run build | ||
| - name: Run tests | ||
| # ESM Jest requires the experimental VM modules flag | ||
| env: | ||
| NODE_OPTIONS: --experimental-vm-modules | ||
| run: npm test -- --ci --coverage | ||
| - name: Upload test artifacts on failure | ||
| uses: actions/upload-artifact@v7 | ||
| if: failure() | ||
| with: | ||
| name: sdk-test-results | ||
| path: | | ||
| sdk/coverage/ | ||
| retention-days: 7 | ||
| # ────────────────────────────────────────────── | ||
| # Job 5: Run backend tests | ||
| # Only runs if validate-env passes. | ||
| # ────────────────────────────────────────────── | ||
| test-client: | ||
| name: Test Client | ||
| runs-on: ubuntu-latest | ||
| needs: [changes, validate-env, security-audit] | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.client == 'true' }} | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v7 | ||
| - name: Set up Node.js | ||
| uses: actions/setup-node@v7 | ||
| with: | ||
| node-version: "20" | ||
| cache: "npm" | ||
| cache-dependency-path: client/package-lock.json | ||
| - name: Install client dependencies | ||
| working-directory: client | ||
| run: npm ci | ||
| - name: Run client unit tests | ||
| working-directory: client | ||
| run: npm test | ||
| test-backend: | ||
| name: Test Backend | ||
| runs-on: ubuntu-latest | ||
| needs: [changes, validate-dependencies, security-audit] | ||
| if: ${{ needs.changes.outputs.force_full_run == 'true' || needs.changes.outputs.backend == 'true' }} | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v7 | ||
| - name: Set up Node.js | ||
| uses: actions/setup-node@v7 | ||
| with: | ||
| node-version: "20" | ||
| cache: "npm" | ||
| cache-dependency-path: backend/package-lock.json | ||
| - name: Install backend dependencies | ||
| working-directory: backend | ||
| run: npm install | ||
| - name: Run backend tests | ||
| working-directory: backend | ||
| env: | ||
| SUPABASE_URL: ${{ secrets.SUPABASE_URL }} | ||
| SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} | ||
| SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} | ||
| PORT: ${{ secrets.PORT }} | ||
| JWT_SECRET: ${{ secrets.JWT_SECRET }} | ||
| SMTP_HOST: ${{ secrets.SMTP_HOST }} | ||
| SMTP_PORT: ${{ secrets.SMTP_PORT }} | ||
| SMTP_USER: ${{ secrets.SMTP_USER }} | ||
| SMTP_PASS: ${{ secrets.SMTP_PASS }} | ||
| STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} | ||
| STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} | ||
| SOROBAN_CONTRACT_ADDRESS: ${{ secrets.SOROBAN_CONTRACT_ADDRESS }} | ||
| STELLAR_NETWORK_URL: ${{ secrets.STELLAR_NETWORK_URL }} | ||
| run: npm test -- --coverage --ci | ||