feat(security): add response security headers + make CI security gates real #19

Closed
admin wants to merge 2 commits from ci/security-headers-and-scanning into main
8 changed files with 221 additions and 42 deletions
+8
View File
@@ -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
+34 -10
View File
@@ -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 .
+15 -5
View File
@@ -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 .
+18 -10
View File
@@ -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 .
+81 -15
View File
@@ -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
+5 -2
View File
@@ -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)
+8
View File
@@ -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
+52
View File
@@ -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