From 8d2ff738c3b621ba0eb79a76e77d66ced2e0d6ed Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Tue, 11 Aug 2026 13:43:08 +0200 Subject: [PATCH 1/2] feat(security): add response security headers + make CI security gates real BACKLOG P0-13: next.config.mjs now sets HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and a same-origin CSP on every route. Previously the only security header came from Vercel's platform default (HSTS) -- nothing was set by the app itself, so it would have silently disappeared on cutover to Coolify/Traefik. Verified at runtime (curl -I against a production build), not just that the build compiles. BACKLOG P0-16: Trivy in security.yml/nightly.yml now actually fails the build on HIGH/CRITICAL findings (`--exit-code 1`) instead of the previous `--exit-code 0` (purely advisory, never blocked a merge). Verified locally against this repo's real dependency tree -- 19 HIGH findings currently exist in pnpm-lock.yaml (next.js, postcss, sharp, nanoid), several in next.js itself relating to middleware/auth bypass. See PR description: this is expected to turn the security.yml `trivy` job red until those are bumped in a follow-up -- that's the fix working as intended, not a regression, but it needs a human decision on timing given the sibling auth-gate PR (#11). `services/scrapers/` stays explicitly excluded from the scan (--skip-dirs) -- that tree is a dated, deliberate keep-decision under active review in a separate thread (BACKLOG P3-3) and is out of scope here. BACKLOG P0-19: dedicated gitleaks job added to security.yml, full git history (--log-opts=--all), pinned + checksum-verified binary. Verified locally against this repo's actual history first (81 commits, no leaks) so this blocking gate doesn't open red on day one. BACKLOG P1-14: SBOM (CycloneDX) + license inventory (`pnpm licenses list`) generated as informational, non-blocking build artifacts. actions/checkout, actions/setup-node, actions/cache, actions/upload-artifact all pinned to commit SHA (was floating @v4 tags) across every workflow file, Trivy/gitleaks pinned to specific released versions instead of floating install scripts. cosign image-signing explicitly NOT added -- there is no container-registry publish step in this repo's CI to attach a signature to yet (Coolify builds directly from git); flagging as a real follow-up once that pipeline exists. BACKLOG P1-15: explicit `permissions: contents: read` + `concurrency` added to every workflow that lacked them (only .github/workflows/ci.yml already had concurrency). Pinned pnpm-store cache added to ci.yml's quality job. Root CODEOWNERS added (`* @sax3l`) so Gitea has someone to assign once required-approvals is turned on (separate repo-settings change, not a file change). The "doctor step" part of P1-15 is added in the follow-up repo- hygiene PR, alongside the `siax:doctor` script it invokes. --- .gitea/workflows/cd.yml | 8 +++ .gitea/workflows/ci.yml | 44 ++++++++++---- .gitea/workflows/health-check.yml | 20 +++++-- .gitea/workflows/nightly.yml | 28 +++++---- .gitea/workflows/security.yml | 96 ++++++++++++++++++++++++++----- .github/workflows/ci.yml | 7 ++- CODEOWNERS | 8 +++ next.config.mjs | 52 +++++++++++++++++ 8 files changed, 221 insertions(+), 42 deletions(-) create mode 100644 CODEOWNERS diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index 89d12d1..c54127e 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -18,6 +18,14 @@ on: push: branches: [main] workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cd-${{ github.ref }} + cancel-in-progress: false # never cancel an in-flight deploy trigger + jobs: deploy: runs-on: ubuntu-latest diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9029ea0..7ca81c7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -9,26 +9,46 @@ name: CI (SIAX Cloud) # is inherited debt; continue-on-error so it never blocks). # * build (BLOCKING) -> `next build`. # -# Nightly re-runs + Trivy security scanning live in nightly.yml and -# security.yml (this file is push/PR-only). +# Nightly re-runs + Trivy/gitleaks security scanning live in nightly.yml and +# security.yml (this file is push/PR-only). BACKLOG P1-15: `permissions`, +# `concurrency`, and a pinned pnpm-store cache added; CODEOWNERS added at the +# repo root (Gitea auto-reviewer assignment); actions pinned to commit SHAs +# (BACKLOG P1-14 digest-pinning). on: push: branches: ["**"] pull_request: workflow_dispatch: +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: quality: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" - name: Enable corepack + pin pnpm@9 (pnpm-lock.yaml is lockfileVersion 9.0) run: | corepack enable corepack prepare pnpm@9 --activate + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Cache pnpm store + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- - name: Install (frozen lockfile) run: pnpm install --frozen-lockfile - name: Typecheck (tsc over src/ + tests/ -- blocking) @@ -44,14 +64,18 @@ jobs: # Kept in ci.yml (advisory) so the protected-branch status check contexts # "CI (SIAX Cloud) / quality (push)" + "CI (SIAX Cloud) / security (push)" # are always reported on push. security.yml is the dedicated scanner on - # push/PR/weekly; this job guarantees the required check exists. + # push/PR/weekly and is now BLOCKING (BACKLOG P0-16) -- this copy is kept + # advisory on purpose: it exists only to guarantee the status-check context + # is always present (see the comment this repeats from before), not as a + # second enforcement point. Do not treat a green run of *this* job as a + # security clearance -- see security.yml's `trivy` + `gitleaks` jobs. security: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Install Trivy + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Install Trivy (pinned) run: | - curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin - - name: Trivy scan (vuln + secrets, advisory) + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.73.0 + - name: Trivy scan (vuln + secrets, advisory -- see security.yml for the blocking scan) run: | - trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 0 . + trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --skip-dirs services/scrapers --exit-code 0 . diff --git a/.gitea/workflows/health-check.yml b/.gitea/workflows/health-check.yml index 90aece5..fa47e4c 100644 --- a/.gitea/workflows/health-check.yml +++ b/.gitea/workflows/health-check.yml @@ -1,18 +1,28 @@ name: Health Check (SIAX Cloud) + +# Weekly informational repo-health ping (no push/PR trigger, nothing to +# block) -- kept advisory on purpose. The BLOCKING secret/vuln gates live in +# security.yml (gitleaks + Trivy, BACKLOG P0-16/P0-19), which runs on every +# push/PR; this job's own Trivy step stays `--exit-code 0` because there is +# no merge for it to gate. on: workflow_dispatch: schedule: - cron: "0 6 * * 1" + +permissions: + contents: read + jobs: health: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Required files run: | - for f in README.md LICENSE .gitignore; do [ -f "$f" ] || echo "::warning file=$f,title=Missing::$f is missing"; done + for f in README.md LICENSE .gitignore CODEOWNERS; do [ -f "$f" ] || echo "::warning file=$f,title=Missing::$f is missing"; done [ -f .gitea/workflows/ci.yml ] || echo "::warning title=No CI::No CI workflow found" - - name: Install Trivy - run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin - - name: Secret scan + - name: Install Trivy (pinned) + run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.73.0 + - name: Secret scan (informational -- see security.yml for the blocking gitleaks gate) run: trivy fs --scanners secret --exit-code 0 . \ No newline at end of file diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index ab56288..0398c3b 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -1,19 +1,25 @@ name: Nightly (SIAX Cloud) # Nightly full-gate re-run so CI exercises the repo even without pushes: -# quality gate + advisory security scan. Fails loudly on regressions -# introduced outside push-triggered runs (e.g. dependency drift). +# quality gate + Trivy scan. Fails loudly on regressions introduced outside +# push-triggered runs (e.g. dependency drift) -- the `security` job below is +# therefore BLOCKING (BACKLOG P0-16), matching security.yml, not advisory: +# a nightly run whose whole purpose is catching drift has no reason to stay +# silent about it. on: workflow_dispatch: schedule: - - cron: "0 4 * * *" # daily 04:00 UTC + - cron: "0 4 * * *" # daily 04:00 UTC + +permissions: + contents: read jobs: quality: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" - name: Enable corepack + pin pnpm@9 @@ -35,10 +41,12 @@ jobs: security: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Install Trivy + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Install Trivy (pinned) run: | - curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin - - name: Trivy fs scan (vuln + secrets, advisory) + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.73.0 + - name: Trivy fs scan (vuln + secrets -- BLOCKING, see BACKLOG P0-16) run: | - trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 0 . + trivy fs --scanners vuln,secret --severity HIGH,CRITICAL \ + --skip-dirs services/scrapers \ + --exit-code 1 . diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml index 5717347..00331b3 100644 --- a/.gitea/workflows/security.yml +++ b/.gitea/workflows/security.yml @@ -1,30 +1,96 @@ name: Security Scan (SIAX Cloud) -# Advisory security scanning on every push/PR + weekly re-run, matching the -# fleet convention (--exit-code 0 so findings never block merges; they are -# triaged via the repo's security channel and BACKLOG). +# BACKLOG P0-16, P0-19, P1-14: this used to run Trivy with `--exit-code 0`, +# i.e. purely advisory -- findings never blocked a merge. It now fails the +# build for real on HIGH/CRITICAL, plus a dedicated gitleaks step (REPO-15: +# the only prior secret sweep was hand-rolled regex, not a real +# secret-scanning tool), plus an SBOM + license inventory as build artifacts. # -# NOTE: findings on services/scrapers/* (FlareSolverr/Tor/curl_cffi) are -# expected and out of scope for CI gating -- kept per the account owner's -# dated, attributed decision (Simon Axelsson, 2026-08-11, interactive -# prompt; no rationale beyond "keep it" given or invented). See SECURITY.md -# and docs/compliance/GOLDEN-STANDARD-REPORT.md control C6 for full context. +# `services/scrapers/` is excluded from the Trivy scan (--skip-dirs). +# Findings there (waf_bypass.py, engine*.py, curl_cffi/cloudscraper/nodriver) +# are expected and out of scope for CI gating -- kept per the account +# owner's dated, attributed decision (Simon Axelsson, 2026-08-11; see +# SECURITY.md and docs/compliance/GOLDEN-STANDARD-REPORT.md control C6). +# That question is explicitly out of scope for this workflow change and +# must not be the reason this gate starts failing for everyone. Every other +# path in the repo is now enforced for real, including the rest of +# services/ (the FastAPI engine itself). on: push: branches: ["**"] pull_request: workflow_dispatch: schedule: - - cron: "0 5 * * 1" # weekly Monday re-scan + - cron: "0 5 * * 1" # weekly Monday re-scan + +permissions: + contents: read + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true jobs: - security: + trivy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Install Trivy + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Install Trivy (pinned) run: | - curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin - - name: Trivy fs scan (vuln + secrets, advisory) + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.73.0 + - name: Trivy fs scan (vuln + secrets -- BLOCKING) run: | - trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 0 . + trivy fs --scanners vuln,secret --severity HIGH,CRITICAL \ + --skip-dirs services/scrapers \ + --exit-code 1 . + - name: SBOM (CycloneDX, informational artifact -- BACKLOG P1-14) + if: always() + run: | + trivy fs --format cyclonedx --output sbom.cdx.json --skip-dirs services/scrapers . + - name: Upload SBOM + if: always() + continue-on-error: true # informational artifact; must never block the blocking scan above + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sbom-cyclonedx + path: sbom.cdx.json + retention-days: 90 + + gitleaks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 # gitleaks needs full history, not just the tip commit + - name: Install gitleaks (pinned + checksum-verified) + run: | + curl -sSL -o gitleaks.tar.gz https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz + echo "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz gitleaks + sudo mv gitleaks /usr/local/bin/gitleaks + - name: gitleaks scan (full history -- BLOCKING) + run: gitleaks detect --source . --log-opts="--all" --redact --verbose + + license-inventory: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + - name: Enable corepack + pin pnpm@9 + run: | + corepack enable + corepack prepare pnpm@9 --activate + - name: Install (frozen lockfile) + run: pnpm install --frozen-lockfile + - name: License inventory (informational -- BACKLOG P1-14) + run: | + pnpm licenses list --json > licenses.json || true + pnpm licenses list + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + continue-on-error: true # informational artifact; must never block CI + with: + name: license-inventory + path: licenses.json + retention-days: 90 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73a27fd..a6e022b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ on: push: branches-ignore: [main] +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -39,8 +42,8 @@ jobs: name: Typecheck + test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '22' - name: Enable corepack (pnpm) diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..0134e0f --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,8 @@ +# BACKLOG P1-15 (CI-14): required reviewers for Gitea's branch-protection +# "required approvals" setting to have anyone to assign. This file alone +# does not turn on required-approvals enforcement -- that is a Gitea +# repo-settings change (branch protection), tracked separately; see +# docs/compliance/BACKLOG.md P1-15 ("required_approvals: 0"). +# +# Gitea CODEOWNERS syntax matches GitHub's: path pattern, then owner(s). +* @sax3l diff --git a/next.config.mjs b/next.config.mjs index 4cd9948..0728b76 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,3 +1,47 @@ +// BACKLOG P0-13 (SEC-06): the app previously shipped no security headers of +// its own -- HSTS existed only as a platform default injected by Vercel, so +// it would have silently disappeared on cutover to Coolify/Traefik (see +// docs/architecture/ARCHITECTURE.md §5/§8). These are set here, at the +// framework level, so they travel with the app regardless of host. +// +// CSP is same-origin-only (`'self'`) plus the minimum carve-outs this app +// actually needs today: +// - `'unsafe-inline'` on script-src/style-src: Next.js's App Router injects +// small inline hydration/RSC payload scripts and Tailwind can emit +// inline style attributes; this repo doesn't yet wire a per-request +// nonce through middleware+layout (a real follow-up -- see BACKLOG -- +// but out of scope for this pass, which fixes "zero headers", not "zero +// inline script tolerance"). +// - `form-action` allows Stripe's hosted Checkout domains: the payment +// flow (app/api/payments/[order_id]/deposit) does a full top-level +// redirect to a Stripe-hosted page, never an embedded iframe/script +// (grep-verified: no @stripe/stripe-js dependency, no client-side +// Stripe.js load anywhere in app/). +// No other third-party origins are contacted from the browser (no +// analytics, no next/font/google, no embedded widgets -- grep-verified). +const securityHeaders = [ + { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()" }, + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "form-action 'self' https://checkout.stripe.com https://api.stripe.com", + "frame-ancestors 'none'", + "base-uri 'self'", + "object-src 'none'", + ].join("; "), + }, +] + /** @type {import('next').NextConfig} */ const nextConfig = { typescript: { @@ -6,6 +50,14 @@ const nextConfig = { images: { unoptimized: true, }, + async headers() { + return [ + { + source: "/(.*)", + headers: securityHeaders, + }, + ] + }, } export default nextConfig -- 2.54.0 From d6adad7caff0dac1896684d6c9e05159f4198396 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Tue, 11 Aug 2026 13:46:36 +0200 Subject: [PATCH 2/2] fix(ci): use actions/upload-artifact@v3, not v4, on this Gitea runner Verified empirically by pushing and inspecting the real run (git.siax.io/sax3l/0pt/actions/runs/4788): v4 (pinned SHA) failed with conclusion=failure on both upload steps. continue-on-error already kept this from breaking the build, but the artifacts silently weren't being saved. v3.2.2 is the known-compatible version for self-hosted Gitea's artifact backend; re-verify after push. --- .gitea/workflows/security.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml index 00331b3..9be7e61 100644 --- a/.gitea/workflows/security.yml +++ b/.gitea/workflows/security.yml @@ -50,7 +50,7 @@ jobs: - name: Upload SBOM if: always() continue-on-error: true # informational artifact; must never block the blocking scan above - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2 (Gitea Actions runner rejects v4 artifacts -- verified empirically 2026-08-11) with: name: sbom-cyclonedx path: sbom.cdx.json @@ -88,7 +88,7 @@ jobs: run: | pnpm licenses list --json > licenses.json || true pnpm licenses list - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + - uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2 (Gitea Actions runner rejects v4 artifacts -- verified empirically 2026-08-11) continue-on-error: true # informational artifact; must never block CI with: name: license-inventory -- 2.54.0