# TODO — Security audit & performance review (2026-08-23) Findings ordered by severity/impact. Line references verified against working tree. Context: pipeline is runCam → mofin → terp → coordinator (+ livestream-cache side channel), all communicating via plaintext gob over TCP. Ports assume LAN/Tailscale isolation today; every finding marked [NET] matters the moment any port is reachable beyond localhost. --- ## SECURITY ### Critical **S1. Path traversal → arbitrary file write in coordinator** [NET] `coordinator/main.go` `handleClip`: `user, camera := splitSource(clp.SourceData)` takes attacker-controlled strings straight into `filepath.Join(storageDir, user, camera, dateShard, dirName)` then `MkdirAll` + writes `clip.mp4`/`clip.json`. A crafted `SourceData` such as `"../../../tmp--evil"` escapes the storage root and writes an attacker-controlled MP4 (raw pixel bytes) outside it. Even benign-looking names with `/` create unintended nested dirs (the test suite itself uses camera name containing `/`). Fix: validate both fields against `^[A-Za-z0-9._-]+$` (reject otherwise), reject empty, and add a final `strings.HasPrefix(finalPath, storageRoot+sep)` assertion. **S2. Remote process-crash DoS across the whole pipeline** [NET] Ingest paths never validate clip internal consistency (`CheckLenCorrelations()` exists in frame/frame.go but is called nowhere except tests): - `coordinator/main.go insertClip/dirName`: indexes `clp.Guids[0]`, `clp.Timestamps[0]`, `clp.PixelMats[0]` unchecked → panic. - `mofin/main.go` → `frame.CountChangedPixels`: loops `i < len(PixelMats)-1` indexing `c.Guids[i]` → short `Guids` slice panics. Any malformed gob Clip crashes the entire receiving process (panic in handler goroutine = process exit). One bad client kills surveillance for all cameras. Fix: call `clip.CheckLenCorrelations()` immediately after every decode in mofin ingest, terp ingest, coordinator handleClip, livestream-cache handleTCPConnection; drop + log on failure. Also wrap handlers with `recover()` as defense-in-depth. **S3. TLS private key committed to git** `livestream-cache/key.pem` (+ `cert.pem`) tracked in history. Regenerate, move out of repo, gitignore, and treat the old key as burned (anyone with repo history can MITM the :8443 viewer traffic). ### High **S4. No transport encryption or authentication on any pipeline hop** [NET] mofin (:8083), terp (:8081), coordinator (:8082), livestream-cache ingest (:8085) accept plaintext gob from anyone who can reach the port, binding all interfaces (`":8083"`-style addresses, not `127.0.0.1`). Consequences: forged footage injected into any stage, live view poisoning, clip replay/spoofing, free reconnaissance of camera names via logged SourceData. Only livestream-cache's *viewer* port has TLS — and no auth either (see S5). Fix options (pick one): a. Default binds to `127.0.0.1` + explicit opt-in interface config (cheapest); b. Shared-token handshake as first gob message on each conn (cheap, works over Tailscale); c. Full mTLS mirroring the existing cert setup (strongest). **S5. Unauthenticated MJPEG viewing** `handleMJPEGStream` checks only that `sourceData` exists in `cameraUsers`. Anyone reaching :8443 watches every camera feed. Add HTTP BasicAuth (bcrypt-hashed creds in YAML) or a bearer token per viewer; return 401 before touching the stream. **S6. Unbounded gob decode → memory-exhaustion DoS** [NET] Every listener decodes attacker-sized `[][]byte` payloads with no cap (a single Clip can declare gigabytes of pixel data → OOM). Combined with S7 this is trivially scriptable. Fix: enforce a max-clip-bytes config; simplest robust route is `io.LimitReader`-wrapped conn + length-prefixed framing, or decode then reject if `len(PixelMats) > MaxFrames` / `len(frame) > MaxFrameBytes` *before* any processing/allocation beyond the decode itself. **S7. No connection deadlines anywhere (slowloris / goroutine exhaustion)** [NET] Zero `SetDeadline` calls repo-wide (verified). Servers hold one goroutine + buffers per client indefinitely; a handful of idle sockets starves the process. Also client-side: `Clip.Send` has DialTimeout but no write deadline, and `flush()` in runCam calls it inline — a stuck receiver freezes that camera's clip building. Fix: `conn.SetDeadline(time.Now().Add(N))` after accept and refresh per message; write deadline around every send including `Clip.Send`. ### Medium **S8. Passphrases exposed via CLI flags and plaintext configs** `-passphrase` flags appear in `ps`/`/proc/*/cmdline` for every local user and persist in shell history; YAML configs store them in cleartext. Fix: support env var + `-passfile` (or keyring prompt), never echo back; document migration. Related landmine: legacy `frame.ConsumeMat`/`preframeWrapper.ToFrame` concatenate `CfgData.PassHash` INTO `SourceData` — if ever rewired, password hashes leak into clip.json metadata and DB rows. Delete the hash-from-metadata behavior now while the code is dead. **S9. Metadata travels in cleartext even when frames are encrypted** AES-GCM covers `PixelBytes` only; SourceData (username/camera), GUIDs, timestamps, detections, and Comparisons ride plaintext gob. Network observer learns which cameras, when, and how active they are. Fix: extend encryption to the serialized metadata block (e.g., encrypt a marshalled header alongside pixels) or tunnel the whole gob stream in TLS (S4c solves this wholesale). **S10. Livestream-cache trusts whatever arrives for a registered camera** [NET] If a camera is registered without a passphrase, any host can inject frames into the live view (no origin concept exists). Subsumed by S4b/S4c, but worth an explicit note: the cache should reject conns lacking the shared secret even when passphrases are unused. **S11. Hardened servers missing timeouts / limits (HTTPS viewer)** `http.Server` created without `ReadHeaderTimeout`, `IdleTimeout`, `MaxHeaderBytes`; unlimited concurrent streams. Slowloris applies here too. Add sane timeouts + a semaphore-capped stream count. **S12. Footage written world-readable** Storage dirs/files default 0755/0644 (`MkdirAll`, VideoWriter output). On multi-user hosts any local account can copy surveillance footage. Write with 0750/0640 (umask or explicit Chmod after MkdirAll). **S13. MySQL DSN built via fmt.Sprintf** Special characters in db-user/password silently corrupt the DSN (and error messages may echo it). Use `mysql.Config{...}.FormatDSN()`; load creds from env/file rather than flags. ### Low - **S14 Log injection**: raw request paths/hosts flow into log lines (livestream-cache, coordinator); sanitize/strip control chars. - **S15 Git remote over plain HTTP** (`http://gitea:3000/...`) — switch to SSH or HTTPS for push traffic on shared LANs. - **S16 oko-run flag interpolation** builds child-process args from unquoted map values — harmless today (config is trusted, exec doesn't use a shell) but easy to misuse; quote values explicitly. - **S17 `Frame.Errors` never populated** — no audit trail for dropped/failed frames; populate at capture time (decode failures, drops-on-full-channel) so downstream can report data-quality issues. --- ## PERFORMANCE Ordered by expected impact on the target hardware (Pi Zero W-class). **P1. PBKDF2 with 600k iterations executed PER FRAME** — `frame.Encrypt` (frame/frame.go). A 10 s clip at ~50 fps = ~500 KDF derivations per clip, each ~100 ms+ on a Pi → encryption dominates total CPU and burns through most of the clip interval. Fix: derive ONE key per clip — move salt to Clip level (add `Salt []byte` field, derive once in mofin, encrypt every frame with distinct random nonces). Backward-compatible enough since all stages deploy together. Expected: ~99% reduction in KDF cost. **P2. Haar cascade XML re-loaded for every frame, for every location** — `frame.Detect` (frame/detect.go:40-43) constructs `gocv.NewCascadeClassifier` + `.Load()` per frame per model path. XML parsing dwarfs actual detection cost. Fix: load all classifiers once at terp startup, share the objects across worker goroutines (`detectMultiScale` is thread-safe for reads). Expected: order-of-magnitude speedup of the classification stage. **P3. Raw, uncompressed pixels end-to-end** — 320×240×3 B ≈ 230 KB/frame; observed clip = ~500 frames ⇒ ~115 MB per clip held in RAM (builder buffer + gob encode buffer + decode side), plus 115 MB bursts on the wire every 10 s per camera. On a Zero W this is the memory ceiling. Options in ascending effort: a. Cap clip duration/fps for the motion stage (e.g. analyze 5 fps subsample); b. Per-frame JPEG (or MJPEG stream) on the wire — ~10–20× size reduction, decode cost paid once in mofin; c. H.264 chunk streaming (matches what coordinator re-encodes anyway). At minimum document the footprint math near the clip builder. **P4. Motion analysis done TWICE** — mofin computes `CountChangedPixels` and stores it in `clp.Comparisons`; terp then recomputes identical values (terp/main.go classify loop) instead of trusting `clp.Comparisons` when present. Free 2× saving on the AbsDiff stage: `if len(clp.Comparisons) > 0 { skip }`. **P5. mofin decrypts then re-encrypts every frame** (fresh salt each time, defeating any KDF caching) purely to run motion detection on plaintext. With P1 fixed the re-encrypt is cheap, but better: move motion gating upstream into runCam (pre-send) and ship encrypted clips untouched through mofin, or have mofin operate on a small thumbnail sub-stream (P3a) so bulk pixels never decrypt/re-encrypt. **P6. Livestream JPEG re-encoded per viewer per tick** — `handleMJPEGStream` encodes a fresh JPEG from the cached RGBA image for EVERY connected client EVERY 100 ms. N viewers = N× encode cost. Fix: cache `[]byte` JPEG per camera; re-encode only when a new frame lands (janitor/ingest hook), serve the same bytes to all viewers. **P7. Pure-Go BGR→RGBA pixel loop in frameToImage** — per-pixel Go loop with bounds checks per frame. Since gocv is already in the module graph, replace the whole helper with `gocv.Mat` construction from bytes + `gocv.IMencode(".jpg", mat, &buf)` — moves color conversion AND compression into optimized OpenCV code in one step (subsumes P6's encoder too). **P8. Motion comparison allocates heavily per frame-pair** — `compareTo` creates 3 Mats (diff/gray/thresh) and converts overlapping frames to Mat twice (frame i used as pair i-1 tail and pair i head). Fix inside `CountChangedPixels`: keep previous frame's gray Mat alive between iterations, reuse scratch Mats with `Mat.CopyTo`/in-place ops, and downscale to e.g. 96×72 grayscale before diffing (motion gating needs neither resolution nor color). Expected: 10–30× less work in the hottest OpenCV section. **P9. Unbounded goroutine-per-connection/per-clip** in mofin, terp, coordinator handlers. One burst of clips = unbounded concurrent 100 MB-scale allocations (compounds S6/P3). Cap with a buffered-channel semaphore (configurable workers, default 2–4) per service. **P10. DB writes row-at-a-time** — `insertClip` Execs one INSERT per detection. Prepare once per connection + single transaction per clip; enable go-sql-driver interpolateParams for small batches. Matters when detections-per-clip grows (YOLO wiring will multiply it). **P11. VideoWriter codec fallback ends at MJPG** — MJPG fallback produces files ~5–10× larger than mp4v/H264. Prefer trying `avc1 → mp4v → MJPG`, and make codec/fourcc configurable per deployment. Also `computeFPS` uses only the first frame delta — average over several deltas to avoid wildly wrong timestamps after capture hiccups. **P12. Dev-loop: oko-run rebuild check stats binary existence only** — stale binaries are silently reused after source edits. Compare mtimes (newest .go vs binary) or add a `-force-rebuild` flag. --- ## COMPUTER-VISION PIPELINE ENHANCEMENTS Grouped: correctness first, then accuracy, then capability. ### Correctness (do these before trusting any detections) **C1. YOLO head parsed wrong — class information discarded.** `DetectYolo` reads only the box/conf channels; ONNX YOLOv8/v11 export layout is [1, 84, N] = cx,cy,w,h + 80 class scores, so every detection currently shares one title and confidence source. Fix: transpose to [N,84], take argmax class + its score as the detection, map through a COCO names table, filter by per-class thresholds. **C2. NMS never applied to YOLO output.** `filterNMS` exists but is unwired — expect dozens of duplicate boxes per object. Apply class-aware NMS (IoU ~0.45) after C1. **C3. Model loaded per call in DetectYolo path** (`loadModel` per invocation). Same fix as P2: load Net once at startup; `net.Empty()`/forward is the only per-frame op. ### Accuracy **C4. Replace Haar fullbody primary detection.** Haar at 320×240 yields heavy false positives/negatives. Better ladder: (a) HOG+SVM person detector as cheap upgrade; (b) a lightweight DNN detector (MobileNet-SSD / nano-YOLO) reusing the existing ONNX plumbing — the infrastructure lands with C1–C3 anyway. **C5. Background subtraction instead of consecutive-frame diff.** `gocv.BackgroundSubtractorMOG2` gives: robustness to gradual lighting changes, built-in shadow suppression, contour masks for area/centroid features (replacing raw changed-pixel counts), and long-term stationary-object suppression. Keep frame-diff as fallback for the first seconds after startup (model warm-up). **C6. Clean up the motion signal.** Before/after diffing: Gaussian blur (σ≈3) to kill sensor noise; morphological open+close on the threshold mask; minimum-contour-area gate; auto-calibrated threshold per camera from rolling idle-period noise statistics (mean+3σ of changed pixels) instead of magic constant 30. **C7. ROI masking per camera.** Configurable polygon per view (sky/hedges/road excluded) applied to the motion mask — eliminates the classic swaying-trees/headlights false alarms. ### Capability **C8. Event-shaped recording.** Today fixed 10 s ticker slices cut events mid-action and record dead air. Switch clip builder to: rolling ring buffer (~5 s pre-roll) + trigger on motion + record until quiet for N s (+ hysteresis), emitting variable-length event clips. Biggest UX win per line of code. **C9. Intra-clip tracking.** IoU-match detections across frames within a clip → stable object IDs, direction, dwell time; persist track summaries to MySQL next to detections. Enables "person loitered 4 min" queries and dedupes alert storms. **C10. Quality gates.** Variance-of-Laplacian blur check per captured frame; discard garbage (exposure glitches) before they poison motion stats; populate `Frame.Errors` (S17) with reasons for observability. **C11. Review affordances at coordinator:** save first-detection-frame JPEG thumbnails + detection crops beside clip.mp4; add a tiny JSON index endpoint for a future web UI. Cheap, transforms triage from "watch every mp4" to scanning a contact sheet. **C12. Retention/GC job.** Storage grows unbounded (only janitor is the live-view cache). Age-based pruning + total-quota enforcement in coordinator, config-driven. **C13. Notification hook.** After terp classification, POST event summaries (camera, class, track info, thumbnail ref) to a webhook/Ntfy/MQTT — turns the pipeline from recorder into alarm system. **C14. Cross-camera correlation (later).** Time-windowed matching of tracks across cameras (same user namespace) for entry/exit reasoning. --- ## Suggested execution order Quick wins first (hours): S2 validation+recover, S1 traversal guard, S5 basic auth, P4 (one-liner), P2 (classifier reuse), P6/P7 (jpeg cache + IMencode). Then structural (days): P1 KDF-per-clip, S4b token auth, S6/S7 deadlines+caps, P8 motion-buffer reuse, C1+C2+C3 YOLO fix. Then feature work: C8 event recording, C5 background subtraction, C9 tracking, P3 compression decision (needs a bandwidth/storage budget discussion).