Security audit (5 phases) → reports under _docs/05_security/. AZ-501 (F-SAST-1, HIGH): Externalize hardcoded Google Geocode key from mission-planner/src/config.ts to VITE_GOOGLE_GEOCODE_KEY via new GeocodeService.ts; fail-soft warn when unset; STC-SEC1D static deny-list gate; +5 unit tests in tests/mission_planner_geocode.test.ts. AZ-502 (F-DEP-1, HIGH): Force vite>=6.4.2 and postcss>=8.5.10 via package.json overrides in both roots; clean reinstall clears all bun audit advisories. Test-spec sync (Step 12) + Update Docs (Step 13) deltas: AC-43, AC-44, NFT-SEC-09b, FT-P-61, FT-N-17, ripple log, batch_12 report. Pending user actions: revoke Google + OWM keys (AC-6 / AZ-499 AC-7). 229 PASS / 13 SKIP / 0 FAIL on static + fast suites. Co-authored-by: Cursor <cursoragent@cursor.com>
11 KiB
Security Tests
Blackbox security assertions against the SPA's observable surface: token storage discipline, refresh cookie attributes, RBAC route gating, credentials flag, secrets-in-source checks, destructive-action policy, dependency hygiene. These complement the server's RBAC and the suite's security_approach (_docs/00_problem/security_approach.md); they do NOT replace server-side enforcement (O4).
NFT-SEC-01: Bearer is never written to localStorage or sessionStorage
Traces to: AC-02, O2 Profile: static + e2e
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | Static code-search on src/ and mission-planner/src/ for `localStorage. |
sessionStorage.nearbearer |
| 2 | E2E: complete a login; inspect localStorage and sessionStorage keys |
neither contains the bearer value |
Pass criteria: row 04 — match_count == 0; runtime storage does not contain the bearer.
Expected result source: results_report.md row 04.
NFT-SEC-02: document.cookie does not expose the refresh token
Traces to: AC-03 Profile: e2e + static
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | Static code-search for document.cookie reads against `refreshToken |
refresh-cookie` |
| 2 | E2E: complete login; read document.cookie from page context |
returned string does NOT contain the refresh-token value (row 06) |
Pass criteria: rows 05 + 06.
Expected result source: results_report.md rows 05, 06.
NFT-SEC-03: Refresh cookie attributes — Secure, HttpOnly, SameSite=Strict
Traces to: AC-03, E3, O5 Profile: e2e
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | Login via POST /api/admin/auth/login against the suite stack |
Set-Cookie header returned |
| 2 | Inspect header value | matches regex Secure;.*HttpOnly;.*SameSite=Strict (case-insensitive, attribute-order-tolerant) |
Pass criteria: row 07 — regex match.
Notes: this is a server-contract assertion; the UI test exists as defence-in-depth so a suite regression is caught before it lands in production.
Expected result source: results_report.md row 07.
NFT-SEC-04: credentials: 'include' is set on every authenticated fetch
Traces to: AC-01, O3 Profile: fast (apiClient wrapper) + e2e (live capture)
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | Issue an authenticated request via apiClient |
RequestInit captured |
| 2 | Inspect | credentials === 'include' (row 01) |
| 3 | Repeat for the bootstrap refresh | same (row 02 — quarantined until Step 4 bootstrap fix) |
Pass criteria: rows 01 + 02.
Expected result source: results_report.md rows 01, 02.
NFT-SEC-05: /admin route blocks non-admins client-side (defence in depth)
Traces to: AC-22
Profile: e2e — quarantined until role-gate is added (Step 4 / Step 8)
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | Log in as op_alice (Operator, no admin role) |
session active |
| 2 | Navigate to /admin |
URL changes |
| 3 | Inspect final URL + DOM | URL is /flights; <AdminPage> NOT mounted |
Pass criteria: row 08.
Notes: server-side RBAC is authoritative; the UI gate is a usability + leakage layer.
Expected result source: results_report.md row 08.
NFT-SEC-06: /settings route gate is applied per RBAC
Traces to: AC-22
Profile: e2e — quarantined
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | Log in as user without SETTINGS permission | session active |
| 2 | Navigate to /settings |
URL changes |
| 3 | Inspect | URL is /flights; <SettingsPage> NOT mounted |
Pass criteria: row 10.
Expected result source: results_report.md row 10.
NFT-SEC-07: alert() is forbidden anywhere in the SPA
Traces to: AC-14, O10 Profile: static
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | Regex sweep src/ and mission-planner/src/ for \balert\( |
match_count == 0 |
Pass criteria: row 50.
Expected result source: results_report.md row 50.
NFT-SEC-08: ConfirmDialog gates every destructive action
Traces to: AC-14, AC-30, O10 Profile: fast
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | For each destructive surface in _docs/ui_design/ (class delete, user deactivate, dataset bulk-overwrite, etc.) |
sequence checked |
| 2 | Confirm sequence on click → before any HTTP fires | dialog present (row 51) |
| 3 | On Confirm in class-delete flow → exactly one DELETE to ^/api/admin/classes/[0-9]+$ |
(row 49) |
Pass criteria: rows 49 + 51.
Expected result source: results_report.md rows 49, 51.
NFT-SEC-09: OpenWeatherMap API key is not shipped in source or bundle
Traces to: AC-20, AC-42 (AZ-499 AC-5, AC-7), P10 Profile: static (source) + static (bundle)
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | STC-SEC1 — Regex sweep src/ for appid=[a-zA-Z0-9]{6,} (filtered to exclude import.meta.env / process.env references) |
match_count == 0 (row 63) |
| 2 | STC-SEC1B — Scan dist/**/*.js post-build for the literal key value |
match_count == 0 (NFT-SEC-09 AC-1 dist portion) |
| 3 | STC-SEC1C — Scan src/ AND mission-planner/ for the literal value of the previously-committed key (335799082893fad97fa36118b131f919); test files excluded; delegated to node scripts/check-banned-deps.mjs --kind=owm_key_in_source |
match_count == 0 (row 63 — AZ-499 AC-5) |
Pass criteria: row 63 (project-level AC-20) AND AZ-499 AC-5 (source scan must reject any future re-introduction of the literal key under src/ or mission-planner/).
Status: All three checks ACTIVE (no quarantine). The source check was un-quarantined on cycle 2 close (2026-05-12) when AZ-499 (a) replaced the hardcoded key in mission-planner/src/services/WeatherService.ts with import.meta.env.VITE_OWM_API_KEY and (b) added STC-SEC1C so a regression cannot silently re-introduce the literal across either source tree (closing the AZ-482 source-scan gap that previously only checked src/ for the regex shape and dist/ for the literal — mission-planner/ stays out of dist/ per STC-S5, so the dist scan alone could not catch it).
Defense-in-depth note: the previously-committed key value (335799082893fad97fa36118b131f919) MUST be revoked at the OpenWeatherMap dashboard — this is AZ-499 AC-7, a manual deliverable, not a test. STC-SEC1C complements but does not replace key revocation.
Expected result source: results_report.md row 63; AZ-499 AC-5.
NFT-SEC-09b: Google Geocode API key is not shipped in source
Traces to: AC-43 (AZ-501 AC-1, AC-4, AC-6) Profile: static (source) + fast (env-resolution + fail-soft contract)
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | STC-SEC1D — Scan src/ AND mission-planner/ for the literal value of the previously-committed Google key (AIzaSyAhvDeYukuyWVrQYbRhuv91bsi_jj5_Iys); test files excluded; delegated to node scripts/check-banned-deps.mjs --kind=google_key_in_source |
match_count == 0 (AZ-501 AC-4) |
| 2 | Fast: import mission-planner/src/services/GeocodeService.ts and stub import.meta.env.VITE_GOOGLE_GEOCODE_KEY; assert outgoing fetch URL contains the env-resolved key |
URL contains key=<env-value> (AZ-501 AC-1; tests/mission_planner_geocode.test.ts) |
| 3 | Fast: stub VITE_GOOGLE_GEOCODE_KEY='' and call geocodeAddress('Kyiv') |
returns null, no fetch issued, single console.warn mentioning VITE_GOOGLE_GEOCODE_KEY (AZ-501 AC-3) |
Pass criteria: AZ-501 AC-1, AC-3, AC-4 — env-resolved + fail-soft + static gate against literal re-introduction.
Status: ACTIVE on cycle 2 close (2026-05-12). The key was extracted from mission-planner/src/config.ts to a new services/GeocodeService.ts module to enable isolated env-resolution + fail-soft testing (mirrors AZ-499 / WeatherService pattern).
Defense-in-depth note: the previously-committed key (AIzaSyAhvDeYukuyWVrQYbRhuv91bsi_jj5_Iys) MUST be revoked at the Google Cloud Console — this is AZ-501 AC-6, a manual deliverable, not a test. STC-SEC1D complements but does not replace key revocation.
Expected result source: AZ-501 AC-1, AC-3, AC-4.
NFT-SEC-10: No in-browser ML libs
Traces to: AC-N2 Profile: static
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | Parse package.json and mission-planner/package.json dependencies |
dependency lists |
| 2 | Match against `^(onnxruntime | @?tensorflow(?:js)?(?:/.*)? |
Pass criteria: row 92.
Expected result source: results_report.md row 92.
NFT-SEC-11: No response-signature / JOSE libs on the request path
Traces to: AC-N4 Profile: static
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | Parse package.json dependencies |
list |
| 2 | Match against `^(jsrsasign | tweetnacl |
Pass criteria: row 94.
Expected result source: results_report.md row 94.
NFT-SEC-12: No service worker — offline mode is explicitly absent
Traces to: AC-N3 Profile: e2e
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | Load the SPA in a fresh browser context | app boots |
| 2 | Read navigator.serviceWorker.getRegistrations() |
empty array |
Pass criteria: row 93 — no service worker registered.
Expected result source: results_report.md row 93.
NFT-SEC-13: Dropped legacy features are not present in source
Traces to: AC-N5 Profile: static
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | Regex sweep src/ and mission-planner/src/ for `SoundDetections |
DroneMaintenance` |
Pass criteria: row 95.
Expected result source: results_report.md row 95.
NFT-SEC-14: Anti-criterion AC-N1 — no concurrent-edit reconciliation surfaces
Traces to: AC-N1 Profile: e2e + static
Steps:
| Step | Consumer Action | Expected Response |
|---|---|---|
| 1 | Open the same annotation in two browser sessions; edit both | both save individually |
| 2 | Inspect each session's DOM | no merge UI; no presence indicator |
Pass criteria: row 91.
Notes: this is an anti-criterion — the test enforces that the feature is NOT silently added.
Expected result source: results_report.md row 91.