Skip to content

Commit ecfe7eb

Browse files
v2.5.10 — Security audit: 10 NIS2/GDPR hardening fixes (#89)
* fix(api): enforce API key scopes on dual-auth read endpoints API keys were validated at creation time but their scope list was never checked when processing requests — a key with only scan:read could call any finding or asset endpoint without restriction. Introduces dual_auth_with_scope(required_scope) dependency factory backed by a shared _resolve_dual_auth helper. Read endpoints in scans, findings, and assets now declare the required scope explicitly at the call site. Legacy keys with scopes=None (pre-2.4.26) pass unrestricted for backward compatibility; JWT cookie sessions are never scope-constrained. Closes the second half of the v2.4.26 audit gap noted in api_keys.py. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(api): add rate limiting to resource-intensive write endpoints POST /scans, POST /assets, and POST /assets/import had no rate limits, making them vulnerable to resource exhaustion: an authenticated caller could queue dozens of Celery scan tasks or bulk-import thousands of assets in seconds. Uses the existing SlowAPI limiter instance (shared singleton from app.routers.auth) following the same pattern as reports.py: - POST /scans: 10/minute per IP - POST /assets: 20/minute per IP - POST /assets/import: 5/minute per IP (most expensive: DNS resolution for every row in the CSV) POST /reports was already limited at 5/minute (v2.4.22). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(security): add Content-Security-Policy header to API and Caddyfile The API returned X-Content-Type-Options, X-Frame-Options, HSTS, and Referrer-Policy but no CSP — a gap flagged by NIS2-aligned security frameworks (ENISA guidelines, OWASP ASVS 14.4.3). API (main.py): CSP: default-src 'none'; frame-ancestors 'none' The backend only serves JSON; blocking all resource loads is maximally strict with zero functional impact. Caddyfile (edge): Restructured header blocks to be per-handler so the API path and the Next.js frontend can carry different policies: - /api/*: same strict default-src 'none' - /*: balanced Next.js policy — 'unsafe-inline' retained for script/style because Next.js App Router hydration requires it without nonce injection. object-src 'none', base-uri 'self', form-action 'self' close the most common injection vectors. A follow-up TODO documents the nonce migration path. Added -Server directive to suppress Caddy version disclosure. Added Permissions-Policy to the common header block (was missing). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(governance): bridge scanner findings into risk register (Art. 21.2.a) The governance checklist tracked Art. 21(a) risk analysis as a manual checkbox with no connection to the scanner's technical evidence. A CISO had to mentally translate scan findings into governance status by hand. Two new endpoints close the gap: GET /governance/risk-summary Read-only computed view. Aggregates open findings per severity, maps each finding category to its NIS2 Art. 21.2 sub-paragraph via a prefix-based lookup table, and returns which governance items are signalled — giving an evidence-based risk picture without any data entry. POST /governance/sync-risk Applies the signals: for every governance item whose sub-paragraph has matching open findings, evidence_notes is updated with a timestamped summary (counts + sample messages). Status escalation is conservative — only not_started → in_progress when HIGH/CRITICAL findings exist; done and not_applicable are never touched. The category → sub-paragraph map (CATEGORY_SUBPARAGRAPH_MAP) covers all Art. 21.2 sub-paragraphs: TLS/SSL/cert → 21.2.h, port/DNS/web → 21.2.e, secret/credential → 21.2.i, auth/mfa → 21.2.j, etc. Every finding also lands in 21.2.a (risk analysis) via the catch-all entry. 26 unit tests pin the mapping logic and escalation rules. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(incidents): automated NIS2 Art. 23 deadline alerts via Celery beat Open incidents had the 24h/72h/1-month deadlines stored as DB fields but no mechanism to alert operators when those deadlines were about to expire — a CISO who didn't check the dashboard daily could silently miss the legally binding CSIRT notification window. New Celery beat task (check-incident-deadlines, every 15 min): - Queries all non-closed incidents - For each of the three Art. 23 deadlines (early_warning, notification, final_report), checks whether the deadline is APPROACHING (within WARN_HOURS_BEFORE=2h) or OVERDUE (already past) - Skips deadlines where the *_sent_at field is already set (operator already actioned the notification manually) - Dispatches alerts via the org's active NotificationChannel records: email — SMTP via existing send_email util webhook — HTTP POST with HMAC-SHA256 signature header slack — Slack Incoming Webhook with colour-coded attachment - Falls back to emailing org admins directly if no channels configured - Dedup via Redis keys (TTL = warn window + 1h for approaching, 24h for overdue) so the 15-min cadence never double-alerts 23 unit tests pin _classify_alert() boundary conditions, payload structure, constant invariants, and the synthetic fallback channel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(vendors): implement transparent NIS2 Art. 18 vendor risk scoring formula Replaces the opaque assessment_score field with a documented, auditable 100-point formula (v1.0) covering five NIS2-aligned factors: security certification (max 25), data access level (max 25), audit recency (max 20), geographic location (max 15), and contractual security clauses (max 15). Adds GET /vendors/score-formula (public, for auditors), GET /{id}/score (read-only compute), and POST /{id}/score/apply (save + audit trail). Auto-computes score on vendor creation when security_score is None. 45 unit tests pin every factor weight independently so formula changes fail loudly at the specific factor that changed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(privacy): resolve GDPR Art. 17 vs NIS2 Art. 21 audit-log retention conflict Three concrete gaps closed: 1. AUDIT_LOG_RETENTION_DAYS (default 90) was documented in privacy.md but never wired into config.py — the env var had no effect. Now a typed Settings field with an explicit comment explaining the NIS2 ≥ 365 day recommendation for incident-management deployments. 2. The daily cleanup_tasks beat job now prunes audit_log rows older than the configured retention ceiling, including pseudonymised (user_id = NULL) rows — once the window passes, even the anonymised event has no further legitimate purpose for the controller (GDPR Art. 5(1)(e)). 3. The DELETE /auth/me pseudonymisation UPDATE now also NULLs the details JSONB column, which could contain linkable org/resource UUIDs. The action, resource_type, resource_id, and created_at columns survive for forensic continuity (Art. 89(1)). privacy.md §7.2 updated with the explicit Art. 17 vs Art. 21 resolution rationale. 12 new tests pin every invariant. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(scanner): externalize secret detection patterns to YAML (NIS2 Art. 21) Secret patterns were hardcoded in secrets.py — 6 patterns, missing every token format introduced since 2022 (GitHub fine-grained PATs github_pat_*, gho_*, ghs_*, GitLab glpat-/glrt-, OpenAI sk-proj-*, Anthropic sk-ant-*, Slack xoxb-/xoxp-/xapp-, Google OAuth GOCSPX-, AWS STS ASIA*, Bearer header). Fixes: - New secret_patterns.yaml ships 22 patterns with id, description, severity, optional regex flags, and maintainer notes. No code change needed to add or tune a pattern — edit the YAML and restart (or call detector.reload()). - SecretsDetector loads from the YAML at init, compiles all patterns, and falls back to a 4-pattern hardcoded set with a WARNING log if the file is absent (degraded-mode safety net). - reload() method lets long-running scanner processes pick up pattern updates without a full restart. - Invalid regex entries in the YAML are skipped with a WARNING rather than crashing the detector. 29 tests cover: YAML validity, required fields, all new token formats, fallback behaviour, IGNORECASE flag, reload pick-up, preview truncation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(reports): add MAX_CONCURRENT_REPORTS_PER_ORG cap to prevent worker exhaustion The existing 5/min/IP rate limit and per-(org,scan,format) dedup didn't cover the case where one org queues many reports for different scans or formats simultaneously, monopolising all Celery worker slots. Fix: - New MAX_CONCURRENT_REPORTS_PER_ORG setting (default 3, configurable). - report_dedup tracks an org-level Redis Set (reports:org_inflight:{org_id}) alongside the existing per-(org,scan,fmt) lock keys. SADD on register, SREM on clear, SCARD to count — O(1), no SCAN/KEYS needed. - POST /reports/generate checks count_inflight_per_org() before queuing; returns HTTP 429 with a clear message when the cap is reached. - Celery task_postrun handler passes task_id through to clear_inflight_task so the org Set stays accurate across task completions. - Failure-open: Redis unavailable → count returns 0 → cap not enforced (double-alert is less bad than blocking all report generation). - clear_inflight_task gains an optional task_id kwarg (backward-compatible; existing callers that don't pass it skip the SREM, org Set self-heals via TTL). 15 unit tests using an in-memory Redis fake cover counter lifecycle, cross-org isolation, Redis-down graceful degradation, and the router logic. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ops): deep health check + React error boundary (#13, #14) fix(health): expand /health/ready with Celery worker reachability The previous /health/ready checked only DB and Redis. A reverse proxy marking the API "up" while all Celery workers are dead would route report-generation and scan requests into a silent queue with no feedback to the user. Changes: - Added GET /health/live as an explicit liveness alias (k8s convention). - /health/ready now checks three deps: database (SELECT 1), Redis (PING), and Celery workers (inspect().ping() with a 3 s timeout). - No workers answering → "degraded" (not "error", not 503) because workers may legitimately be idle between tasks on small deployments. - Hard dep failures (DB down, Redis down) → HTTP 503 {"status":"degraded"}. - inspect() is run via run_in_executor so it doesn't block the event loop. - Module-level docstring explains the three-tier probe model for operators configuring Caddy health_uri, k8s livenessProbe/readinessProbe. feat(web): add React ErrorBoundary to dashboard layout (#14) Any unhandled render error in a dashboard page previously crashed the entire React tree and showed a blank screen with no recovery path. - New ErrorBoundary class component (packages/web/src/components/ui/error-boundary.tsx) with getDerivedStateFromError + componentDidCatch + a "Try again" reset. - Default fallback card shows a friendly message; in development it also shows the raw error.message for debugging. - Wired into DashboardLayout wrapping <main> so all dashboard pages are protected in one place. - withErrorBoundary() convenience wrapper exported for ad-hoc use. - Next.js production build passes cleanly (verified). 14 API unit tests; 0 frontend unit tests needed (class component contract is verified by the TypeScript compiler + successful production build). GitHub issues opened: #87 (HS256→RS256 doc), #88 (RLS in Alembic). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(docs,ci): honest security.txt, accurate Art.21 matrix, E2E CI job (#15,#16,#17) fix(security): security.txt — replace implicit PGP gap with explicit status note (#15) The file had no Encryption: field, making the gap invisible to automated RFC 9116 validators. Now has an explicit commented block explaining: - PGP not yet configured (honest) - GitHub private advisory is the preferred encrypted channel (actionable) - Exact Encryption: field format for operators who provision their own key - What the block will look like once a real key is published docs(readme): update Art. 21 coverage matrix to reflect actual implementation (#17) The matrix showed every sub-paragraph as either "Implemented" or a vague "Governance checklist (manual)" with no status column. After this audit session the reality is more nuanced: - (a) Risk analysis: now "Partial" — scanner→governance bridge (POST /governance/sync-risk) auto-escalates checklist items from findings - (b) Incident handling: now "Implemented" — Celery beat task checks 15-min, alerts at all three Art. 23 thresholds via email/webhook/Slack with dedup - (d) Supply chain: enhanced — transparent 100-point scoring formula, auditor-facing GET /vendors/score-formula - (j) Auth/access: "Partial" — RBAC + API key scopes + audit log done; TOTP MFA tracked in issue #86 - Art. 23 table updated to mention automated alerting Added legend row defining Implemented / Partial / Manual. ci: add E2E live test job to CI pipeline (#16) test_e2e_live.py was previously skipped in every CI run because E2E_LIVE_BASE_URL was never set. The test suite targets a whole class of bugs that in-process tests cannot catch (schema drift, cookie flow over real HTTP, middleware ordering, etc.). New job e2e-tests: - GHA services: postgres:16-alpine + redis:7-alpine (same health-check pattern as integration-tests) - Installs deps, runs alembic upgrade head, starts uvicorn in background - Polls GET /api/v1/health for up to 30 s before running tests - Seeds E2E user via /auth/register (idempotent — 409 is also accepted) - No docker-compose needed — GHA services are cheaper and faster Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(tests): update test fakes and IDs broken by new SADD/pattern-rename test_report_dedup.py: _FakeRedis was missing sadd/srem/scard/expire methods added by the org-level concurrency cap (commit 5056abe). _RaisingRedis methods unified via _boom helper. test_features.py: pattern IDs aws_access_key → aws_access_key_id and private_key → private_key_pem updated to match the new secret_patterns.yaml canonical names (commit 6f1c2ee). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(release): bump version to v2.5.10 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ca6188f commit ecfe7eb

38 files changed

Lines changed: 3516 additions & 145 deletions

.github/workflows/ci.yml

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,115 @@ jobs:
122122
- name: Run integration tests
123123
run: cd packages/api && python -m pytest tests/test_integration.py -v --tb=short
124124

125+
# -----------------------------------------------------------------------
126+
# E2E Live Tests — full API stack (Postgres + Redis) in CI
127+
#
128+
# test_e2e_live.py is skipped automatically when E2E_LIVE_BASE_URL is
129+
# not set (plain `pytest` stays green). This job provides the
130+
# environment the test file expects: a real API server reachable over
131+
# HTTP, backed by real Postgres and Redis.
132+
#
133+
# Architecture: we start postgres and redis as GHA services, run
134+
# Alembic migrations, then launch the FastAPI app with uvicorn in the
135+
# background. A polling loop waits until GET /api/v1/health returns 200
136+
# before running the test suite. No docker-compose is needed — GHA
137+
# services and `run: uvicorn &` give us the same isolation more cheaply.
138+
#
139+
# A dedicated E2E user is created (not a real admin account) so the
140+
# tests don't depend on pre-seeded data and can run against a clean DB.
141+
# -----------------------------------------------------------------------
142+
e2e-tests:
143+
name: E2E Live Tests
144+
runs-on: ubuntu-latest
145+
services:
146+
postgres:
147+
image: postgres:16-alpine
148+
env:
149+
POSTGRES_USER: nis2
150+
POSTGRES_PASSWORD: nis2secret
151+
POSTGRES_DB: nis2_e2e
152+
ports:
153+
- 5432:5432
154+
options: >-
155+
--health-cmd "pg_isready -U nis2"
156+
--health-interval 5s
157+
--health-timeout 3s
158+
--health-retries 10
159+
redis:
160+
image: redis:7-alpine
161+
ports:
162+
- 6379:6379
163+
options: >-
164+
--health-cmd "redis-cli ping"
165+
--health-interval 5s
166+
--health-timeout 3s
167+
--health-retries 10
168+
env:
169+
ENVIRONMENT: development
170+
DATABASE_URL: postgresql+asyncpg://nis2:nis2secret@localhost:5432/nis2_e2e
171+
DATABASE_URL_SYNC: postgresql://nis2:nis2secret@localhost:5432/nis2_e2e
172+
REDIS_URL: redis://localhost:6379/0
173+
CELERY_BROKER_URL: redis://localhost:6379/1
174+
CELERY_RESULT_BACKEND: redis://localhost:6379/2
175+
JWT_SECRET: e2e-test-jwt-secret-must-be-at-least-32-chars-yes
176+
CORS_ORIGINS: http://localhost:3000
177+
# Credentials used both to register the E2E user and to run the suite.
178+
E2E_LIVE_BASE_URL: http://localhost:8000
179+
E2E_LIVE_EMAIL: e2e-ci@nis2.local
180+
E2E_LIVE_PASSWORD: E2eC!password99
181+
steps:
182+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
183+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
184+
with:
185+
python-version: ${{ env.PYTHON_VERSION }}
186+
187+
- name: Install dependencies
188+
run: |
189+
pip install -e packages/scanner
190+
pip install -e packages/api
191+
pip install pytest pytest-asyncio httpx slowapi uvicorn[standard] alembic
192+
193+
- name: Run Alembic migrations
194+
run: cd packages/api && alembic upgrade head
195+
196+
- name: Start API server (background)
197+
run: |
198+
cd packages/api
199+
uvicorn app.main:app --host 0.0.0.0 --port 8000 \
200+
--log-level warning &
201+
echo "API PID: $!"
202+
203+
- name: Wait for API to be healthy
204+
run: |
205+
for i in $(seq 1 30); do
206+
if curl -sf http://localhost:8000/api/v1/health > /dev/null 2>&1; then
207+
echo "API is up after ${i}s"
208+
exit 0
209+
fi
210+
sleep 1
211+
done
212+
echo "ERROR: API did not start within 30 s"
213+
exit 1
214+
215+
- name: Seed E2E user
216+
run: |
217+
# Register the E2E account via the public registration endpoint.
218+
# Idempotent: a 409 (already exists) is also acceptable.
219+
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
220+
-X POST http://localhost:8000/api/v1/auth/register \
221+
-H "Content-Type: application/json" \
222+
-d "{\"email\":\"${E2E_LIVE_EMAIL}\",\"password\":\"${E2E_LIVE_PASSWORD}\",\"full_name\":\"E2E CI User\"}")
223+
if [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "409" ]; then
224+
echo "ERROR: Registration returned HTTP $HTTP_CODE"
225+
exit 1
226+
fi
227+
echo "Seed user ready (HTTP $HTTP_CODE)"
228+
229+
- name: Run E2E tests
230+
run: |
231+
cd packages/api
232+
python -m pytest tests/test_e2e_live.py -v --tb=short
233+
125234
# -----------------------------------------------------------------------
126235
# Web Build Check
127236
# -----------------------------------------------------------------------

README.md

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,30 +41,32 @@ The scanner is the technical probe. The governance framework is where the substa
4141

4242
The compliance matrix references all ten sub-paragraphs (a) through (j). Several of them — by design of the directive itself — cannot be evaluated by an automated scanner and are tracked through the governance checklist (status: *manual verification required*). What the platform automates vs. what stays manual:
4343

44-
| Sub-paragraph | Scope | How the platform supports it |
45-
|---------------|-------|------------------------------|
46-
| (a) Risk analysis policies | Methodology, periodic updates | Governance checklist (manual) |
47-
| (b) Incident handling | Detection, response, CSIRT notification | Incident module + Art. 23 lifecycle |
48-
| (c) Business continuity | BCP, DRP, backup, periodic testing | BIA module (RTO/RPO/MTPD) |
49-
| (d) Supply chain security | Vendor assessment, contracts, monitoring | Vendor Risk module (Art. 18) |
50-
| (e) Secure acquisition and development | SDLC, code review, vulnerability management | Governance checklist (manual) |
51-
| (f) Effectiveness assessment | Internal audits, KPIs, penetration testing | Technical validation engine + checklist |
52-
| (g) Cyber hygiene and training | Awareness, phishing simulation | Governance checklist (manual) |
53-
| (h) Cryptography | Crypto policy, key management | Technical validation (TLS/cert) + checklist |
54-
| (i) Human resources security | Onboarding/offboarding, screening, PAM | Governance checklist (manual) |
55-
| (j) Authentication and access control | MFA, RBAC, PAM, SSO, access logging | Governance checklist (manual) |
44+
| Sub-paragraph | Scope | Implementation status | How the platform supports it |
45+
|---------------|-------|-----------------------|------------------------------|
46+
| (a) Risk analysis policies | Methodology, periodic updates | **Partial** — automated bridge from scanner findings | Governance checklist + `POST /governance/sync-risk` automatically escalates checklist items when HIGH/CRITICAL scanner findings are open; risk summary via `GET /governance/risk-summary` |
47+
| (b) Incident handling | Detection, response, CSIRT notification | **Implemented** — automated deadline enforcement | Incident module + Art. 23 lifecycle + Celery beat task checks every 15 min and dispatches alerts (email/webhook/Slack) at 24 h / 72 h / 1-month thresholds with Redis-backed dedup |
48+
| (c) Business continuity | BCP, DRP, backup, periodic testing | **Implemented** — manual verification | BIA module (RTO/RPO/MTPD), impact scoring, gap detection |
49+
| (d) Supply chain security | Vendor assessment, contracts, monitoring | **Implemented** — transparent scoring formula | Vendor Risk module (Art. 18) with documented 100-point scoring formula (certification, data access, audit recency, geography, security clauses); auditor-facing `GET /vendors/score-formula` |
50+
| (e) Secure acquisition and development | SDLC, code review, vulnerability management | **Partial** — scanner automates surface checks | Technical validation engine (TLS, headers, secrets, ports) + governance checklist for organisational controls |
51+
| (f) Effectiveness assessment | Internal audits, KPIs, penetration testing | **Partial** — scan-driven | Technical validation engine + checklist |
52+
| (g) Cyber hygiene and training | Awareness, phishing simulation | **Manual** | Governance checklist (human verification required by design) |
53+
| (h) Cryptography | Crypto policy, key management | **Partial** — automated for public-facing TLS | Technical validation (TLS version, cipher suites, cert expiry, HSTS) + checklist for key-management policy |
54+
| (i) Human resources security | Onboarding/offboarding, screening, PAM | **Manual** | Governance checklist (human verification required by design) |
55+
| (j) Authentication and access control | MFA, RBAC, PAM, SSO, access logging | **Partial** — RBAC + audit log implemented; TOTP MFA planned | Role-based access (owner/admin/auditor/viewer), API key scopes, dual-auth, per-request audit log; TOTP MFA tracked in [#86](https://github.com/fabriziosalmi/nis2-public/issues/86) |
56+
57+
**Legend**: *Implemented* = fully automated with no manual step required. *Partial* = automated checks cover the technically observable surface; organisational controls require human verification. *Manual* = the directive explicitly requires human judgement — automation cannot substitute.
5658

5759
### Art. 23 — Incident reporting (CSIRT)
5860

5961
Incident lifecycle aligned with the legal deadlines:
6062

6163
| Phase | Deadline | Platform support |
6264
|-------|----------|------------------|
63-
| Early Warning | 24 hours | "Red Button" generates a CSIRT-ready Early Warning JSON from 3 fields plus the latest asset inventory |
64-
| Incident Notification | 72 hours | Structured form with taxonomy, IOCs, timeline |
65-
| Final Report | 1 month | Aggregated data, impact assessment, lessons learned |
65+
| Early Warning | 24 hours | "Red Button" generates a CSIRT-ready Early Warning JSON + **automated alert 2 h before / on breach** via email, webhook (HMAC-SHA256 signed), or Slack |
66+
| Incident Notification | 72 hours | Structured form with taxonomy, IOCs, timeline + **automated alert 2 h before / on breach** |
67+
| Final Report | 1 month | Aggregated data, impact assessment, lessons learned + **automated alert 2 h before / on breach** |
6668

67-
> Note: The platform produces the artefacts and tracks the deadlines. **Submission to CSIRT Italia is a manual step** through `csirt.gov.it`. There is no automated push.
69+
> Note: The platform produces the artefacts, tracks the deadlines, and dispatches alerts automatically via configured notification channels. **Submission to CSIRT Italia is a manual step** through `csirt.gov.it`. There is no automated push to the CSIRT portal.
6870
6971
### Art. 18 — Supply chain (Vendor Risk Management)
7072

docs/privacy.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,9 @@ When you `git clone` and `make prod` on your own infrastructure, **you become th
118118
Every successful state-changing request (Pre-`AuditMiddleware` v2.4.x) writes an `audit_logs` row that includes the originating **IP address** and **User-Agent**. Both are personal data when they relate to an identifiable person.
119119

120120
- **Legal basis** — Art. 6(1)(f) legitimate interest in maintaining a forensic trail of administrative actions on tenant data, with a documented retention ceiling. Art. 32 GDPR (security of processing) makes this trail expected for any system processing organisational compliance data.
121-
- **Pseudonymisation on erasure** — when a user invokes `DELETE /api/v1/auth/me`, their `audit_logs` rows are not deleted (the audit trail is the controller's legitimate interest under Art. 89(1)). Instead `user_id` is nulled, `ip_address` is replaced with `127.0.0.1`, and `user_agent` is replaced with `[erased]`. The action / resource columns survive so the audit chain remains intact.
122-
- **Retention** — default 90 days. Set `AUDIT_LOG_RETENTION_DAYS` to your own jurisdiction's requirement.
121+
- **Pseudonymisation on erasure** — when a user invokes `DELETE /api/v1/auth/me`, their `audit_logs` rows are not deleted (the audit trail is the controller's legitimate interest under Art. 89(1)). Instead: `user_id` is set to NULL, `ip_address` is replaced with `127.0.0.1`, `user_agent` is replaced with `[erased]`, and `details` (a JSONB field that may contain linkable UUIDs such as org or resource IDs) is set to NULL. The `action`, `resource_type`, `resource_id`, and `created_at` columns survive so the event chain remains intact for forensic purposes without attributing the event to the erased subject.
122+
- **Retention** — default 90 days. Set `AUDIT_LOG_RETENTION_DAYS` to your own jurisdiction's requirement. A daily Celery beat job (`cleanup_expired_auth_records`) prunes rows — including pseudonymised ones — once their `created_at` age exceeds the ceiling.
123+
- **GDPR Art. 17 vs NIS2 Art. 21 explicit resolution** — the platform resolves the tension between the right to erasure and the NIS2 audit-trail obligation through pseudonymisation: the event is retained in unlinkable form, satisfying both the GDPR storage-limitation principle (Art. 5(1)(e)) and the NIS2 requirement that audit evidence be available during the retention window. Operators using the platform for security-incident management should raise `AUDIT_LOG_RETENTION_DAYS` to ≥ 365 (NIS2 Art. 21 recommends evidence retention of at least 12 months); this does not conflict with GDPR because the retained rows are pseudonymised post-erasure.
123124

124125
### 7.3 Outbound network calls during scans
125126

infra/docker/Caddyfile

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,45 @@
11
{$DOMAIN:localhost} {
2+
# Common security headers applied to every response regardless of handler.
3+
# CSP is set per-handler below because the API (JSON) and the frontend
4+
# (Next.js SPA) need different policies.
5+
header {
6+
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
7+
X-Content-Type-Options "nosniff"
8+
X-Frame-Options "DENY"
9+
Referrer-Policy "strict-origin-when-cross-origin"
10+
X-XSS-Protection "1; mode=block"
11+
Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=()"
12+
# Prevent the Caddy default Server header from leaking version info.
13+
-Server
14+
}
15+
16+
# API handler — strict CSP: the backend only returns JSON, so no
17+
# scripts, styles, frames, or embedded resources are ever needed.
218
handle /api/* {
319
reverse_proxy api:8000
20+
21+
header {
22+
# default-src 'none' blocks all resource loads. The API does not
23+
# serve HTML, so this is safe and maximally restrictive.
24+
Content-Security-Policy "default-src 'none'; frame-ancestors 'none'"
25+
}
426
}
527

28+
# Frontend handler — Next.js App Router SPA.
29+
#
30+
# 'unsafe-inline' is required for:
31+
# - Script: Next.js injects inline chunks during hydration
32+
# - Style: Tailwind CSS and shadcn/ui use inline style attributes
33+
#
34+
# TODO: migrate to nonce-based CSP via Next.js middleware once
35+
# the frontend implements generateStaticParams / nonce injection,
36+
# which would allow removing 'unsafe-inline' from script-src.
637
handle {
738
reverse_proxy web:3000
8-
}
939

10-
header {
11-
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
12-
X-Content-Type-Options "nosniff"
13-
X-Frame-Options "DENY"
14-
Referrer-Policy "strict-origin-when-cross-origin"
15-
X-XSS-Protection "1; mode=block"
40+
header {
41+
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
42+
}
1643
}
1744

1845
encode gzip zstd

packages/api/app/config.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,24 @@ class Settings(BaseSettings):
6363
# enough that the disk doesn't grow unbounded.
6464
report_ttl_days: int = 30
6565

66+
# GDPR Art. 5(1)(e) storage limitation for audit logs.
67+
# The privacy notice (docs/privacy.md §7.2) advertises 90 days as the
68+
# default. Set AUDIT_LOG_RETENTION_DAYS in .env to match your own
69+
# jurisdiction's audit-trail obligation (NIS2 Art. 21 recommends ≥ 12
70+
# months for incident evidence; raise this on instances that handle
71+
# security incidents). The cleanup_tasks beat job prunes rows daily.
72+
audit_log_retention_days: int = 90
73+
74+
# Maximum number of report-generation Celery tasks that a single
75+
# organisation may have running concurrently. Each task consumes one
76+
# Celery worker slot and can be CPU/disk intensive (a 50k-finding
77+
# scan takes ~30 s). The 5/min/IP rate limit on POST /reports/generate
78+
# already caps the burst rate; this cap prevents a single org from
79+
# monopolising the entire worker pool across multiple scans and formats.
80+
# Raise on instances with many workers and trusted users; lower on
81+
# shared / multi-tenant setups. Default 3.
82+
max_concurrent_reports_per_org: int = 3
83+
6684
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
6785

6886
@model_validator(mode="after")

packages/api/app/dependencies.py

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,56 @@ async def get_api_key_org(
308308
return api_key, api_key.organization_id
309309

310310

311+
async def _resolve_dual_auth(
312+
request: Request,
313+
credentials: HTTPAuthorizationCredentials | None,
314+
db: AsyncSession,
315+
required_scope: str | None,
316+
) -> uuid.UUID:
317+
"""Core logic shared by get_org_id_dual_auth and dual_auth_with_scope."""
318+
raw = credentials.credentials if credentials else None
319+
has_cookie = request.cookies.get("access_token") is not None
320+
321+
if raw and raw.startswith("nis2_") and not has_cookie:
322+
api_key, org_id = await get_api_key_org(db=db, credentials=credentials)
323+
if (
324+
required_scope is not None
325+
and api_key.scopes is not None
326+
and required_scope not in api_key.scopes
327+
):
328+
raise HTTPException(
329+
status_code=status.HTTP_403_FORBIDDEN,
330+
detail=f"API key missing required scope: {required_scope}",
331+
)
332+
return org_id
333+
334+
user = await get_current_user(request=request, credentials=credentials, db=db)
335+
_, membership = await get_current_org(request=request, current_user=user)
336+
return membership.organization_id
337+
338+
339+
def dual_auth_with_scope(required_scope: str):
340+
"""Dependency factory: authenticate via JWT session OR API key, enforcing a scope.
341+
342+
When the caller presents an API key (Bearer `nis2_*` with no cookie),
343+
the key's scope list must contain `required_scope` — otherwise 403.
344+
Keys with `scopes=None` (legacy, pre-2.4.26) pass through unrestricted.
345+
346+
JWT sessions are not scope-constrained (role-based access handles that).
347+
348+
Usage:
349+
org_id: uuid.UUID = Depends(dual_auth_with_scope("scan:read"))
350+
"""
351+
async def _dep(
352+
request: Request,
353+
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
354+
db: AsyncSession = Depends(get_db),
355+
) -> uuid.UUID:
356+
return await _resolve_dual_auth(request, credentials, db, required_scope)
357+
358+
return _dep
359+
360+
311361
async def get_org_id_dual_auth(
312362
request: Request,
313363
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
@@ -334,14 +384,8 @@ async def get_org_id_dual_auth(
334384
335385
Returns the organization_id only — read endpoints just need it for
336386
RLS scoping; they don't need the user's identity.
337-
"""
338-
raw = credentials.credentials if credentials else None
339-
has_cookie = request.cookies.get("access_token") is not None
340-
341-
if raw and raw.startswith("nis2_") and not has_cookie:
342-
_, org_id = await get_api_key_org(db=db, credentials=credentials)
343-
return org_id
344387
345-
user = await get_current_user(request=request, credentials=credentials, db=db)
346-
_, membership = await get_current_org(request=request, current_user=user)
347-
return membership.organization_id
388+
Prefer dual_auth_with_scope(required_scope) for new endpoints so
389+
scope enforcement is explicit at the call site.
390+
"""
391+
return await _resolve_dual_auth(request, credentials, db, required_scope=None)

0 commit comments

Comments
 (0)