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..9be7e61 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@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2 (Gitea Actions runner rejects v4 artifacts -- verified empirically 2026-08-11) + 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@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 + 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