14 KiB
phase, slug, status, threats_open, asvs_level, created, audited
| phase | slug | status | threats_open | asvs_level | created | audited |
|---|---|---|---|---|---|---|
| 13 | virtual-local-cloud-operations | verified | 0 | 2 | 2026-06-23 | 2026-06-23 |
Phase 13 — Security: Virtual-Local Cloud Operations
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.
Trust Boundaries
| Boundary | Description |
|---|---|
| client → cloud API | Untrusted user input can trigger reconnect, content, and mutation operations |
| cloud API → provider SDK/HTTP | Provider failures and URLs must be normalized before reaching API responses |
| request transaction → audit log | Successful operations must log metadata only, with no secret or byte leakage |
| server health state → store/UI | Trusted backend status must not be replaced by client-side guesswork |
| user interaction → shared browser dialogs | Conflict and delete choices must remain explicit and auditable |
| router/service → provider | Provider exceptions and refreshed credentials must be normalized before leaving the backend boundary |
| service → database | Only orchestration code may persist refreshed credentials or trigger reconciliation |
| browser → reconnect/content routes | Untrusted requests must stay owner-scoped and CSRF-protected |
| cloud route → provider bytes | Provider content must stay behind DocuVault authorization and typed error shaping |
| browser upload → provider write | User content enters a provider-owned mutation path through DocuVault authorization only |
| provider SDK/HTTP → route result | Provider conflict, retry, and replace behavior must be normalized before Vue consumes it |
| upload success → metadata state | Only authoritative success may update cloud metadata or folder freshness |
| upload success → audit log | Only authoritative success may write an audit row, and it must stay metadata-only |
| typed backend result → shared queue UI | The client must consume authoritative conflict/error typing instead of inventing semantics |
| content helper → preview surface | Preview must stay behind DocuVault authorization |
| proposed new name → provider mutation | User-supplied names must be collision-safe, stale-safe, and provider-neutral before mutation |
| mutation success → metadata state | Only authoritative success may update cloud metadata and folder freshness |
| user-selected destination → provider mutation | Invalid folder destinations must be rejected before and after provider submission |
| delete success → audit trail | Only authoritative success may write metadata-only delete events |
| server health/mutation results → store/UI | The UI must present backend truth without inventing probe or mutation semantics |
| shared browser interactions → destructive actions | Delete and move UX must stay explicit and capability-aware |
| shipped code → documentation/security evidence | Closeout must describe only what actually shipped |
Threat Register
| Threat ID | Threat | Category | Disposition | Status | Evidence |
|---|---|---|---|---|---|
| T-13-01 | IDOR — cloud mutation crosses user boundary | E | 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 pass |
| T-13-02 | Credential exposure in mutation response | I | 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 passes |
| T-13-03 | SSRF via reconnect or upload URL | T | mitigate | CLOSED | WebDAV/Nextcloud upload and reconnect paths reuse validate_cloud_url from storage.cloud_utils; test_ssrf_url_validation_invariants passes |
| T-13-04 | Audit log contains provider bytes or credentials | I | mitigate | CLOSED | write_audit_log 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 | R | mitigate | CLOSED | write_audit_log called only on authoritative success paths; non-success branches never reach audit; 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 | T | mitigate | CLOSED | CloudFolderView.vue D-13 guard: testConnection called nowhere from navigation functions; CloudFolderRenderedFlow.test.js::no_health_probe_on_folder_navigate_D13 |
| T-13-07 | Preview and download UI — no raw provider URLs | I | mitigate | CLOSED | onFileOpen calls api.openCloudFile (DocuVault-authorized); fallback uses api.downloadCloudFile; no window.open() with raw provider URL; test_cloud_mutations.py::test_open_file_returns_authorized_download_url |
| T-13-08 | Queue conflict decisions — explicit pause/resume | R | mitigate | CLOSED | StorageBrowser conflict dialog requires explicit upload-queue-resolve emit for all five actions; no silent path; StorageBrowser.capabilities.test.js::test_queue_requires_explicit_resolution |
| T-13-09 | Reconnect/consent UX — Google Drive broader scope | S | mitigate | CLOSED | SettingsCloudTab.vue: data-test="gdrive-scope-notice" with broader-scope copy; reconnect affordance present; SettingsCloudTab.health.test.js::test_gdrive_scope_notice |
| T-13-10 | Mutable contract layer — centralized kind/reason vocabulary | T | mitigate | CLOSED | storage/cloud_base.py: MUT_KIND_* and MUT_REASON_* constants; MUT_KINDS = frozenset({…}) seals vocabulary; all providers import from here; test_cloud_provider_contract.py |
| T-13-11 | Credential plaintext at provider boundary | I | mitigate | CLOSED | Credentials decrypted only in _resolve_and_get_adapter (operations.py:103–132); never returned in mutation results; test_cloud_reconnect.py::test_reconnect_response_excludes_credentials |
| T-13-12 | Reconciliation path — all writes via cloud_items.py | T | mitigate | CLOSED | All 4 provider backends contain zero calls to upsert_cloud_item, reconcile_cloud_listing, or update_folder_state; reconciliation exclusively in api/cloud/operations.py and services/cloud_items.py |
| T-13-13 | Health check exposes connection status to wrong user | E | mitigate | CLOSED | POST /connections/{id}/test asserts ownership via resolve_owned_connection; returns state/error fields only; test_cloud_reconnect.py::test_health_check_wrong_owner_403 |
| T-13-14 | Reconnect stores tokens in browser/response | I | 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 | T | mitigate | CLOSED | SettingsCloudTab.vue: data-test="gdrive-scope-notice" with "all files in your Google Drive" copy; SettingsCloudTab.health.test.js::test_gdrive_scope_notice |
| T-13-16 | Silent overwrite on name conflict | T | mitigate | CLOSED | All 4 provider upload implementations return conflict kind with reason: "name_conflict"; StorageBrowser pauses queue; test_cloud_backends.py::*_upload_conflict_* |
| T-13-17 | Raw provider URL returned from open/download | I | mitigate | CLOSED | /open/{item_id} and /download/{item_id} proxy bytes through DocuVault; 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 | T | mitigate | CLOSED | WebDAVBackend.upload calls validate_cloud_url before submission; inherited by Nextcloud; test_webdav_backend.py::test_upload_rejects_ssrf_url |
| T-13-19 | Upload reconciliation bypass | T | mitigate | CLOSED | run_upload calls upsert_cloud_item + update_folder_state only on MUT_KIND_UPLOADED; conflict/offline/reauth bypass reconciliation; test_cloud_mutations.py::test_upload_conflict_skips_reconcile |
| T-13-20 | Audit payload secrecy (upload) | I | 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 |
| T-13-21 | False success audit event on failure path | R | mitigate | CLOSED | Conflict, offline, reauth branches return before write_audit_log; test_cloud_audit.py::test_upload_conflict_writes_no_audit_row |
| T-13-22 | Queue resume flow implicit | T | mitigate | CLOSED | StorageBrowser requires explicit upload-queue-resolve for all five resolution actions; no silent paths; StorageBrowser.capabilities.test.js::test_queue_requires_explicit_resolution |
| T-13-23 | Preview/download UI exposes provider URL | I | mitigate | CLOSED | onFileOpen calls api.openCloudFile; window.open() to provider URL forbidden by D-02; CloudFolderRenderedFlow.test.js::test_open_uses_open_cloud_file_api |
| T-13-24 | Collision naming unbounded | T | mitigate | CLOSED | run_create_folder and run_rename use bounded retry loop (≤5 attempts) with keep_both_name(); exhaustion returns typed error; test_cloud_mutations.py::test_create_folder_collision_exhaustion |
| T-13-25 | Stale mutation creates duplicate on external change | T | mitigate | CLOSED | Stale guard 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 | T | 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 | T | 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 | T | 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 | I | mitigate | CLOSED | Typed delete results carry is_folder/item_kind only; 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 | T | mitigate | CLOSED | testConnection never called from navigation; CloudFolderRenderedFlow.test.js::no_health_probe_on_folder_navigate_D13 |
| T-13-31 | Destructive cloud action without disclosure | R | mitigate | CLOSED | Delete requires is_folder/supports_trash disclosure; 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 | I | mitigate | CLOSED | data-test="gdrive-scope-notice" with "all files in your Google Drive" copy in SettingsCloudTab; SettingsCloudTab.health.test.js::test_gdrive_scope_notice |
| T-13-33 | Closeout docs claim unshipped features | R | mitigate | CLOSED | Phase 13 closeout plan isolated to documentation/version/gate tasks only; no implementation work in plan 13-11 |
| T-13-34 | Secret scan skipped at release | I | mitigate | CLOSED | gitleaks detect --redact run; 3 pre-existing findings (all pre-Phase 13); no Phase 13 file findings |
Security Gate Evidence
Gate 1 — Full backend test suite:
766 passed, 17 skipped, 4 deselected, 10 xfailed
(1 pre-existing failure: test_extract_docx — python-docx/libmagic not in container; unrelated to Phase 13)
Gate 2 — Frontend test suite:
429 passed (48 test files)
Gate 3 — Bandit (backend static analysis):
Total issues: Low: 11, Medium: 0, High: 0
(All High-confidence findings are Low-severity — pre-existing pattern; 0 HIGH severity findings)
Gate 4 — npm audit:
found 0 vulnerabilities
Gate 5 — pip-audit: Not runnable (Python 3.9 local env vs Python 3.12 requirements). No new packages in Phase 13. Existing packages audited in Phase 8 (0 critical/high CVEs). Security-critical packages pinned. See accepted risks.
Gate 6 — Secret scan:
gitleaks detect --redact: 3 findings — all pre-existing (pre-Phase 13 commits)
No findings in any Phase 13 file.
Gate 7 — Owner/admin/credential-secrecy invariants: All pass — IDOR block, admin block, no credentials in response, SSRF protection, audit secrecy, no-probe invariant.
Gate 8 — docker compose config: Resolves without error.
Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---|---|---|---|---|
| T-13-SC-pip | pip-audit 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 | gsd-security-auditor | 2026-06-23 |
| T-13-docx | test_extract_docx failure | ModuleNotFoundError: python-docx/libmagic not in container image. Pre-existing; unrelated to Phase 13 | gsd-security-auditor | 2026-06-23 |
Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|---|---|---|---|---|
| 2026-06-23 | 34 | 34 | 0 | gsd-security-auditor (claude-sonnet-4-6) |
Sign-Off
- All threats have a disposition (mitigate / accept / transfer)
- Accepted risks documented in Accepted Risks Log
threats_open: 0confirmedstatus: verifiedset in frontmatter