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.
This commit is contained in:
@@ -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
@@ -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 .
|
||||
|
||||
@@ -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 .
|
||||
@@ -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 .
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user