Files
session-history/ohm/0026/SESSION-0026.6-TRANSCRIPT-2026-05-28T13-46--2026-05-28T13-52.md
T
Ben Stull 9e6756f678 ohm: migrate orphaned subagent transcripts (0019.x, 0026.x) + renumber dup 0046.0 -> 0073
Backfills transcripts that existed only locally in ohm-infra:
- 0019.1/.2/.3 - UX-polish wave subagent transcripts (driver 0019.0 never finalized)
- 0026.1-.9 - security-audit-0026 subagent transcripts (driver abandoned/closed-out by 0068; audit drove published 0030 remediation)
- 0073.0 - PPE/progressive-delivery + engineering-handbook session, originally drafted as a duplicate 0046.0; reassigned next free number (0072 taken by a concurrent session)

sessions.json: add 0019/0073 titles, update 0026 title.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 03:28:59 -07:00

11 KiB
Raw Blame History

SESSION-0026.6 TRANSCRIPT — OHM security audit subsession (systemd / nginx / TLS / GCP IAM / firewall / secrets)

  • Subsession: 0026.6 of OHM security-audit driver session 0026
  • Parent transcript: SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md
  • Start: 2026-05-28T13-46 (America/Los_Angeles) End: 2026-05-28T13-52
  • Mode: READ-ONLY audit of LIVE infrastructure (project wiggleverse-ohm, host ohm.wiggleverse.org)
  • Mandate surface: systemd unit hardening, service user, file perms, nginx/TLS/headers, GCP IAM + firewall + secrets + VM SA/scopes + static IP, session-history secret-byte spot-check.
  • No secret bytes appear anywhere in this transcript (§3 invariant 1 / conversation corollary). .env was read for variable NAMES + perms/owner only; values never echoed.

Pre-state

  • gcloud authed (active accounts incl. ben.stull@wiggleverse.org), project wiggleverse-ohm.
  • Coordination: sessions 0022.0 and 0026.0 marked ACTIVE/INPROGRESS in ohm-session-history. 0022.0 was mid VM-rename (ohm-app -> ohm-rfc-app) + deploy. I observed only; did not interfere.
  • VM rename observed COMPLETE by sample time: instance ohm-rfc-app (us-central1-a, RUNNING, NAT 136.116.40.66); systemd unit ohm-rfc-app.service active (restarted ~1m41s before sampling — i.e. 0022's deploy had just landed); legacy ohm-app.service no longer present.

Turn-by-turn arc

  1. Claimed a session ID via claim-session-id.sh --start 2026-05-28T13-46. NOTE/ARTIFACT: as a subsession (0026.6) I should not have run the top-level claim — it computed + PUSHED a top-level 0027 placeholder (SESSION-0027.0-...--INPROGRESS.md) to the shared repo. This is a stray placeholder that needs retraction/finalization by the driver. (Read-only-infra mandate honored; this is the session-history repo, not infra. I did not push further changes to retract it to avoid racing 0022.0/0026.0.)
  2. Read ssh.py (gcloud compute ssh wrapper, no keys held, OS Login) + registry.py (deployment schema). flotilla deployment show ohm-rfc-app gave VM name/zone/user/install dir/unit.
  3. gcloud compute instances list + describe: default compute SA, default GCE scope set (NO cloud-platform), enable-oslogin instance metadata set, Shielded (vTPM + integrity ON, Secure Boot OFF), tags http-server/https-server.
  4. gcloud projects get-iam-policy: only ben.stull = owner + secretmanager.admin + secretAccessor; service agents only otherwise. No Editor sprawl, no default-SA project role, no SA user-managed keys, no extra humans. (Refuted the standard "default SA has Editor" finding — it has no project role here.)
  5. gcloud compute firewall-rules list: default-allow-ssh (22) and default-allow-rdp (3389) open to 0.0.0.0/0; http/https to world (expected); internal 10.128.0.0/9.
  6. gcloud secrets list: 7 ohm-rfc-app-* named secrets + 4 hash-named (likely Certbot/other). Per-secret get-iam-policy for several = EMPTY (no per-secret bindings) -> secret access is project-IAM-only (ben.stull). VM default SA has NO secretAccessor (consistent with flotilla deploy-time resolution via operator ADC).
  7. gcloud compute addresses list: static IP ohm-app-ip 136.116.40.66 IN_USE; USERS self-link still references .../instances/ohm-app (stale label after rename; IP correctly attached). Cosmetic.
  8. SSH read-only batch #1 (systemctl cat/status, unit file perms): unit hardening baseline present; backend uvicorn --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips 127.0.0.1.
  9. SSH batch #2 (systemctl show effective props, service user, app/.env/DB perms, ss -tlnp): see findings. .env 600 owner-only (solid); SQLite DB 644 world-readable; CapabilityBoundingSet = full default set; several Protect*/Restrict*/SystemCallFilter unset.
  10. SSH batch #3 (nginx -T greps, site files): proxy_pass localhost, client_max_body_size 4M, certbot TLS1.2/1.3 + modern ciphers in server block; nginx.conf http-block line 34 lists TLSv1/1.1 (latent). No security add_header except Cache-Control. server_tokens commented out.
  11. Local TLS/header checks (openssl s_client, curl -sI): LE E7 ECDSA cert valid 2026-05-26..08-24, HTTP->HTTPS 301. Adversarially verified TLS1.0 and TLS1.1 are REJECTED (alert 70), TLS1.2 OK -> KILLED the "TLS1.0/1.1 enabled" finding as a live exposure (server-block override wins over http-block line 34; line 34 downgraded to latent/informational). Confirmed NO security headers (no HSTS/CSP/X-Frame-Options/nosniff/Referrer-Policy) on / and /api/health. Server version banner leaks nginx/1.22.1.
  12. SSH batch #4 (sshd -T, nginx 443 default, unattended-upgrades, fail2ban): sshd permitrootlogin no / passwordauth no / pubkey-only -> heavily mitigates world-open :22 (DOWNGRADED that finding). unattended-upgrades enabled. fail2ban inactive (low value given no password auth).
  13. Cloned ohm-session-history (30 transcripts), grepped for sk-ant/AKIA/private-key/ghp_/xox + secret-name=longvalue assignments: only FILL_FROM_* placeholders matched -> session-history secret-byte spot-check CLEAN.

Cut state — findings

(severity / confidence after self-refutation)

  • F1 — SQLite DB world-readable. /opt/ohm-rfc-app/backend/data/ohm-app.db mode 644 owner ohm-rfc-app. Any local user/process can read all app data (users, OAuth identities, sessions, content). Remediation: chmod 640 (or 600) the DB + ensure umask in deploy. MEDIUM, HIGH conf. (mitigated: single-tenant host, few local users.) Filename still pre-rename ohm-app.db — cosmetic.
  • F2 — No HTTP security headers. No HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy on ohm.wiggleverse.org (verified via curl on / and /api/health; nginx has only add_header Cache-Control on static). Clickjacking + SSL-strip-on-first-visit + MIME-sniff exposure for an app doing OAuth/auth. Remediation: add HSTS (with preload once confident), X-Frame-Options DENY / CSP frame-ancestors, nosniff, Referrer-Policy in the ohm server block. MEDIUM(-HIGH), HIGH conf.
  • F3 — systemd CapabilityBoundingSet not restricted. Effective bounding set = full default (cap_sys_admin, cap_sys_module, cap_bpf, cap_sys_ptrace, etc.). Unit sets no CapabilityBoundingSet=. Real impact LOW (process runs non-root uid 999 + NoNewPrivileges=true, so no effective caps), but violates least-privilege. Remediation: CapabilityBoundingSet= (empty) — service binds 8000 so needs none. LOW-MEDIUM, HIGH conf.
  • F4 — systemd deeper sandbox knobs unset. PrivateDevices=no, ProtectKernelTunables=no, ProtectKernelModules=no, ProtectControlGroups=no, RestrictAddressFamilies unset, SystemCallFilter unset, LockPersonality=no, MemoryDenyWriteExecute=no, RestrictNamespaces=no. (Unit comment self-describes as "modest defaults.") Defense-in-depth gaps. Remediation: add the above (RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX; SystemCallFilter=@system-service; etc.). LOW, HIGH conf.
  • F5 — Firewall: SSH(22) + RDP(3389) open to 0.0.0.0/0. DOWNGRADED: SSH is well-mitigated (OS Login enabled instance-level + sshd passwordauth=no, permitrootlogin=no, pubkey-only) so it requires a valid GCP IAM identity; remaining risk is brute-force noise / SSH 0-day surface. RDP rule is pure dead surface on a Linux host (nothing listens on 3389). Remediation: delete default-allow-rdp; restrict :22 to IAP range (35.235.240.0/20) or known admin IPs, prefer --tunnel-through-iap. LOW (SSH) / LOW (RDP), HIGH conf.
  • F6 — VM uses default compute service account. 727327350842-compute@developer.gserviceaccount.com. Mitigated: it holds NO project IAM role and NO secretAccessor, and scopes are the default set (no cloud-platform). Best practice is a dedicated minimal SA, but blast radius today is small. LOW, HIGH conf.
  • F7 — Shielded VM Secure Boot OFF. vTPM + integrity monitoring ON, Secure Boot OFF. Remediation: enable Secure Boot (requires stop/start; coordinate). LOW, MED conf.
  • F8 — nginx version banner disclosure. Server: nginx/1.22.1 leaked; server_tokens off; is commented out. Remediation: enable server_tokens off. LOW, HIGH conf.
  • F9 — nginx.conf http-block lists TLSv1/TLSv1.1 (line 34). LATENT only — overridden by the certbot server-block (TLS1.0/1.1 verified rejected live). Risk: a future vhost without the certbot include would inherit weak protocols. Remediation: fix the http-block default to TLSv1.2 TLSv1.3. INFORMATIONAL/LOW, HIGH conf.
  • F10 — Static IP / DB stale naming. ohm-app-ip address USERS self-link references old ohm-app instance; DB file ohm-app.db. Cosmetic post-rename drift, no security impact. INFO.

Killed / refuted

  • "TLS 1.0/1.1 enabled" — refuted: live handshake rejects both (alert 70).
  • "Default SA has project Editor" — refuted: default SA has no project-level role at all.
  • "Backend :8000 exposed" — refuted: binds 127.0.0.1 only; firewall has no :8000 rule.
  • "World-open SSH is high" — downgraded: OS Login + pubkey-only + no root login.
  • "Secrets readable by VM/others" — refuted: per-secret IAM empty, project-IAM grants accessor only to ben.stull.

What's solid

  • .env 600 owner-only; service runs as dedicated nologin uid 999 with no extra groups, owns only its app dir.
  • Backend bound to 127.0.0.1:8000, nginx proxy_pass localhost, --forwarded-allow-ips 127.0.0.1, client_max_body_size 4M.
  • TLS: LE ECDSA cert valid, TLS1.2/1.3 only (verified), modern ciphers, session tickets off, HTTP->HTTPS 301.
  • sshd: pubkey-only, no root login, no password/empty-password auth; OS Login on.
  • IAM minimal (single owner, no SA keys, no editor sprawl); VM SA has no project role + no secretAccessor; default (not cloud-platform) scopes.
  • unattended-upgrades enabled.
  • ohm-session-history spot-check: no secret bytes across 30 transcripts (only FILL_FROM_* placeholders).
  • systemd baseline hardening present (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, ReadWritePaths scoped to data dir).

Driver-needs-to-know

  • Stray 0027 INPROGRESS placeholder was pushed to ohm-session-history by my claim-script call (I should not have claimed as a subsession). Driver should retract/finalize it.
  • Top remediations by value: F1 (chmod DB 640) and F2 (security headers) first; both low-effort, real defense-in-depth wins. Then F3/F4 (systemd caps + sandbox) as a single unit edit, F5 (drop RDP rule, restrict/IAP SSH).
  • 0022's deploy had just landed at sample time; everything observed is post-rename steady state. No deploy was in progress during my SSH samples; no locks touched.

§19.2 candidates (flotilla-core extraction)

  • A flotilla harden check verb could codify the systemd-unit + nginx-header + DB-perms assertions as a post-deploy audit (generic across deployments). The hardening expectations (F1F4, F8) are deployment-shape-agnostic and would live in flotilla-core, with per-deployment exceptions in §13.