Files
kite/SECURITY.md
T
curo1305 7bb046ac41 fix(12.1): align cloud browser contract — kind, provider_item_id, server freshness
Normalized cloud frontend for DocuVault v0.2.5:
- Cloud UI classifies items by kind=folder/file (is_dir removed from all active paths)
- Folder navigation uses provider_item_id (opaque provider ref) — DocuVault id kept for row identity
- Server freshness (refresh_state, last_refreshed_at) mapped verbatim — no browser-clock evidence
- Breadcrumb lineage built from explicit visited nodes — never split(provider_item_id)
- CloudFolderTreeItem: expandable/icon/navigate/loadChildren all use kind and provider_item_id
- Version bump 0.2.4 → 0.2.5
- SECURITY.md: T-12.1-11 through T-12.1-15 closed
- 369 tests pass, npm audit clean, production build clean
2026-06-22 08:51:14 +02:00

56 KiB
Raw Blame History

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:93payload.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:310code_hash=hash_password(code) (Argon2); services/auth.py:338verify_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:360sha1[: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-371httpx timeout=5.0, except Exception: logger.warning(…); return False (fail-open)
T-02-07 EoP mitigate CLOSED deps/auth.py:87if 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-26PyJWT>=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:100samesite="strict" on refresh cookie (api/auth.py:100); main.py:47-61OriginValidationMiddleware rejects non-GET/HEAD/OPTIONS requests with Origin not in settings.cors_origins
T-02-12 Info Disclosure accept CLOSED Accepted: stores/auth.jsaccessToken = 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-40SecurityHeadersMiddleware sets Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff on every response
T-02-15 Tampering mitigate CLOSED main.py:124allow_origins=settings.cors_origins; grep for allow_origins=["*"] returns 0 matches
T-02-16 EoP mitigate CLOSED api/auth.py:259-260if 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-126decode_password_reset_token checks payload.get("typ") != "password-reset" raises ValueError
T-02-21 EoP mitigate CLOSED api/auth.py:730POST /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.pygrep -c get_current_admin returns 12; every handler has _admin: User = Depends(get_current_admin)
T-02-26B Spoofing mitigate CLOSED services/auth.py:330BackupCode.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,377status_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:189v-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:255password_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-174v-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:42meta: { requiresAdmin: true } on /admin route; router/index.js:91-93if (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:265await 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-88null_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:221token = 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-113suffix = 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:327size = 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-351UPDATE 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-177cleanup_abandoned_uploads Celery task exists; _cleanup_abandoned() selects Document.status == "pending" and Document.created_at < cutoff (1 hour); backend/celery_app.py:43-46beat_schedule entry with _timedelta(minutes=30)
T-03-09 Info Disclosure mitigate CLOSED docker-compose.yml:26MINIO_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-60self._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-109get_regular_user raises HTTP 403 if user.role == "admin"; backend/api/documents.pyDepends(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:44storage.load_topics_for_user(session, user_id=current_user.id); backend/api/topics.py:64storage.create_topic(..., user_id=current_user.id)
T-03-14 EoP mitigate CLOSED backend/api/admin.py:602-622POST /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:113object_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:35security = 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-64user = 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-25xhr.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,30item.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:70const 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-149fetchQuota() 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:81except 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-84except 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-361user_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-107revoke_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-42CloudConnectionOut 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:35from 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-89write_audit_log(session, event_type="auth.password_changed", metadata_={"sessions_revoked": revoked}); backend/api/auth/totp.py:94-103auth.totp_enrolled with sessions_revoked; backend/api/auth/totp.py:141-148auth.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:20router = 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:25from services.ai_config import ... validate_provider_id; ai.py:72-73 and ai.py:93-94provider_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:89if doc.user_id != current_user.id
T-08-05-02 Tampering mitigate CLOSED backend/api/documents/__init__.py:21router = 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:13from 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-89write_audit_log with auth.password_changed + sessions_revoked; totp.py:93-102auth.totp_enrolled + sessions_revoked; totp.py:141-148auth.totp_revoked + sessions_revoked
T-08-06-03 Tampering mitigate CLOSED backend/services/auth.py:207-239rotate_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:15router = 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-80skip_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:8import { 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-20export * 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:2import 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.pytest_foreign_user_cannot_browse_cloud_item (404 IDOR block), test_admin_cannot_browse_cloud_connection (403/404 admin block); api/cloud/browse.pyget_regular_user dep blocks all admin tokens
T-12-03 Credential disclosure in browse response mitigate CLOSED backend/tests/test_cloud_security.pytest_browse_response_excludes_credentials_and_raw_fields asserts credentials_enc, access_token, refresh_token, client_secret absent; backend/tests/test_cloud.pytest_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.pytest_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.pytest_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.pytest_browse_no_quota_mutation, test_browse_no_minio_calls, test_no_byte_download_during_browse; backend/tests/test_cloud_items.pytest_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.