Commit ecfe7eb
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
File tree
- .github/workflows
- docs
- infra/docker
- packages
- api
- app
- routers
- tasks
- utils
- tests
- scanner
- nis2scan
- tests
- web
- public/.well-known
- src
- app/dashboard
- components/ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
125 | 234 | | |
126 | 235 | | |
127 | 236 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
54 | | - | |
55 | | - | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
56 | 58 | | |
57 | 59 | | |
58 | 60 | | |
59 | 61 | | |
60 | 62 | | |
61 | 63 | | |
62 | 64 | | |
63 | | - | |
64 | | - | |
65 | | - | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
66 | 68 | | |
67 | | - | |
| 69 | + | |
68 | 70 | | |
69 | 71 | | |
70 | 72 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
118 | 118 | | |
119 | 119 | | |
120 | 120 | | |
121 | | - | |
122 | | - | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
123 | 124 | | |
124 | 125 | | |
125 | 126 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
2 | 18 | | |
3 | 19 | | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
4 | 26 | | |
5 | 27 | | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
6 | 37 | | |
7 | 38 | | |
8 | | - | |
9 | 39 | | |
10 | | - | |
11 | | - | |
12 | | - | |
13 | | - | |
14 | | - | |
15 | | - | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
16 | 43 | | |
17 | 44 | | |
18 | 45 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
66 | 84 | | |
67 | 85 | | |
68 | 86 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
308 | 308 | | |
309 | 309 | | |
310 | 310 | | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
311 | 361 | | |
312 | 362 | | |
313 | 363 | | |
| |||
334 | 384 | | |
335 | 385 | | |
336 | 386 | | |
337 | | - | |
338 | | - | |
339 | | - | |
340 | | - | |
341 | | - | |
342 | | - | |
343 | | - | |
344 | 387 | | |
345 | | - | |
346 | | - | |
347 | | - | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
0 commit comments