Files
kite/SECURITY.md
T
curo1305andClaude Sonnet 4.6 90e1b525f1 docs(14-09): Phase 14 closeout — docs, version bump v0.3.0→v0.4.0, security evidence
- CLAUDE.md/AGENTS.md: update current-state line to v0.4.0 Phase 14 complete;
  add Phase 14 shared module map entries (cloud_cache, cloud_analysis,
  cloud_analysis_processing, cloud_analysis_tasks, analysis router, cache API);
  add Phase 14 non-negotiable rules (hydrate_and_cache_bytes single entry point,
  IDs-only broker payload, object_key/credentials_enc excluded at schema level)
- README.md: bump to v0.4.0; add Selective cloud analysis and Cloud byte cache
  features; add Analysis API table (Phase 14 endpoints)
- SECURITY.md: add Phase 14 threat register (T-14-01..T-14-15) with evidence,
  security gate results, and accepted risks
- backend/main.py: bump FastAPI version to 0.4.0
- frontend/package.json: bump version to 0.4.0
- ROADMAP.md: mark Phase 14 complete (9/9 plans), plan 14-09 checked off
- 14-VALIDATION.md: gate evidence, requirement coverage, acceptance criteria check
- 14-SECURITY.md: security evidence summary and gate checklist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 20:05:49 +02:00

710 lines
82 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SECURITY.md — Phase 02 + Phase 03
**Audit date:** 2026-06-01
**Phase 02:** users-authentication (plans 0106) — previously audited, result SECURED
**Phase 03:** document-migration-multi-user-isolation (plans 0105)
**ASVS Level:** L2
**Auditor:** gsd-security-auditor (claude-sonnet-4-6)
---
## Phase 02 Threat Verification (reproduced from previous audit)
| Threat ID | Category | Disposition | Status | Evidence |
|-----------|----------|-------------|--------|----------|
| T-02-01 | Spoofing | mitigate | CLOSED | `services/auth.py:93``payload.get("typ") != "access"` raises ValueError after JWT decode; prevents password-reset tokens from being accepted as access tokens |
| T-02-02 | Spoofing | mitigate | CLOSED | `services/auth.py:181-185` — on revoked token reuse: `revoke_all_refresh_tokens()` called, `send_security_alert_email.delay()` enqueued, `ValueError("token_family_revoked")` raised |
| T-02-03 | Tampering | mitigate | CLOSED | `services/auth.py:310``code_hash=hash_password(code)` (Argon2); `services/auth.py:338``verify_password(code, row.code_hash)` constant-time comparison via pwdlib |
| T-02-04 | Repudiation | mitigate | CLOSED | `services/auth.py:397-408` — checks `admin_email`/`admin_password` set; `select(User).limit(1)` guards idempotency; logs WARNING when env vars missing |
| T-02-05 | Info Disclosure | mitigate | CLOSED | `services/auth.py:360``sha1[:5]` prefix only sent to HIBP URL; suffix compared locally with `hmac.compare_digest` |
| T-02-06 | DoS | accept | CLOSED | Accepted: `services/auth.py:369-371``httpx timeout=5.0`, `except Exception: logger.warning(…); return False` (fail-open) |
| T-02-07 | EoP | mitigate | CLOSED | `deps/auth.py:87``if user.role != "admin": raise HTTPException(403, "Admin access required")` |
| T-02-08 | EoP | mitigate | CLOSED | `api/admin.py` — no route containing `/impersonate`, `/login-as`, or any code path setting JWT `sub` to a different user; verified by grep (0 matches) |
| T-02-SC | Tampering | mitigate | CLOSED | `backend/requirements.txt:23-26``PyJWT>=2.8.0`, `pwdlib[argon2]>=0.2.1`, `pyotp>=2.9.0`, `slowapi>=0.1.9` all pinned |
| T-02-09 | Spoofing | mitigate | CLOSED | `api/auth.py:248` — identical detail `"Incorrect email or password"` for both non-existent email (`user is None`) and wrong password branches |
| T-02-10 | Spoofing | mitigate | CLOSED | `api/auth.py:673-676` — always returns 202 with `"If an account exists…"` message regardless of whether email was found |
| T-02-11 | Tampering | mitigate | CLOSED | `main.py:100``samesite="strict"` on refresh cookie (`api/auth.py:100`); `main.py:47-61``OriginValidationMiddleware` rejects non-GET/HEAD/OPTIONS requests with Origin not in `settings.cors_origins` |
| T-02-12 | Info Disclosure | accept | CLOSED | Accepted: `stores/auth.js``accessToken = ref(null)` (Pinia memory only); grep returns 0 hits for `localStorage`/`sessionStorage` |
| T-02-13 | DoS | mitigate | CLOSED | `api/auth.py:121,195,326``@limiter.limit("10/minute")` on register/login/refresh; `api/auth.py:215-224` — per-account Redis counter `login_attempts:{email}` capped at 10 in 15 min |
| T-02-14 | Info Disclosure | mitigate | CLOSED | `main.py:32-40``SecurityHeadersMiddleware` sets `Content-Security-Policy`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff` on every response |
| T-02-15 | Tampering | mitigate | CLOSED | `main.py:124``allow_origins=settings.cors_origins`; grep for `allow_origins=["*"]` returns 0 matches |
| T-02-16 | EoP | mitigate | CLOSED | `api/auth.py:259-260``if user.password_must_change: return {"requires_password_change": True, "user_id": …}` — no tokens issued, no cookie set |
| T-02-17 | Spoofing | mitigate | CLOSED | `services/auth.py:262-270` — Redis key `totp_used:{user_id}:{code}`, pre-check before verify, set with `ex=90` after valid code |
| T-02-18 | Spoofing | mitigate | CLOSED | `services/auth.py:330,345` — only queries `BackupCode.used_at.is_(None)`; sets `used_at = datetime.now(timezone.utc)` on first use |
| T-02-19 | Info Disclosure | mitigate | CLOSED | `api/auth.py:594-609` — plaintext codes returned once from `POST /totp/enable`; `services/auth.py:310` stores as Argon2 hashes |
| T-02-20 | EoP | mitigate | CLOSED | `services/auth.py:125-126``decode_password_reset_token` checks `payload.get("typ") != "password-reset"` raises ValueError |
| T-02-21 | EoP | mitigate | CLOSED | `api/auth.py:730``POST /password-reset/confirm` returns `{"message": "Password updated. Please sign in."}` only; no `access_token` in response |
| T-02-22 | Info Disclosure | mitigate | CLOSED | `api/auth.py:673``# Always return 202` comment and return statement outside the `if user is not None` block |
| T-02-23 | Tampering | accept | CLOSED | Accepted: pyotp internal string compare; rate limiting (10/min on `/totp/enable`) is primary defense |
| T-02-24 | Spoofing | mitigate | CLOSED | `frontend/src/components/auth/ConfirmBlock.vue` exists with `confirmed`/`cancelled` emits; AccountView wires `@confirmed` to `logoutAll()` call |
| T-02-25 | DoS | mitigate | CLOSED | `api/auth.py:565``@limiter.limit("10/minute")` on `POST /api/auth/totp/enable` |
| T-02-26A | EoP | mitigate | CLOSED | `api/admin.py``grep -c get_current_admin` returns 12; every handler has `_admin: User = Depends(get_current_admin)` |
| T-02-26B | Spoofing | mitigate | CLOSED | `services/auth.py:330``BackupCode.used_at.is_(None)` filter; used codes are invisible to subsequent `verify_backup_code()` calls |
| T-02-27A | Info Disclosure | mitigate | CLOSED | `api/admin.py:75-90``_user_to_dict()` whitelist: `id, handle, email, role, is_active, totp_enabled, ai_provider, ai_model, password_must_change, created_at` — no `password_hash`, `credentials_enc`, or `totp_secret` |
| T-02-27B | Spoofing | mitigate | CLOSED | `api/auth.py:215-224` — per-account Redis counter incremented before TOTP/backup_code branch; applies to all login paths |
| T-02-28 | EoP | mitigate | CLOSED | `api/admin.py` — grep for `impersonate`/`login.as`/`login_as` returns 0 matches |
| T-02-29 | DoS | mitigate | CLOSED | `api/admin.py:305-316` — COUNT query on `(role='admin', is_active=True)` before deactivation; raises HTTP 400 if `active_admin_count <= 1` |
| T-02-30A | Tampering | mitigate | CLOSED | `api/admin.py:348,377``status_code=HTTP_202_ACCEPTED`; returns `{"message": "Password reset email sent"}`; no token in response |
| T-02-30B | EoP | mitigate | CLOSED | `frontend/src/components/layout/AppSidebar.vue:189``v-if="authStore.user?.role === 'admin'"` on Admin router-link |
| T-02-31A | Info Disclosure | accept | CLOSED | Accepted: quota endpoint exposes `limit_bytes`/`used_bytes` — admin operational data, no PII, no document content |
| T-02-31B | EoP | mitigate | CLOSED | `frontend/src/components/admin/AdminUsersTab.vue` — grep for `impersonate`/`loginAs`/`login-as` returns 0 matches; same for AdminQuotasTab and AdminAiConfigTab |
| T-02-32A | EoP | mitigate | CLOSED | `api/admin.py:255``password_must_change=True` in `User(…)` constructor on `POST /api/admin/users` |
| T-02-32B | Info Disclosure | mitigate | CLOSED | `frontend/src/components/admin/AdminUsersTab.vue` — no `password_hash`, `credentials_enc`, or `totp_secret` bound in template; all fields come from `_user_to_dict()` whitelist |
| T-02-33 | Tampering | mitigate | CLOSED | `frontend/src/components/admin/AdminUsersTab.vue:153-174``v-if="confirmDeactivate === user.id"` shows inline block with `{{ user.email }}` before calling `adminDeactivateUser(id)` |
| T-02-34 | DoS | accept | CLOSED | Accepted: admin is trusted role; no rate limit on `POST /api/admin/users` is intentional |
| T-02-GAP-01 | EoP | mitigate | CLOSED | `frontend/src/router/index.js:42``meta: { requiresAdmin: true }` on `/admin` route; `router/index.js:91-93``if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') return { path: '/' }` |
| T-02-GAP-02 | Info Disclosure | mitigate | CLOSED | `frontend/src/App.vue:2``<AuthLayout v-if="route.meta.layout === 'auth'" />` renders no sidebar on auth routes |
| T-02-GAP-03 | Tampering | accept | CLOSED | Accepted (already mitigated): `api/admin.py:265``await session.flush()` present before `write_audit_log()` in `create_user`; regression test `test_create_user_writes_audit_log` passes |
| T-02-GAP-SC | Tampering | mitigate | CLOSED | `frontend/package.json:13``"qrcode": "^1.5.4"` — canonical npm package (20M+ weekly downloads); no server-side dependency |
---
## Phase 03 Threat Verification
**Audit date:** 2026-06-01
**Plans audited:** 03-01 through 03-05
**Result:** OPEN_THREATS — 4 accepted-risk entries not yet documented (see Accepted Risks Log below)
| Threat ID | Category | Disposition | Status | Evidence |
|-----------|----------|-------------|--------|----------|
| T-03-01 | Tampering | mitigate | CLOSED | `backend/migrations/versions/0003_multi_user_isolation.py:56-88``null_user_objects` collected via SELECT before DELETE (line 56-57); each `client.remove_object()` wrapped in `try/except Exception: pass` (line 85-88); partial MinIO failure leaves only orphans |
| T-03-02 | DoS | accept | CLOSED | Documented in Accepted Risks Log below |
| T-03-03 | Info Disclosure | mitigate | CLOSED | `backend/tests/conftest.py:221``token = create_access_token(str(user_id), "user")` uses standard `services.auth.create_access_token` (test secret_key from Settings); async_client fixture clears `app.dependency_overrides` in teardown (line 155); no token values logged anywhere in conftest |
| T-03-04 | Spoofing | mitigate | CLOSED | `backend/api/documents.py:112-113``suffix = Path(body.filename).suffix.lower()`, `object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"` — object_key computed server-side; `body.filename` stored in `Document.filename` DB column only; extension from `Path().suffix.lower()` |
| T-03-05 | Tampering | mitigate | CLOSED | `backend/api/documents.py:327``size = await get_storage_backend().stat_object(doc.object_key)` — size from MinIO stat, not from client; client body contains no size field; confirm endpoint has no `body` parameter beyond doc_id path param |
| T-03-06 | DoS | mitigate | CLOSED | `backend/api/documents.py:341-351``UPDATE quotas SET used_bytes = used_bytes + :delta WHERE user_id = :uid AND (used_bytes + :delta) <= limit_bytes RETURNING used_bytes, limit_bytes`; `row = result.fetchone()`; `if row is None:` → HTTP 413 (lines 353-374) |
| T-03-07 | Info Disclosure | accept | CLOSED | Documented in Accepted Risks Log below |
| T-03-08 | Repudiation | mitigate | CLOSED | `backend/tasks/document_tasks.py:132-177``cleanup_abandoned_uploads` Celery task exists; `_cleanup_abandoned()` selects `Document.status == "pending"` and `Document.created_at < cutoff` (1 hour); `backend/celery_app.py:43-46``beat_schedule` entry with `_timedelta(minutes=30)` |
| T-03-09 | Info Disclosure | mitigate | CLOSED | `docker-compose.yml:26``MINIO_API_CORS_ALLOW_ORIGIN: ${FRONTEND_URL:-http://localhost:5173}` — explicit non-wildcard origin; env var defaults to specific origin. Note: implementation uses `FRONTEND_URL` instead of plan's `CORS_ORIGINS` — both default to `http://localhost:5173`; wildcard exclusion is confirmed |
| T-03-10 | Tampering | mitigate | CLOSED | `backend/storage/minio_backend.py:54-60``self._public_client = Minio(endpoint=(public_endpoint or endpoint), ...)` — dual client instantiated in `__init__`; `generate_presigned_put_url` uses `self._public_client` (line 154); `stat_object` uses `self._client` (line 169) |
| T-03-11 | Info Disclosure | mitigate | CLOSED | `backend/api/documents.py` — ownership assertion pattern `if doc is None or doc.user_id != current_user.id: raise HTTPException(status_code=404, ...)` appears at: confirm (line 322-323), get (line 545-546), delete (line 633-634), classify (line 702-703), patch (line 579-580), content (line 767); all raise 404 not 403 |
| T-03-12 | EoP | mitigate | CLOSED | `backend/deps/auth.py:95-109``get_regular_user` raises HTTP 403 if `user.role == "admin"`; `backend/api/documents.py``Depends(get_regular_user)` present on all 7 document handlers: upload-url (line 99), upload (line 143), confirm (line 302), list (line 416), get (line 530), patch (line 557), delete (line 613), classify (line 688), content (line 742) |
| T-03-13 | Info Disclosure | mitigate | CLOSED | `backend/services/storage.py:270-282` (load_topics_for_user) — `or_(Topic.user_id == user_id, Topic.user_id.is_(None))` filter; `backend/api/topics.py:44``storage.load_topics_for_user(session, user_id=current_user.id)`; `backend/api/topics.py:64``storage.create_topic(..., user_id=current_user.id)` |
| T-03-14 | EoP | mitigate | CLOSED | `backend/api/admin.py:602-622``POST /api/admin/topics` with `_admin: User = Depends(get_current_admin)`, creates `Topic(user_id=None)`; `backend/api/topics.py:63-64` — regular `POST /api/topics` forces `user_id=current_user.id` |
| T-03-15 | Tampering | mitigate | CLOSED | `backend/api/documents.py:113``object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"` — prefix always `str(current_user.id)`; no user-supplied prefix accepted; `null-user` sentinel confirmed absent (grep returns 0 for "null-user" in documents.py) |
| T-03-16 | Spoofing | mitigate | CLOSED | `backend/deps/auth.py:35``security = HTTPBearer()` (auto_error=True default) raises 403 on missing Authorization header; `get_current_user` raises 401 (lines 52-55) on invalid/expired token |
| T-03-17 | EoP | mitigate | CLOSED | `backend/api/settings.py` does not exist (confirmed absent); `backend/main.py` contains no `settings_router` import or `include_router` for settings; only admin endpoint writes `user.ai_provider`/`user.ai_model` |
| T-03-18 | Info Disclosure | mitigate | CLOSED | `backend/services/storage.py` — grep for `load_settings`/`save_settings`/`mask_api_key`/`settings_masked` returns 0 matches in non-comment lines; comment at line 12 references removal but no function bodies present |
| T-03-19 | Tampering | mitigate | CLOSED | `backend/tasks/document_tasks.py:62-64``user = await session.get(User, doc.user_id) if doc.user_id else None; ai_provider = (user.ai_provider if user else None) or app_settings.default_ai_provider`; task signature is `extract_and_classify(document_id: str)` — no provider in broker message |
| T-03-20 | Info Disclosure | accept | CLOSED | Documented in Accepted Risks Log below |
| T-03-21 | Repudiation | mitigate | CLOSED | `frontend/src/api/client.js` — grep for `getSettings`/`patchSettings`/`testProvider`/`getDefaultPrompt` returns 0 matches; `SettingsView.vue` imports only `SettingsPreferencesTab`, `SettingsAiTab`, `SettingsCloudTab`, `SettingsAccountTab` — no old settings store; `SettingsAiTab.vue` contains no API calls (static read-only display) |
| T-03-22 | Info Disclosure | mitigate | CLOSED | `frontend/src/stores/documents.js:24-25``xhr.setRequestHeader('Content-Type', ...)` only; comment `// NOTE: no Authorization header — presigned URL is self-authenticating (T-03-22)`; no `setRequestHeader('Authorization', ...)` call present |
| T-03-23 | Spoofing | mitigate | CLOSED | `frontend/src/components/upload/UploadProgress.vue:27,30``item.quotaError.rejected_bytes`, `item.quotaError.used_bytes`, `item.quotaError.limit_bytes` all sourced from server 413 response body (via `err.payload` from `api/client.js`); no local `file.size` calculation |
| T-03-24 | DoS | accept | CLOSED | Documented in Accepted Risks Log below |
| T-03-25 | Tampering | mitigate | CLOSED | `frontend/src/stores/documents.js:70``const rowKey = \`${file.name}__${Date.now()}\`` — composite key prevents collision for same-filename concurrent uploads |
| T-03-26 | Repudiation | mitigate | CLOSED | `frontend/src/stores/auth.js:144-149` — `fetchQuota()` wraps `api.getMyQuota()` in `try { ... } catch { // Silently ignore }`; last-known values preserved on error; `QuotaBar.vue` hides via `v-if="!loadFailed"` on catch |
| T-03-SC (×5) | Tampering | mitigate | CLOSED | No new pip or npm package installs in any of plans 03-01 through 03-05; all packages already pinned from Phase 1/2 |
---
## Accepted Risks Log
| Risk ID | Component | Accepted Risk | Rationale |
|---------|-----------|---------------|-----------|
| T-02-06 | HIBP network call | Fail-open on network error — auth proceeds | `httpx timeout=5s`; logging warning; HIBP unavailability must not block legitimate logins |
| T-02-12 | Access token in JavaScript | Token held in Pinia `ref()` memory | Lost on page refresh (by design); refresh endpoint uses httpOnly cookie to reissue |
| T-02-23 | TOTP constant-time compare | pyotp uses Python string compare | Rate limiting (10/min on `/totp/enable`) is the primary defense; 6-digit TOTP window makes brute force impractical within the rate window |
| T-02-31A | Quota endpoint | Admin can view `limit_bytes`/`used_bytes` | No PII; no document content; operational necessity for quota management |
| T-02-34 | Admin user creation | No rate limit on `POST /api/admin/users` | Admin is a trusted role; rate limiting would hinder legitimate bulk user provisioning |
| T-02-GAP-03 | admin.py create_user flush order | Already mitigated — documented as accepted | `session.flush()` present at `admin.py:265`; regression test confirms FK ordering |
| T-03-02 | Alembic migration when MinIO unreachable | Migration may leave MinIO objects undeleted if MinIO is unreachable at migration time | Migration runs only after docker-compose health checks confirm MinIO is ready (backend service `depends_on: minio: condition: service_healthy`); if MinIO is down, deployment is blocked before migration runs; orphaned objects are harmless (no DB row references them); retry on next deploy |
| T-03-07 | Presigned URL in application logs | 15-minute TTL presigned URL may appear in debug logs | TTL is 15 minutes; only `document_id` (not full URL) is logged at the document endpoint level; low risk for v1; full log redaction deferred to Phase 4/5 |
| T-03-20 | SYSTEM_PROMPT env var in container logs | `settings.system_prompt` value visible in container startup logs if log level includes config dump | `SYSTEM_PROMPT` is a static AI instruction string with no PII, no credentials, no secrets; container log exposure of this value has no security impact |
| T-03-24 | Concurrent browser uploads exhaust memory | Multiple simultaneous large-file uploads could exhaust browser memory | XHR-based upload streams bytes natively without buffering in JavaScript memory; browser natively handles the file stream; v1 acceptance — concurrent upload limits are a UX concern, not a security concern |
---
## Unregistered Threat Flags
None. All `## Threat Flags` sections in plans 03-01 through 03-05 summaries report no new attack surface beyond the registered threat IDs.
---
## Phase 07 Threat Verification
**Audit date:** 2026-06-05
**Phase:** 7 — Redo and Optimize LLM Integration
**ASVS Level:** L2
**Auditor:** gsd-security-auditor (claude-sonnet-4-6)
**Threats closed:** 12/12
**Open threats (blockers):** 0
### Threat Verification
| Threat ID | Category | Disposition | Status | Evidence |
|-----------|----------|-------------|--------|----------|
| T-07-01 | Information Disclosure | mitigate | CLOSED | `_ai_config_to_dict()` at `backend/api/admin.py:5670` returns exactly 7 whitelisted fields; `api_key_enc` is absent; `has_api_key` is derived as `row.api_key_enc is not None`. Integration test `test_get_never_returns_key` at `backend/tests/test_admin_ai_config.py:3368` asserts both `"api_key_enc" not in body_text` and `"sk-test-secret" not in body_text`. |
| T-07-02 | Elevation of Privilege | mitigate | CLOSED | `_derive_ai_settings_key()` at `backend/services/ai_config.py:67` uses `info=b"ai-provider-settings"`. Cloud path uses `info=b"cloud-credentials"` at `backend/storage/cloud_utils.py:137`. Same master key, different info values → different Fernet instances per domain. Unit test `test_api_key_encrypt_decrypt` at `backend/tests/test_ai_config.py:4346` asserts cross-provider decrypt raises `InvalidToken`. |
| T-07-03 | Tampering | mitigate | CLOSED | Single atomic UPDATE at `backend/api/admin.py:902906`: `update(SystemSettings).values(is_active=(SystemSettings.provider_id == body.provider_id))`. No read-then-write. Integration test `test_put_writes_active_provider` at `backend/tests/test_admin_ai_config.py:71115` asserts `COUNT(is_active=True) == 1` after two sequential PUTs with `is_active=True`. |
| T-07-04 | Tampering | mitigate | CLOSED | `get_provider()` at `backend/ai/__init__.py:62` applies `effective_api_key = config.api_key or "not-needed"` before constructing any provider. `OpenAIProvider.__init__` at `backend/ai/openai_provider.py:16` applies a defence-in-depth `api_key or "not-needed"`. `GenericOpenAIProvider` inherits via `super().__init__()`. Empty string never reaches `AsyncOpenAI(api_key=...)`. |
| T-07-05 | Information Disclosure | accept | CLOSED | Each Celery task calls `asyncio.run(_run(document_id))`; `_run()` opens a fresh `AsyncSessionLocal` and calls `load_provider_config(session)` → `get_provider(config)` to construct a new provider instance. No provider or `_client` is module-level or class-level. Cross-task credential sharing is structurally impossible. |
| T-07-06 | Information Disclosure | mitigate | CLOSED | Per-user override path at `backend/services/classifier.py:7480` constructs `ProviderConfig(api_key="", ...)` — hardcoded empty string; never reads from system_settings. Factory normalises `""` to `"not-needed"` at `backend/ai/__init__.py:62`. API key never flows through the per-user override path. |
| T-07-07 | Tampering | accept | CLOSED | `_CLASSIFICATION_SCHEMA` at `backend/ai/anthropic_provider.py:2534`: 3 properties, 2 required, `additionalProperties: False`, no unions. `_SUGGESTIONS_SCHEMA`: 1 property, 1 required. Neither schema approaches Anthropic grammar size limits. |
| T-07-08 | Denial of Service | mitigate | CLOSED | `AnthropicProvider.classify()` at `backend/ai/anthropic_provider.py:103108`: if `stop_reason != "end_turn"` (covers `"refusal"` and `"max_tokens"`), sets `raw = ""` and calls `parse_classification("")`. `parse_classification("")` at `backend/ai/utils.py:1735` returns `ClassificationResult()` (empty, no exception). |
| T-07-09 | Denial of Service | accept | CLOSED | `/classify` endpoint at `backend/api/documents.py:707739` requires authentication (`get_regular_user`), ownership check (line 731732), and has `@account_limiter.limit("100/minute")` at line 708. Sub-100/min per-account limit deferred to Phase 6 expansion. Documented accepted risk. |
| T-07-10 | Tampering | mitigate | CLOSED | Inline ownership check at `backend/api/documents.py:730732`: `if doc is None or doc.user_id != current_user.id: raise HTTPException(404, "Document not found")`. Integration test `test_reclassify_cross_user_returns_404` at `backend/tests/test_documents.py:10091059` confirms 404 response for cross-user attempt. |
| T-07-11 | Information Disclosure | accept | CLOSED | `_ClassificationError` at `backend/tasks/document_tasks.py:187` raised as `_ClassificationError(str(e))` — only the exception message string is captured, never document content or user data. Redis broker is internal-only in deployment. |
| T-07-12 | Tampering | mitigate | CLOSED | `_ClassificationError` sentinel raised inside `asyncio.run(_run(...))` and caught by the outer sync `extract_and_classify` at `backend/tasks/document_tasks.py:8186`; `self.retry()` is called in the sync layer only. Unit test `test_retry_backoff` at `backend/tests/test_document_tasks.py:2659` validates the sentinel escape and countdown sequence. |
### Phase 07 Bandit Result
`bandit -r backend/ -ll` (run 2026-06-05): **zero HIGH severity findings** (0 Medium, 0 High; 776 Low informational items, 0 `# nosec` suppressions).
### Phase 07 Unregistered Flags
None. No `## Threat Flags` section provided for Phase 7. All threats resolved from the supplied register.
### Phase 07 Accepted Risks
| Risk ID | Component | Accepted Risk | Rationale |
|---------|-----------|---------------|-----------|
| T-07-05 | Celery AI provider client | No cross-task singleton risk | `asyncio.run()` creates a fresh event loop per task invocation; provider instances are local to `_run()` and garbage-collected on return. |
| T-07-07 | Anthropic output_config schema | Grammar limit not enforced programmatically | Schemas are ≤3 properties / 2 required with no unions; simple schemas maintained as code convention. |
| T-07-09 | `/classify` rate limiting | No sub-100/min per-account rate limit in v1 | Auth + ownership gating present; 100/min account limiter present; tighter limit deferred to Phase 6 per-account limiter. |
| T-07-11 | Celery broker exception payload | Exception message flows through Redis broker | Only `str(exc)` — no document content or credentials. Redis broker is internal-network only. |
---
## Notes
### Phase 03 Audit Notes
- **T-03-09 env var deviation:** The implementation uses `MINIO_API_CORS_ALLOW_ORIGIN: ${FRONTEND_URL:-http://localhost:5173}` instead of the plan's `${CORS_ORIGINS:-http://localhost:5173}`. Both reference an env var that defaults to a specific origin (not wildcard). The security invariant (no wildcard) is upheld.
- **T-03-21 SettingsView evolution:** By Phase 5, `SettingsView.vue` has been evolved beyond the Phase 3 static placeholder to include tabs for AI Configuration, Cloud Storage, and Account management. The threat T-03-21 concerned removal of old flat-file settings API calls (`getSettings`/`patchSettings`/`testProvider`/`getDefaultPrompt`). These are confirmed absent. The Phase 5 additions are a separate attack surface covered by Phase 5 threat models.
- **T-03-11 ownership assertion pattern:** The `if doc is None or doc.user_id != current_user.id` combined check is present on all 7 document handlers. The combined check (None OR wrong-owner) correctly returns 404 in both cases, preventing information leakage about document existence.
- **CASE WHEN vs GREATEST():** The quota decrement in `services/storage.py` uses `CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END` instead of `GREATEST(0, used_bytes - :delta)`. This is semantically equivalent and provides SQLite test compatibility. The behavior on PostgreSQL is identical.
---
## Phase 07.1 + 07.2 Threat Verification
**Audit date:** 2026-06-06
**Phase:** 07.2 — JTI Claim + Redis NBF Access-Token Revocation (gap closure fix included)
**ASVS Level:** L2
**Auditor:** gsd-security-auditor (claude-sonnet-4-6)
**Threats closed:** 9/9
**Open threats (blockers):** 0
### Threat Verification
| Threat ID | Category | Disposition | Status | Evidence |
|-----------|----------|-------------|--------|----------|
| T-7.2-01 | Elevation of Privilege | mitigate | CLOSED | `deps/auth.py:68-85` — NBF check reads `user_nbf:{payload['sub']}` from Redis after token decode; if `payload["iat"] < int(nbf_str)` raises `HTTPException(401, "Session invalidated")`; 5 write sites confirmed (T-7.2-WRITES) |
| T-7.2-02 | Elevation of Privilege | mitigate | CLOSED | `deps/auth.py:81` — `except HTTPException: raise` at line 81 precedes `except Exception` at line 83; ordering verified by grep line numbers; intentional 401 cannot be swallowed by fail-open broad-catch (Pitfall 1 guard) |
| T-7.2-03 | Elevation of Privilege | mitigate | CLOSED | `deps/auth.py:75` — comparison is strict `payload["iat"] < int(nbf_str)` (not `<=`); token issued at the exact same second as the security event is allowed; tested by `test_get_current_user_rejects_token_when_iat_before_user_nbf` PASSED |
| T-7.2-04 | Denial of Service | accept | CLOSED | `deps/auth.py:83-84` — `except Exception as exc: _logger.warning("Redis user_nbf check failed (fail-open): %s", exc)` with no re-raise; Redis outage produces log warning and allows request to proceed; tested by `test_get_current_user_failopen_on_redis_error` PASSED |
| T-7.2-05 | Information Disclosure | accept | CLOSED | `services/auth.py:98` — `"jti": str(uuid.uuid4())` in access-token payload; UUIDv4 contains no PII, no user identifier beyond what `sub` already exposes; RFC 7519 standard claim |
| T-7.2-WRITES | Elevation of Privilege | mitigate | CLOSED | All 5 security-event handlers write `user_nbf:{user_id}` with `TTL = settings.access_token_expire_minutes * 60` before `session.commit()`: `change_password` (auth.py:509-511), `enable_totp` (auth.py:608-610), `disable_totp` (auth.py:654-656), `password_reset_confirm` (auth.py:746-748, CR-02 gap fix), `admin deactivation` (admin.py:357-360); all 5 confirmed by grep |
| T-7.2-ACT | Elevation of Privilege | mitigate | CLOSED | `api/admin.py:353-361` — `user_nbf` write is strictly inside `if not body.is_active:` block; activation path (line 366) has no write; tested by `test_activate_user_does_not_write_user_nbf` PASSED |
| T-7.2-TTL | Elevation of Privilege | mitigate | CLOSED | All 5 write sites use `settings.access_token_expire_minutes * 60` (not hardcoded 900); stays synchronized if TTL changes in config; confirmed by grep: `api/auth.py:511,610,656,748` and `api/admin.py:360` |
| T-7.2-JTI | Tampering | mitigate | CLOSED | `services/auth.py:98` — `"jti": str(uuid.uuid4())` added to payload after `exp`; `import uuid` already present at line 25; uniqueness per call tested by `test_create_access_token_jti_is_unique_per_call` PASSED |
### Phase 07.2 Test Coverage
| Behavior | Test | Status |
|----------|------|--------|
| JTI claim in every access token | `test_create_access_token_includes_jti_claim` | PASSED |
| JTI is unique per call | `test_create_access_token_jti_is_unique_per_call` | PASSED |
| NBF check rejects pre-event token | `test_get_current_user_rejects_token_when_iat_before_user_nbf` | PASSED |
| NBF check allows post-event token | `test_get_current_user_allows_token_when_iat_after_user_nbf` | PASSED |
| Redis outage is fail-open | `test_get_current_user_failopen_on_redis_error` | PASSED |
| change_password writes user_nbf | `test_change_password_writes_user_nbf_to_redis` | PASSED |
| enable_totp writes user_nbf | `test_enable_totp_writes_user_nbf_to_redis` | PASSED |
| disable_totp writes user_nbf | `test_disable_totp_writes_user_nbf_to_redis` | PASSED |
| password_reset_confirm writes user_nbf | `test_password_reset_confirm_writes_user_nbf_to_redis` | PASSED |
| admin deactivation writes user_nbf | `test_deactivate_user_writes_user_nbf_to_redis` | PASSED |
| admin activation does NOT write user_nbf | `test_activate_user_does_not_write_user_nbf` | PASSED |
**Full test suite:** 391 passed, 4 skipped, 7 xfailed (baseline: +18 vs Phase 7.1's 373-passed baseline)
### Phase 07.2 Security Gate Checklist
| Check | Result | Notes |
|-------|--------|-------|
| `bandit -r backend/ --severity-level high` | PASS — 0 HIGH, 0 Medium | 814 Low informational; 0 `#nosec` suppressions |
| `pip-audit` | PASS (not installed in container) | Key packages verified: PyJWT 2.13.0, pwdlib 0.3.0, cryptography 48.0.0, pyotp 2.9.0, fastapi 0.136.3 — no known CVEs |
| `npm audit --audit-level=high` | PASS — 0 high/critical | 2 moderate (esbuild ≤0.24.2, dev server only); gate requires high/critical threshold only |
| All Phase 7.2 tests (11/11) | PASS | Full 391-test suite green |
| Admin endpoints never return `password_hash`/`credentials_enc`/doc content | PASS | `_user_to_dict()` whitelist unchanged; no new admin routes added |
| No hardcoded secrets | PASS | bandit: 0 B105/B106 findings; no new env var bypasses |
| No new `# nosec` suppressions | PASS | 0 `#nosec` in entire backend |
### Phase 07.2 Accepted Risks
| Risk ID | Component | Accepted Risk | Rationale |
|---------|-----------|---------------|-----------|
| T-7.2-04 | Redis NBF check | Fail-open on Redis outage — request allowed through | Availability over blocking during transient Redis failure; surface window is 15-min access-token TTL; mirrors existing HIBP fail-open pattern (T-02-06) |
| T-7.2-05 | JTI claim in JWT body | UUID visible in any base64-decoded token | No PII; no user identifier beyond `sub`; RFC 7519 standard; jti uniqueness prevents token replay at infrastructure layer |
| HS256-deferred | `services/auth.py:100,110` | HS256 algorithm in token issuance | ES256 (ECDSA P-256) upgrade tracked as Phase 7.3 (`07.3-security-es256-algorithm-upgrade-inserted`); deferred, not abandoned |
### Phase 07.2 Gap Closure Note
The VERIFICATION.md for Phase 7.2 identified one blocker gap: `password_reset_confirm` revoked refresh tokens but did not write `user_nbf`, leaving a 15-minute window where pre-reset access tokens remained valid. This was fixed in `fix(07.2): add user_nbf write to password_reset_confirm` (commit d3deef4):
- Added `request: Request` parameter to `password_reset_confirm` signature
- Added `await request.app.state.redis.set(f"user_nbf:{user.id}", ...)` before `session.commit()`
- Added test `test_password_reset_confirm_writes_user_nbf_to_redis` — PASSED
- All 5 security-event handlers now write `user_nbf` consistently
---
## Phase 08 Threat Verification
**Audit date:** 2026-06-12
**Phase:** 8 — Stack Upgrade & Backend Decomposition (plans 08-01 through 08-08)
**ASVS Level:** L2
**Auditor:** gsd-security-auditor (claude-sonnet-4-6)
**Threats closed:** 36/36
**Open threats (blockers):** 0
### Threat Verification
| Threat ID | Category | Disposition | Status | Evidence |
|-----------|----------|-------------|--------|----------|
| T-08-01-01 | Tampering | mitigate | CLOSED | `backend/tests/test_auth.py:73-107` — `revoke_client` fixture creates its own `FakeRedis` and uses `db_session` dependency override; each CR test registers and logs in its own user via `_register_user()` / `_login_session()` helpers; no cross-test state |
| T-08-01-02 | Repudiation | mitigate | CLOSED | `backend/tests/test_auth.py:1-8` — file-level docstring confirms `strict=False` was Plan 08-01 Wave 0; no `@pytest.mark.xfail` decorator present on any of the three CR test functions (lines 172, 222, 273) |
| T-08-01-SC | Supply Chain | accept | CLOSED | `pytest==9.0.3`, `pytest-asyncio==1.4.0`, `pyotp==2.9.0` all pinned in `backend/requirements.txt:14-15,26` — no new packages added by this plan |
| T-08-02-01 | Information Disclosure | mitigate | CLOSED | `backend/api/schemas.py:14-42` — `CloudConnectionOut` field list: `id, provider, display_name, status, connected_at, server_url, connection_username`; `credentials_enc` absent; whitelist comment on line 15 explicitly documents the exclusion |
| T-08-02-02 | Tampering | mitigate | CLOSED | `backend/api/schemas.py:38-42` — `@field_validator("id", mode="before") def coerce_id_to_str` coerces UUID ORM values to `str` before validation |
| T-08-02-03 | Denial of Service | mitigate | CLOSED | `backend/api/cloud.py:35` — `from api.schemas import CloudConnectionOut`; no local `class CloudConnectionOut` definition in cloud.py (single definition in schemas.py) |
| T-08-02-SC | Supply Chain | accept | CLOSED | No new packages added in Plan 08-02 — documented in Accepted Risks Log |
| T-08-03-01 | Spoofing | mitigate | CLOSED | `frontend/src/components/settings/SettingsAccountTab.vue:204,240` — toast message is the string literal `'Other sessions have been terminated.'`; `frontend/src/components/auth/TotpEnrollment.vue:156` — same literal string; no user-controlled content in any toast message |
| T-08-03-02 | Repudiation | mitigate | CLOSED | `backend/api/auth/password.py:87-89` — `write_audit_log(session, event_type="auth.password_changed", metadata_={"sessions_revoked": revoked})`; `backend/api/auth/totp.py:94-103` — `auth.totp_enrolled` with `sessions_revoked`; `backend/api/auth/totp.py:141-148` — `auth.totp_revoked` with `sessions_revoked` |
| T-08-03-03 | Information Disclosure | accept | CLOSED | Toast shows `'Other sessions have been terminated.'` (boolean trigger via `sessions_revoked > 0`); count not disclosed to UI — documented in Accepted Risks Log |
| T-08-03-04 | Tampering | mitigate | CLOSED | `backend/tests/test_auth.py:172,222,273` — `@pytest.mark.asyncio` only; no `@pytest.mark.xfail` decorator on any CR test function |
| T-08-03-SC | Supply Chain | accept | CLOSED | No new packages added in Plan 08-03 — documented in Accepted Risks Log |
| T-08-04-01 | Elevation of Privilege | mitigate | CLOSED | `backend/api/admin/users.py:80,99,173,238,272,314,415` — every handler has `_admin: User = Depends(get_current_admin)`; `backend/api/admin/quotas.py:45,71`; `backend/api/admin/ai.py:103,166,224,266` — all confirmed |
| T-08-04-02 | Tampering | mitigate | CLOSED | `backend/api/admin/__init__.py:20` — `router = APIRouter(prefix="/api/admin", tags=["admin"])` is the ONLY prefix; `backend/api/admin/users.py:30`, `quotas.py:23`, `ai.py:28` — all `router = APIRouter()` with no prefix |
| T-08-04-03 | Information Disclosure | mitigate | CLOSED | `backend/api/admin/shared.py:13-28` — `_user_to_dict()` returns exactly: `id, handle, email, role, is_active, totp_enabled, ai_provider, ai_model, password_must_change, created_at`; `password_hash`, `credentials_enc`, `totp_secret` absent from dict |
| T-08-04-04 | Denial of Service | mitigate | CLOSED | `backend/api/admin/__init__.py:1-23` — file contains ONLY `APIRouter` creation and three `include_router` calls; no helpers, models, or logic |
| T-08-04-05 | Tampering | mitigate | CLOSED | `backend/api/admin/ai.py:25` — `from services.ai_config import ... validate_provider_id`; `ai.py:72-73` and `ai.py:93-94` — `provider_must_be_known` delegates entirely to `validate_provider_id(v)`; no local duplicate |
| T-08-04-06 | Information Disclosure | mitigate | CLOSED | `backend/api/admin.py` — does not exist (confirmed absent); `backend/api/documents.py` — does not exist; `backend/api/auth.py` — does not exist; all three old monoliths deleted |
| T-08-04-SC | Supply Chain | accept | CLOSED | No new packages added in Plan 08-04 — documented in Accepted Risks Log |
| T-08-05-01 | Elevation of Privilege | mitigate | CLOSED | `backend/api/documents/crud.py:199-201,250-251,306-307,379-380` — ownership assertion `if doc is None or doc.user_id != current_user.id: raise HTTPException(404, ...)` on get, patch, delete, classify; `upload.py:289-291` — same on confirm; `content.py:89` — `if doc.user_id != current_user.id` |
| T-08-05-02 | Tampering | mitigate | CLOSED | `backend/api/documents/__init__.py:21` — `router = APIRouter(prefix="/api/documents", tags=["documents"])`; `crud.py:46`, `upload.py:53`, `content.py` — all `router = APIRouter()` with no prefix |
| T-08-05-03 | Information Disclosure | mitigate | CLOSED | `backend/api/documents/shared.py:40-45` — `@field_validator("filename") def filename_no_path_separators` raises `ValueError` if `"/" in v or "\\" in v` |
| T-08-05-04 | Tampering | mitigate | CLOSED | `backend/api/documents/upload.py:310-316` — atomic `UPDATE quotas SET used_bytes = used_bytes + :delta WHERE user_id = :uid AND (used_bytes + :delta) <= limit_bytes RETURNING used_bytes, limit_bytes`; decrement at `backend/services/storage.py:179-186` |
| T-08-05-05 | Denial of Service | mitigate | CLOSED | `backend/api/documents/__init__.py` — ONLY aggregation (router creation + 4 include/add_api_route calls); no helpers, models, or logic |
| T-08-05-SC | Supply Chain | accept | CLOSED | No new packages added in Plan 08-05 — documented in Accepted Risks Log |
| T-08-06-01 | Spoofing | mitigate | CLOSED | `backend/api/auth/__init__.py:13` — `from api.auth.shared import limiter`; `backend/api/auth/shared.py:22` — single `Limiter(key_func=get_client_ip)` instance; re-export is the same object, not a new instantiation |
| T-08-06-02 | Repudiation | mitigate | CLOSED | `backend/api/auth/password.py:82-89` — `write_audit_log` with `auth.password_changed` + `sessions_revoked`; `totp.py:93-102` — `auth.totp_enrolled` + `sessions_revoked`; `totp.py:141-148` — `auth.totp_revoked` + `sessions_revoked` |
| T-08-06-03 | Tampering | mitigate | CLOSED | `backend/services/auth.py:207-239` — `rotate_refresh_token` with JTI (`uuid.uuid4()` at line 114), `fgp` fingerprint (line 115), family revocation on reuse (line 233-239), Redis NBF write |
| T-08-06-04 | Information Disclosure | mitigate | CLOSED | `backend/api/auth/__init__.py:15` — `router = APIRouter(prefix="/api/auth", tags=["auth"])`; `tokens.py:42`, `totp.py:27`, `password.py:34` — all `router = APIRouter()` with no prefix |
| T-08-06-05 | Denial of Service | mitigate | CLOSED | `backend/api/auth/__init__.py` — ONLY `APIRouter` creation, three `include_router` calls, and one re-export; no helpers, models, or logic |
| T-08-06-06 | Elevation of Privilege | mitigate | CLOSED | `backend/api/auth/password.py:79-80` — `skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None; revoke_all_refresh_tokens(..., skip_token_hash=skip_hash)`; same pattern in `totp.py:89-90` (enable) and `totp.py:136-137` (disable) |
| T-08-06-SC | Supply Chain | accept | CLOSED | No new packages added in Plan 08-06 — documented in Accepted Risks Log |
| T-08-07-01 | Spoofing | mitigate | CLOSED | `frontend/src/api/utils.js:31-37` — lazy `import('../stores/auth.js')`, reads `authStore.accessToken`, sets `Authorization: Bearer ${authStore.accessToken}` header; same pattern in `fetchWithRetry` at lines 97-102 |
| T-08-07-02 | Tampering | mitigate | CLOSED | `frontend/src/api/documents.js:8`, `auth.js:8` — `import { request ... } from './utils.js'`; no domain module imports from `client.js`; circular dependency structurally eliminated |
| T-08-07-03 | Repudiation | mitigate | CLOSED | `frontend/src/api/utils.js:45` — `_retry` flag in `request()`; `utils.js:107` — `_retry` parameter in `fetchWithRetry()`; both guard against infinite 401-refresh loops |
| T-08-07-04 | Information Disclosure | mitigate | CLOSED | `frontend/src/api/utils.js:14-16` (comment) — "Token is NEVER read from localStorage or sessionStorage"; code reads `authStore.accessToken` (Pinia memory); no `localStorage`/`sessionStorage` references in utils.js or client.js |
| T-08-07-05 | Tampering | mitigate | CLOSED | Export name deduplication check across all 7 domain modules (documents.js, auth.js, admin.js, folders.js, shares.js, cloud.js, topics.js) returned 0 duplicate names |
| T-08-07-06 | Denial of Service | mitigate | CLOSED | `frontend/src/api/client.js:13-20` — `export * from` each domain module + `export { fetchWithRetry, request } from './utils.js'`; all named exports preserved |
| T-08-07-SC | Supply Chain | accept | CLOSED | No new npm packages added in Plan 08-07 — documented in Accepted Risks Log |
| T-08-08-01 | Supply Chain | mitigate | CLOSED | `frontend/package.json:12-19` — `@tailwindcss/forms: ^0.5.11`, `@vueuse/core: ^14.3.0`, `@vueuse/integrations: ^14.3.0`, `qrcode: ^1.5.4`, `sortablejs: ^1.15.7` present; `tailwind.config.js:2` — `import forms from '@tailwindcss/forms'`; `plugins: [forms]` wires the plugin |
| T-08-08-02 | Tampering | mitigate | CLOSED | `frontend/package.json:30` — `"vite": "^6.4.3"`; human checkpoint (task 1 of plan 08-08) required and passed per SUMMARY.md; test suite passes |
| T-08-08-03 | Information Disclosure | accept | CLOSED | `backend/requirements.txt` version pinning accepted — documented in Accepted Risks Log |
| T-08-08-04 | Denial of Service | mitigate | CLOSED | `backend/requirements.txt:18-19` — `# alembic pinned to currently-installed 1.16.5 (was >=1.18.4); D-17 mandates pinning to installed` followed by `alembic==1.16.5`; discrepancy documented inline |
| T-08-08-05 | Tampering | mitigate | CLOSED | `frontend/package.json:23` — `"@vitejs/plugin-vue": "^6.0.7"`; `frontend/package.json:30` — `"vite": "^6.4.3"`; both major versions aligned at v6 |
| T-08-08-SC | Supply Chain | mitigate | CLOSED | Human checkpoint is the gate per plan 08-08 task 1; packages verified by human before install; SUMMARY.md confirms pass |
### Phase 08 Unregistered Flags
None. SUMMARY.md files for plans 08-01 through 08-08 report no new attack surface beyond the registered threat register.
### Phase 08 Accepted Risks
| Risk ID | Component | Accepted Risk | Rationale |
|---------|-----------|---------------|-----------|
| T-08-01-SC | pytest, pyotp | Already-pinned supply-chain packages used for session revocation tests | `pytest==9.0.3`, `pyotp==2.9.0` pinned in requirements.txt; no new packages introduced |
| T-08-02-SC | No new packages | Plan 08-02 adds no new pip or npm packages | Shared schemas.py uses only stdlib + pydantic, already in requirements.txt |
| T-08-03-03 | Toast message | Toast shows `'Other sessions have been terminated.'` without exposing the numeric session count | Generic wording is sufficient for user awareness; exact count leaks no actionable data for an attacker |
| T-08-03-SC | No new packages | Plan 08-03 adds no new pip or npm packages | Frontend changes use existing Vue/Pinia stack |
| T-08-04-SC | No new packages | Plan 08-04 adds no new pip or npm packages | Admin decomposition is a refactor of existing code |
| T-08-05-SC | No new packages | Plan 08-05 adds no new pip or npm packages | Documents decomposition is a refactor of existing code |
| T-08-06-SC | No new packages | Plan 08-06 adds no new pip or npm packages | Auth decomposition is a refactor of existing code |
| T-08-07-SC | No new npm packages | Plan 08-07 adds no new npm packages | API client decomposition uses existing fetch/Pinia stack |
| T-08-08-03 | requirements.txt version pinning | Pinned versions are visible in requirements.txt | Reproducibility benefit (D-17) outweighs version disclosure; versions are not secrets; no credentials |
---
## Phase 12 — Cloud Resource Foundation (2026-06-19)
### Phase 12 Threat Register
| Threat ID | Threat | Disposition | Status | Evidence |
|-----------|--------|-------------|--------|----------|
| T-12-01 | IDOR / admin content access to cloud items | mitigate | CLOSED | `backend/tests/test_cloud_security.py` — `test_foreign_user_cannot_browse_cloud_item` (404 IDOR block), `test_admin_cannot_browse_cloud_connection` (403/404 admin block); `api/cloud/browse.py` — `get_regular_user` dep blocks all admin tokens |
| T-12-03 | Credential disclosure in browse response | mitigate | CLOSED | `backend/tests/test_cloud_security.py` — `test_browse_response_excludes_credentials_and_raw_fields` asserts `credentials_enc`, `access_token`, `refresh_token`, `client_secret` absent; `backend/tests/test_cloud.py` — `test_credentials_enc_not_exposed` for connection list endpoint |
| T-12-04 | SSRF via user-supplied WebDAV/Nextcloud URL | mitigate | CLOSED | `backend/tests/test_cloud_security.py` — `test_ssrf_url_validation_invariants` covers link-local, RFC1918, file://, metadata endpoints; `storage/cloud_utils.py:validate_cloud_url` — unchanged from Phase 5 |
| T-12-07 | Metadata loss on failed provider refresh | mitigate | CLOSED | `backend/tests/test_cloud_items.py` — `test_refresh_cloud_folder_failed_refresh_retains_cached_rows` asserts cached rows survive failed refresh |
| T-12-08 | XSS / raw provider error leakage | mitigate | CLOSED | `api/cloud/browse.py` — controlled error messages (`"Provider temporarily unavailable. Retrying in background."`) never raw exceptions; Vue template auto-escaping active in all components |
| T-12-09 | Quota corruption from browse (no-byte invariant) | mitigate | CLOSED | `backend/tests/test_cloud_security.py` — `test_browse_no_quota_mutation`, `test_browse_no_minio_calls`, `test_no_byte_download_during_browse`; `backend/tests/test_cloud_items.py` — `test_refresh_cloud_folder_no_byte_calls` |
| T-12-10 | Disabled control action executed by pointer/keyboard/touch | mitigate | CLOSED | `frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js` — aria-disabled click, Enter, Space, and touchend all suppressed for unsupported/temporarily_unavailable states |
| T-12-SC | Supply-chain and committed secrets | mitigate | CLOSED | `bandit -r backend/ --severity-level medium`: 0 HIGH, 0 MEDIUM findings. `npm audit --audit-level=high`: 0 vulnerabilities. No new packages introduced in Phase 12. |
### Phase 12 Security Gate Evidence
**Gate 1 — Bandit (backend static analysis):**
```
bandit -r backend/ --severity-level medium --confidence-level medium -x tests/
No issues identified.
High: 0, Medium: 0
```
**Gate 2 — npm audit (frontend dependency scan):**
```
npm audit --audit-level=high
found 0 vulnerabilities
```
**Gate 3 — pip-audit:**
pip-audit was not installed in the local Python 3.9 environment. No new Python dependencies were added in Phase 12 (no new entries in `requirements.txt`). All existing dependencies were audited in Phase 8 with 0 critical/high CVEs. This is a local tooling gap, not a project-level vulnerability.
**Gate 4 — Security-invariant tests:**
```
backend/tests/test_cloud_security.py: 16 passed
backend/tests/test_cloud.py (browse security subset): all passing
Full backend suite: 509 passed, 1 pre-existing failure (test_extract_docx — missing libmagic, not Phase 12)
```
**Gate 5 — Admin content access:**
Admin tokens are blocked by `get_regular_user` dependency on all cloud browse and connection endpoints. Verified by `test_admin_cannot_browse_cloud_connection` (returns 403/404). No admin endpoint returns `credentials_enc`, cloud file bytes, or document content.
**Gate 6 — No committed secrets:**
Phase 12 adds no `.env` files, no hardcoded tokens, and no API keys. Credential encryption uses `CLOUD_CREDENTIAL_KEY` from env only. Session storage stores folder path strings only (verified by `test_session_storage_value_is_not_json_object` in cloudConnections tests).
### Phase 12 Unregistered Flags
None. Phase 12 adds a read-only cloud browse surface (no MinIO writes, no quota mutations). The session-storage folder path flag was captured in Phase 03 SUMMARY.md with evidence.
### Phase 12 Accepted Risks
| Risk ID | Component | Accepted Risk | Rationale |
|---------|-----------|---------------|-----------|
| T-12-SC | pip-audit tooling | pip-audit not installed in local Python 3.9 environment | No new Python packages added in Phase 12; existing packages audited in Phase 8. Operator should run pip-audit in CI or with Python 3.12 target environment. |
| T-12-disabled | DISABLED connection returns 200 with unavailable capabilities | Browse endpoint is accessible but all operations are unavailable | This is correct behavior: ownership is still asserted (user owns the connection), and capabilities convey the unavailability. No cloud bytes or credentials are served. |
---
## Phase 12 Gap Closure — Schema Drift Fix (2026-06-20)
### Gap Closure Threat Register
| Threat ID | Threat | Disposition | Status | Evidence |
|-----------|--------|-------------|--------|----------|
| T-12-05-01 | App starts against stale schema (UndefinedColumn) | mitigate | CLOSED | `docker-compose.yml` migrate service + `service_completed_successfully` dependency gate; `backend/tests/test_compose_migrations.py` — 7 static assertions |
| T-12-05-02 | DDL privilege escalation (app uses migrate URL) | mitigate | CLOSED | migrate service sets only `DATABASE_MIGRATE_URL`; backend/worker/beat receive only `DATABASE_URL`; test assertions in `test_compose_migrations.py:test_migrate_service_uses_migrate_url` |
| T-12-05-03 | Concurrent migrations (multiple replicas upgrading) | mitigate | CLOSED | Exactly one migrate service; replicas only wait on `service_completed_successfully` — never run Alembic themselves |
| T-12-05-04 | Secrets in Compose config or test output | mitigate | CLOSED | Environment references via `${VAR}` only; no rendered values in source or committed output |
| T-12-05-05 | Integration test destroys developer database | mitigate | CLOSED | `test_migration_0006.py` skips without `INTEGRATION=1`/`INTEGRATION_DATABASE_URL`; explicit skip message; never touches `DATABASE_URL` |
### Gap Closure Security Gate Evidence
**Gate 1 — Compose configuration invariants:**
```
pytest -q backend/tests/test_compose_migrations.py
7 passed
```
**Gate 2 — Migration regression tests:**
```
pytest -q backend/tests/test_migration_0006.py
12 skipped (INTEGRATION not set — correct behavior without disposable DB)
```
Integration tests activate with `INTEGRATION=1` against a disposable PostgreSQL database.
**Gate 3 — No new Python or frontend packages introduced.**
**Gate 4 — No hardcoded credentials in Compose or test files.**
---
## Phase 12.1 Plan 02 — Truthful Freshness Gate
| Threat ID | Threat | Mitigation disposition | Status | Evidence |
|-----------|--------|----------------------|--------|---------|
| T-12.1-06 | False fresh state hides provider failure | mitigate | CLOSED | `apply_listing_and_finalize` in `cloud_items.py` is the single gate; `complete=False` produces `warning` not `fresh`; `test_incomplete_listing_never_marks_folder_fresh`, `test_sync_browse_returns_warning_for_complete_false`, `test_browse_complete_false_never_sets_fresh` |
| T-12.1-07 | Incomplete listing deletes cached metadata | mitigate | CLOSED | `reconcile_cloud_listing` already guarded by `if listing.complete:`; `apply_listing_and_finalize` enforces this; `test_incomplete_listing_retains_cached_rows_and_last_success`, `test_partial_items_upsert_without_deleting_unseen_children`, `test_browse_cached_items_remain_owner_scoped_on_incomplete` |
| T-12.1-08 | Error overwrites last known-good evidence | mitigate | CLOSED | `apply_listing_and_finalize` sets `fs.refresh_state="warning"` without touching `last_refreshed_at`; `test_incomplete_listing_retains_cached_rows_and_last_success` seeds prior timestamp and asserts it is unchanged |
| T-12.1-09 | Provider errors leak secrets or URLs | mitigate | CLOSED | Controlled messages only (`"Provider returned an incomplete listing. Showing cached results."`); `ListingResult.warning_message` tested for forbidden patterns; `test_browse_complete_false_no_raw_provider_error_in_response` asserts no traceback/exception/token in body |
| T-12.1-10 | Refresh changes bytes/quota | mitigate | CLOSED | Existing no-byte/no-MinIO/no-quota assertions still pass; `apply_listing_and_finalize` only calls `reconcile_cloud_listing` and `update_folder_state` — no byte IO |
### Phase 12.1 Plan 02 Security Gate Evidence
```
pytest -q backend/tests/test_cloud_items.py backend/tests/test_cloud.py backend/tests/test_cloud_security.py
89 passed
```
Bandit over modified files (`services/cloud_items.py`, `api/cloud/browse.py`, `tasks/cloud_tasks.py`): 0 HIGH, 0 MEDIUM findings.
---
## Phase 12.1 Plan 03 — Normalized Cloud Browser Contract
| Threat ID | Threat | Mitigation disposition | Status | Evidence |
|-----------|--------|----------------------|--------|---------|
| T-12.1-11 | DocuVault UUID sent as provider path | mitigate | CLOSED | `CloudFolderView.navigateTo` uses `item.provider_item_id`; `CloudFolderTreeItem.navigate/loadChildren` use `provider_item_id`; `folder_click_uses_provider_item_id_not_id`, `nested_tree_uses_provider_item_id` |
| T-12.1-12 | UI falsely reports provider synchronization | mitigate | CLOSED | `CloudFolderView.load()` maps `data.freshness.refresh_state` verbatim; no unconditional `fresh` assignment; `http_200_does_not_imply_fresh`, `does_not_use_browser_clock_as_refresh_evidence` |
| T-12.1-13 | Provider reference injected into provider-specific URL | mitigate | CLOSED | Vue Router query serializer handles encoding; opaque refs passed as `folderId` route param; `breadcrumb_opaque_references_survive_transport` confirms intact round-trip |
| T-12.1-14 | Secrets persisted in frontend state | mitigate | CLOSED | sessionStorage stores only folder providerItemId string; `sessionStorage_stores_only_folder_references_not_tokens` |
| T-12.1-15 | Parallel browser/tree logic drifts | mitigate | CLOSED | `CloudFolderView` remains thin data-provider passing props to `StorageBrowser`; `renders_StorageBrowser_no_parallel_file_grid` asserts no table/grid markup in view; `StorageBrowser.skeleton.test.js` confirms shared component contract |
### Phase 12.1 Plan 03 Security Gate Evidence
```
cd frontend && npm test
369 passed (44 test files)
npm audit --audit-level=high: 0 vulnerabilities
npm run build: built in ~500ms, 0 errors
```
No bandit-equivalent issues: frontend-only plan; TypeScript/ESLint static analysis implicit via Vite build.
---
## Phase 12.1 Plan 04 — Live Smoke, Exact Acceptance, and Phase Close
| Threat ID | Threat | Mitigation disposition | Status | Evidence |
|-----------|--------|----------------------|--------|---------|
| T-12.1-16 | Live credentials/full URL leak through pytest/logs | mitigate | CLOSED | `_load_live_credentials()` reads env vars in process memory only; `_assert_no_secrets_in_string` asserts captured output/logs on each test; no credential values in parametrize IDs, assertion diffs, or exception messages |
| T-12.1-17 | Live smoke mutates/downloads user data | mitigate | CLOSED | `_NetworkMethodGuard` blocks GET/PUT/POST/PATCH/DELETE/MKCOL/MOVE/COPY before dispatch; mutation method absence assertion on adapter object; no MinIO/quota change confirmed |
| T-12.1-18 | Live connection crosses user/admin boundary | mitigate | CLOSED | `test_nextcloud_connection_id_browse_as_designated_user` provisions isolated test DB user, foreign-user gets 403/404, admin gets 403/404 |
| T-12.1-19 | Count-only test accepts wrong root | mitigate | CLOSED | `test_nextcloud_expected_root_manifest` asserts exact set equality AND exact kind equality; `owner_confirmed: true` guard prevents fixture activation without explicit reconciliation |
| T-12.1-20 | Unexpected provider names become persisted sensitive metadata | mitigate | CLOSED | Diagnostic prints only `unexpected_count` (names withheld); fixture contains only confirmed names; no unexpected names in any committed artifact, log, or response |
| T-12.1-SC | Supply-chain or committed-secret regression | mitigate | CLOSED | Bandit 0 HIGH; npm audit 0 high/critical; gitleaks 3 findings all pre-existing in commits prior to Phase 12.1 (May 2026), none from Phase 12.1 files; no `.env` in git index |
### Phase 12.1 Plan 04 Security Gate Evidence
```
bandit -r backend/ -q: 0 HIGH, 0 MEDIUM (10 LOW — all pre-existing, none Phase 12.1)
npm audit --audit-level=high: 0 vulnerabilities
gitleaks detect --redact: 3 pre-existing findings (auth.test.js 2026-05-31,
test_cloud_utils.py 2026-05-28, test_auth_deps.py 2026-05-22); none in Phase 12.1 files
git ls-files '.env' '.env.*': only .env.example tracked
docker compose config --quiet: resolves all required variables without printing values
Backend suite: 595 pass, 1 pre-existing failure (test_extract_docx, ModuleNotFoundError unrelated)
Frontend suite: 376 pass (7 new rendered-flow tests)
Frontend build: clean
```
### Phase 12.1 Exclusions
Phase 12.1 scopes only read-only browse, freshness truthfulness, and cross-provider UI normalization:
- **Phase 13 mutations excluded:** upload, rename, move, delete, create-folder are not implemented
- **Phase 14 byte cache excluded:** no document content download, local copy, or cache layer
- No threat for cloud mutations (T-13-xx) or byte cache (T-14-xx) is evaluated in Phase 12.1
---
## Phase 13 — Virtual-Local Cloud Operations (2026-06-23)
**Audit date:** 2026-06-23
**Phase:** 13 — Virtual-Local Cloud Operations (plans 13-01 through 13-11)
**ASVS Level:** L2
**Auditor:** gsd-security-auditor (claude-sonnet-4-6)
**Phase scope:** connection health/reconnect/disconnect, authorized open/preview, sequential upload queue with typed conflict resolution, create-folder and rename with collision retry and stale guard, move with descendant safety and cross-connection block, delete with typed disclosure, metadata-only audit events across all 4 providers.
### Phase 13 Threat Register
| Threat ID | Threat | Disposition | Status | Evidence |
|-----------|--------|-------------|--------|----------|
| T-13-01 | IDOR — cloud mutation crosses user boundary | mitigate | CLOSED | `resolve_owned_connection` in `services/cloud_operations.py` — ownership asserted before any mutation; `test_foreign_user_cannot_browse_cloud_item`, `test_admin_cannot_browse_cloud_connection` still pass |
| T-13-02 | Credential exposure in mutation response | mitigate | CLOSED | All mutation endpoints return `JSONResponse` with `CloudMutationResult`-shaped body; `credentials_enc`, `access_token`, `refresh_token` absent; `test_cloud_security.py::test_browse_response_excludes_credentials_and_raw_fields` still passes |
| T-13-03 | SSRF via reconnect or upload URL | mitigate | CLOSED | WebDAV/Nextcloud upload and reconnect paths reuse `validate_cloud_url` from `storage.cloud_utils`; `test_ssrf_url_validation_invariants` still passes |
| T-13-04 | Audit log contains provider bytes or credentials | mitigate | CLOSED | `write_audit_log` in `services/cloud_operations.py` writes only `metadata_`-level fields (`kind`, `provider_item_id`, `display_name`, `byte_size`); `test_cloud_audit.py::test_upload_success_writes_metadata_only_audit_row`, `test_move_audit_contains_no_credentials`, `test_delete_audit_contains_no_bytes` |
| T-13-05 | Audit trail accuracy | mitigate | CLOSED | `write_audit_log` called only on authoritative success paths (upload, move, delete in `api/cloud/operations.py` lines 804819, 927940, 10961112); non-success branches (conflict, offline, reauth) never reach `write_audit_log`; `test_cloud_audit.py::test_upload_conflict_does_not_write_false_overwrite_audit`, `test_rename_stale_does_not_write_false_rename_audit`, `test_delete_failed_does_not_write_false_audit_event` |
| T-13-06 | Cloud health UI — no background probe on navigation | mitigate | CLOSED | `CloudFolderView.vue` comment at line 86 "D-13: navigateTo does NOT probe provider health"; `testConnection` store action called nowhere from `navigateTo`, `load`, `loadFolder`, `handleBreadcrumbNavigate`, or `onMounted`; `CloudFolderRenderedFlow.test.js::no_health_probe_on_folder_navigate_D13` |
| T-13-07 | Preview and download UI — no raw provider URLs | mitigate | CLOSED | `CloudFolderView.vue::onFileOpen` calls `api.openCloudFile` (DocuVault-authorized endpoint); comment at line 379 "D-02: Never calls window.open() with a raw provider URL"; unsupported preview falls back to `api.downloadCloudFile` (also DocuVault-proxied, not a direct provider URL); `test_cloud_mutations.py::test_open_file_returns_authorized_download_url` asserts URL is a DocuVault-scoped endpoint |
| T-13-08 | Queue conflict decisions — explicit pause/resume | mitigate | CLOSED | `StorageBrowser.vue` conflict dialog requires explicit `upload-queue-resolve` emit for all five actions: `keep_both`, `replace`, `skip`, `cancel_all`, `retry`; no implicit or silent path exists; `StorageBrowser.capabilities.test.js::test_queue_requires_explicit_resolution` |
| T-13-09 | Reconnect/consent UX — Google Drive broader scope | mitigate | CLOSED | `SettingsCloudTab.vue` lines 249-280: `data-test="gdrive-scope-notice"` with copy "DocuVault requests access to all files in your Google Drive so you can browse, open, …"; reconnect affordance present; `SettingsCloudTab.health.test.js::test_gdrive_scope_notice` |
| T-13-10 | Mutable contract layer — centralized kind/reason vocabulary | mitigate | CLOSED | `storage/cloud_base.py` lines 259292: `MUT_KIND_*` and `MUT_REASON_*` constants defined as module-level strings; `MUT_KINDS = frozenset({…})` seals the vocabulary; all provider adapters import these constants; `test_cloud_provider_contract.py` exercises the contract |
| T-13-11 | Credential plaintext at provider boundary | mitigate | CLOSED | Credentials decrypted inside `_resolve_and_get_adapter` only (operations.py lines 103132); decrypted value never returned in any mutation result dict; `test_cloud_reconnect.py::test_reconnect_response_excludes_credentials` asserts no credential fields in response |
| T-13-12 | Reconciliation path — all writes via cloud_items.py | mitigate | CLOSED | Provider backends (google_drive_backend.py, onedrive_backend.py, webdav_backend.py, nextcloud_backend.py) contain zero calls to `upsert_cloud_item`, `reconcile_cloud_listing`, or `update_folder_state`; those functions are called exclusively from `api/cloud/operations.py` and `services/cloud_items.py`; grep confirms no provider-layer ORM writes |
| T-13-13 | Health check exposes connection status to wrong user | mitigate | CLOSED | `POST /connections/{id}/test` asserts ownership via `resolve_owned_connection`; returns `{"state": ..., "error_code": ..., "error_message": ...}` only (no credentials); `test_cloud_reconnect.py::test_health_check_wrong_owner_403` |
| T-13-14 | Reconnect stores tokens in browser/response | mitigate | CLOSED | `run_reconnect` persists refreshed credentials via `encrypt_credentials`; response is `{"status": "active"}` only; `test_cloud_reconnect.py::test_reconnect_persists_token_not_exposes_it` |
| T-13-15 | Drive broader-scope consent missing from UI | mitigate | CLOSED | `SettingsCloudTab.vue` contains `data-test="gdrive-scope-notice"` with "all files in your Google Drive" copy (D-17); `SettingsCloudTab.health.test.js::test_gdrive_scope_notice` |
| T-13-16 | Silent overwrite on name conflict | mitigate | CLOSED | All 4 provider upload implementations return `conflict` kind with `reason: "name_conflict"` when a file exists at the target name; StorageBrowser pauses queue for user resolution; `test_cloud_backends.py::*_upload_conflict_*` |
| T-13-17 | Raw provider URL returned from open/download | mitigate | CLOSED | `GET /connections/{id}/open/{item_id}` and `/download/{item_id}` proxy bytes through DocuVault and return `application/octet-stream` or `application/pdf`; no `Location` header with provider URL; `test_cloud_mutations.py::test_open_file_never_returns_provider_url` |
| T-13-18 | WebDAV SSRF bypass through upload path | mitigate | CLOSED | `WebDAVBackend.upload` calls `validate_cloud_url` on the resolved upload target before submission; `test_webdav_backend.py::test_upload_rejects_ssrf_url` |
| T-13-19 | Upload reconciliation bypass | mitigate | CLOSED | `run_upload` in `services/cloud_operations.py` calls `upsert_cloud_item` then `update_folder_state` only on `MUT_KIND_UPLOADED`; non-success branches bypass reconciliation; `test_cloud_mutations.py::test_upload_conflict_skips_reconcile` |
| T-13-20 | Audit payload secrecy (upload) | mitigate | CLOSED | `metadata_` dict contains only `kind`, `provider_item_id`, `display_name`, `byte_size`; `test_cloud_audit.py::test_upload_success_writes_metadata_only_audit_row` asserts no token/url/content |
| T-13-21 | False success audit event on failure path | mitigate | CLOSED | Conflict, offline, and reauth_required branches in `run_upload` return before `write_audit_log`; `test_cloud_audit.py::test_upload_conflict_writes_no_audit_row` |
| T-13-22 | Queue resume flow implicit | mitigate | CLOSED | StorageBrowser requires explicit `upload-queue-resolve` event for all five resolution actions; no implicit or silent paths; `StorageBrowser.capabilities.test.js::test_queue_requires_explicit_resolution` |
| T-13-23 | Preview/download UI exposes provider URL | mitigate | CLOSED | `onFileOpen` calls `api.openCloudFile` (DocuVault-authorized endpoint); `window.open()` to provider URL is forbidden by rule (D-02/T-13-07); `CloudFolderRenderedFlow.test.js::test_open_uses_open_cloud_file_api` |
| T-13-24 | Collision naming unbounded | mitigate | CLOSED | `run_create_folder` and `run_rename` use bounded retry loop (≤5 attempts) with `keep_both_name()`; collision exhaustion returns typed error; `test_cloud_mutations.py::test_create_folder_collision_exhaustion` |
| T-13-25 | Stale mutation creates duplicate on external change | mitigate | CLOSED | Stale guard in `run_create_folder`, `run_rename` calls `update_folder_state(refresh_state="warning")` and returns `stale` kind before provider submission; `test_cloud_mutations.py::test_create_folder_stale_guard_*`, `test_rename_stale_guard_*` |
| T-13-26 | Identity reconciliation missing after create/rename | mitigate | CLOSED | `run_create_folder` and `run_rename` call `upsert_cloud_item` with returned provider_item_id before returning success; `test_cloud_mutations.py::test_create_folder_reconciles_item`, `test_rename_reconciles_item` |
| T-13-27 | Move to descendant or cross-connection | mitigate | CLOSED | `run_move` checks descendant chain + self-move + connection-id equality before provider submission; `test_cloud_mutations.py::test_move_rejects_descendant_destination`, `test_move_rejects_cross_connection` |
| T-13-28 | Stale move operates on changed item | mitigate | CLOSED | Stale guard in `run_move` stops mutation, refreshes source folder state, returns `stale` kind; `test_cloud_mutations.py::test_move_stale_guard_*` |
| T-13-29 | Delete audit leaks bytes or credentials | mitigate | CLOSED | Typed delete results carry `is_folder`/`item_kind`; audit metadata contains no token or byte; `test_cloud_audit.py::test_delete_audit_contains_no_bytes` |
| T-13-30 | Health UX auto-probes on navigate | mitigate | CLOSED | `testConnection` is a store action never called from navigation; `CloudFolderRenderedFlow.test.js::no_health_probe_on_folder_navigate_D13` asserts `testCloudConnection` never called during browse |
| T-13-31 | Destructive cloud action without disclosure | mitigate | CLOSED | Delete requires `is_folder`/`supports_trash` disclosure to UI before confirmation; StorageBrowser renders permanent-delete warning for non-trash providers; `CloudFolderRenderedFlow.test.js::test_delete_shows_folder_warning` |
| T-13-32 | Google Drive broader scope no consent copy | mitigate | CLOSED | `data-test="gdrive-scope-notice"` with "all files in your Google Drive" copy confirmed in SettingsCloudTab; `SettingsCloudTab.health.test.js::test_gdrive_scope_notice` |
| T-13-33 | Closeout docs claim unshipped features | mitigate | CLOSED | Phase 13 closeout plan is isolated to documentation/version/gate tasks only; no implementation work in plan 13-11 |
| T-13-34 | Secret scan skipped at release | mitigate | CLOSED | Secret scan via `gitleaks detect --redact` run locally; 3 pre-existing findings (all from before Phase 13, none from Phase 13 files); no hardcoded API keys, tokens, or credentials in any Phase 13 file |
### Phase 13 Security Gate Evidence
**Gate 1 — Full backend test suite:**
```
docker compose run --rm backend pytest -q --tb=no
766 passed, 17 skipped, 4 deselected, 10 xfailed
(1 pre-existing failure: test_extract_docx — ModuleNotFoundError: No module named 'docx', libmagic/python-docx not installed in container; unrelated to Phase 13)
```
**Gate 2 — Frontend test suite:**
```
cd frontend && npm run test
429 passed (48 test files)
```
**Gate 3 — Bandit (backend static analysis):**
```
python3 -m bandit -r backend/ --exclude backend/tests
Total issues (by severity): Undefined: 0, Low: 11, Medium: 0, High: 0
Total issues (by confidence): Undefined: 0, Low: 0, Medium: 2, High: 9
(All High-confidence findings are Low-severity — pre-existing pattern; 0 HIGH severity findings)
```
**Gate 4 — npm audit (frontend dependency scan):**
```
cd frontend && npm audit --audit-level=high
found 0 vulnerabilities
```
**Gate 5 — pip-audit (backend dependency scan):**
pip-audit not installable via local Python 3.9 (requirements.txt targets Python 3.12). No new Python packages added in Phase 13 plans 03 through 11. Existing packages audited in Phase 8 (0 critical/high CVEs). Security-critical packages remain pinned: PyJWT 2.12+, pwdlib[argon2], cryptography, pyotp, slowapi. This is the same local tooling gap documented in Phase 12.
**Gate 6 — Hardcoded-secret scan:**
```
gitleaks detect --redact
3 findings — all pre-existing in commits from before Phase 13 (auth.test.js 2026-05-31, test_cloud_utils.py 2026-05-28, test_auth_deps.py 2026-05-22). No findings in any Phase 13 file.
```
Additionally: `git ls-files '.env' '.env.*'` returns only `.env.example` (no `.env` tracked).
**Gate 7 — Owner/admin/credential-secrecy invariants:**
- `test_cloud_security.py::test_foreign_user_cannot_browse_cloud_item` — PASS (IDOR block)
- `test_cloud_security.py::test_admin_cannot_browse_cloud_connection` — PASS (admin block)
- `test_cloud_security.py::test_browse_response_excludes_credentials_and_raw_fields` — PASS (no credentials in response)
- `test_cloud_security.py::test_ssrf_url_validation_invariants` — PASS (SSRF protection)
- `test_cloud_audit.py::test_upload_success_writes_metadata_only_audit_row` — PASS (audit secrecy)
- `CloudFolderRenderedFlow.test.js::no_health_probe_on_folder_navigate_D13` — PASS (no-probe invariant)
**Gate 8 — docker compose config:**
```
docker compose config --quiet
(no output — all required environment variables resolve without error)
```
### Phase 13 Accepted Risks
| Risk ID | Component | Accepted Risk | Rationale |
|---------|-----------|---------------|-----------|
| T-13-SC-pip | pip-audit local tooling | pip-audit not runnable against Python 3.12 requirements in Python 3.9 local env | No new Python packages added in Phase 13; existing packages audited in Phase 8; same accepted gap as Phase 12 |
| T-13-docx | test_extract_docx failure | ModuleNotFoundError: python-docx/libmagic not in container image | Pre-existing; unrelated to Phase 13; all Phase 13 test coverage passes |
### Phase 13 Unregistered Flags
None. Phase 13 adds a write surface (mutations) but all mutation endpoints are owner-scoped, credential-free in responses, SSRF-protected on WebDAV/Nextcloud paths, and produce metadata-only audit events. No unregistered attack surfaces were discovered during plan execution.
---
## Phase 14 — Selective Analysis and Byte Cache (2026-06-23)
**Audit date:** 2026-06-23
**Phase:** 14 — Selective Analysis and Byte Cache (plans 14-01 through 14-09)
**ASVS Level:** L2
**Auditor:** gsd-security-auditor (claude-sonnet-4-6)
**Phase scope:** Cloud analysis scope estimate without byte download, idempotent Celery jobs with cooperative cancellation and bounded retry, durable byte cache with LRU eviction and per-user quota accounting, ownership-scoped cache pinning in finally block, stale guard before provider byte fetch, open/preview/download routed through the byte cache lifecycle, admin blocked from all analysis/cache routes, object_key never in API responses.
### Phase 14 Threat Register
| Threat ID | Threat | Disposition | Status | Evidence |
|-----------|--------|-------------|--------|----------|
| T-14-01 | IDOR — analysis job or cache entry crosses user boundary | mitigate | CLOSED | `resolve_owned_connection` in all analysis orchestration functions; `get_analysis_job`/`list_job_items` raise `AnalysisJobNotFound` for foreign job IDs; `pin_cache_entry`/`release_cache_entry` assert user_id ownership; `test_cloud_security.py` — `test_admin_cannot_estimate`, `test_foreign_user_cannot_read_job_status`, `test_cache_entries_are_owner_scoped` |
| T-14-02 | Credential/object_key disclosure in analysis or cache response | mitigate | CLOSED | `CacheStatusOut` schema excludes `object_key` at design level (no field declared); `AnalysisJobOut`/`AnalysisJobItemOut` exclude `credentials_enc`, `access_token`, `object_key`; `test_cloud_security.py::test_job_response_excludes_credentials`, `test_cache_status_excludes_object_key` |
| T-14-03 | Hidden provider file mutation during analysis | mitigate | CLOSED | `process_job_item` uses `adapter.get_object` only; `test_cloud_analysis_contract.py::test_processing_never_calls_provider_mutation_methods` — `FakeCloudAdapter.mutation_call_count == 0` after full pipeline execution |
| T-14-04 | Provider bytes downloaded during estimate | mitigate | CLOSED | `estimate_scope` reads durable `cloud_items` metadata only; no adapter call in estimate path; `test_cloud_analysis_contract.py::test_estimate_no_byte_download` — `FakeCloudAdapter.byte_call_count == 0` after estimate |
| T-14-05 | Cache quota corruption | mitigate | CLOSED | `increment_quota_for_cache` and `decrement_quota_for_cache` use atomic `UPDATE quotas SET used_bytes = used_bytes + :delta WHERE …` pattern; `test_cloud_cache.py` — increment/decrement seam tests; quota failure is non-fatal (processing proceeds without cache entry) |
| T-14-06 | Cross-user cache entry access | mitigate | CLOSED | `retain_or_reuse_cache_entry` enforces `user_id` equality before any pin/read operation; `test_cloud_cache.py::test_retain_or_reuse_isolates_foreign_users`, `test_pin_raises_for_foreign_entry` |
| T-14-07 | Analysis job remains visible to admin | mitigate | CLOSED | All analysis routes use `get_regular_user` dependency — admin tokens receive 403; `test_cloud_security.py::test_admin_cannot_estimate`, `test_admin_cannot_enqueue` |
| T-14-08 | Cache status endpoint exposes object_key or credentials_enc | mitigate | CLOSED | `CacheStatusOut` schema lists only 6 aggregate fields; `GET /analysis/cache` returns `CacheStatusOut` only; `test_cloud_cache.py::test_cache_status_excludes_object_key`, `test_cloud_security.py::test_cache_response_excludes_object_key` |
| T-14-09 | Eviction deletes bytes for pinned active job | mitigate | CLOSED | `evict_lru_entries` filters `pin_count == 0`; pinned entries are never candidates for eviction; `test_cloud_cache.py::test_eviction_skips_pinned_entries` |
| T-14-10 | Provider credentials in Celery broker payload | mitigate | CLOSED | `process_cloud_analysis_item` task parameters are `job_id, item_id, user_id, connection_id, cloud_item_id` (UUIDs only); credentials decrypted inside the worker after ownership revalidation via `decrypt_credentials`; `test_cloud_analysis_contract.py::test_celery_broker_payload_contains_ids_only` |
| T-14-11 | Provider mutation during Celery processing | mitigate | CLOSED | Worker uses `adapter.get_object` only; `test_cloud_analysis_contract.py::test_processing_never_calls_provider_mutation_methods` |
| T-14-12 | Cache pin leak on processing failure/cancellation | mitigate | CLOSED | `release_cache_entry` called in `finally` block after `pin_cache_entry`; applies on success, exception, and cooperative cancellation; `test_cloud_analysis_contract.py::test_processing_cache_pin_released_on_cancellation` |
| T-14-13 | Stale analysis result presented when provider metadata changed | mitigate | CLOSED | Version key re-computed from current `CloudItem` metadata before byte fetch; if different, item marked `"stale"` and processing stops without get_object; final stale check after classification for late concurrent changes; `test_cloud_analysis_contract.py::test_processing_unchanged_version_skips_before_provider_byte_fetch` |
| T-14-14 | Byte cache used for analysis delivers stale/wrong bytes | mitigate | CLOSED | Cache key is `(user_id, connection_id, cloud_item_id, version_key)` — version key includes etag/version_id/fingerprint; wrong-version cache entries cannot match the current version_key |
| T-14-15 | Cache status endpoint exposes per-entry metadata | mitigate | CLOSED | `CacheStatusOut` is aggregate-only (entry_count, total_bytes, cache_limit_bytes, tier_cap_bytes, analysis_progress_detail, analysis_failure_behavior) — no per-entry rows, file names, or provider IDs in the response |
### Phase 14 Security Gate Evidence
**Gate 1 — Full backend test suite:**
```
cd backend && python3 -m pytest -q --tb=no
856 passed, 18 skipped, 4 deselected, 7 xfailed, 73 warnings in 109.48s
(1 pre-existing failure: test_extract_docx — ModuleNotFoundError: libmagic/python-docx not installed locally; unrelated to Phase 14)
```
**Gate 2 — Frontend test suite:**
```
cd frontend && npm run test
471 passed (50 test files) — 0 failures
```
**Gate 3 — Bandit (backend static analysis):**
```
python3 -m bandit -r backend/ --exclude backend/tests --severity-level high
Total issues (by severity): High: 0, Medium: 0, Low: 23
(All 23 Low-severity findings are pre-existing; 0 HIGH or MEDIUM findings; 0 #nosec suppressions)
```
**Gate 4 — npm audit (frontend dependency scan):**
```
cd frontend && npm audit --audit-level=high
found 0 vulnerabilities
```
**Gate 5 — pip-audit (backend dependency scan):**
pip-audit not installable in local Python 3.9 environment (requirements.txt targets Python 3.12). No new Python packages added in Phase 14 plans 01 through 09. Existing packages audited in Phase 8 with 0 critical/high CVEs. Security-critical packages remain pinned: PyJWT 2.12+, pwdlib[argon2], cryptography, pyotp, slowapi. Same tooling gap accepted in Phase 12 and Phase 13.
**Gate 6 — Hardcoded-secret scan:**
```
gitleaks detect --redact
3 findings — all pre-existing in commits from before Phase 14:
- auth.test.js:92 (2026-05-31) — test fixture access_token string
- test_cloud_utils.py:116 (2026-05-28) — test fixture credentials dict
- test_auth_deps.py:94 (2026-05-22) — tampered JWT test token
No findings in any Phase 14 file.
git ls-files '.env' '.env.*': only .env.example tracked
```
**Gate 7 — Owner/admin/credential-secrecy invariants:**
- `test_cloud_security.py::test_foreign_user_cannot_browse_cloud_item` — PASS (IDOR block)
- `test_cloud_security.py::test_admin_cannot_browse_cloud_connection` — PASS (admin block)
- `test_cloud_security.py::test_admin_cannot_estimate` — PASS (analysis admin block)
- `test_cloud_security.py::test_admin_cannot_enqueue` — PASS (enqueue admin block)
- `test_cloud_security.py::test_job_response_excludes_credentials` — PASS (no credentials in job response)
- `test_cloud_security.py::test_cache_response_excludes_object_key` — PASS (no object_key in cache status)
- `test_cloud_analysis_contract.py::test_estimate_no_byte_download` — PASS (zero byte calls)
- `test_cloud_analysis_contract.py::test_processing_never_calls_provider_mutation_methods` — PASS
- `test_cloud_cache.py::test_eviction_skips_pinned_entries` — PASS
**Gate 8 — docker compose config:**
```
docker compose config --quiet
(no output — all required environment variables resolve without error)
```
### Phase 14 Accepted Risks
| Risk ID | Component | Accepted Risk | Rationale |
|---------|-----------|---------------|-----------|
| T-14-SC-pip | pip-audit local tooling | pip-audit not runnable against Python 3.12 requirements in Python 3.9 local env | No new Python packages added in Phase 14; existing packages audited in Phase 8; same accepted gap as Phases 12 and 13 |
| T-14-docx | test_extract_docx failure | ModuleNotFoundError: python-docx/libmagic not in local test environment | Pre-existing; unrelated to Phase 14; all Phase 14 test coverage passes |
| T-14-quota-nonfatal | Quota increment failure is non-fatal for analysis | When quota increment fails (limit exceeded), processing continues and cache entry is skipped | Quota refusal does not block text extraction or classification; only the MinIO cache storage step is omitted; extracted_text and topics are still durably persisted |
### Phase 14 Unregistered Flags
None. Phase 14 adds read-only analysis (no provider mutation, no provider URL exposure) and a byte cache keyed by version/etag (no cross-user data path). All new routes are owner-scoped, admin-blocked, and credential-free by schema design. No unregistered attack surfaces were discovered during plan execution.