Repository navigation
Threat Model
New to threat modelling? Identity, secrets and zero trust basics covers the vocabulary (tokens, least privilege, lateral movement), and Enterprise engineering habits explains why a hobby lab writes one of these at all.
Security controls only make sense against named threats. This page lists what is worth protecting, who might go after it, where the trust boundaries are, what stops each threat, and what is left over and accepted on purpose.
It is a lab with a $30 budget and one operator, and the threat model is sized to that. Nation-state targeting of a hobby cloud account is out of scope; the internet's background noise, credential theft and supply-chain mistakes are very much in scope.
| Asset | Why it matters |
|---|---|
| DigitalOcean write token | Can create, destroy or open anything in the account, and run up a bill |
| Twingate API token | Can grant itself access to every resource |
| Doppler service token | Reads every secret above |
| Connector tokens | Can impersonate a connector in the tenant |
| OpenTofu state in Spacelift | Contains connector tokens and the full resource map |
| The droplets | Today: the access path. Later: workloads and a link back to the homelab |
| The operator's identity | The repo is public; account identifiers, home ranges and names stay out |
flowchart TB
subgraph untrusted["Untrusted"]
inet["The internet"]:::ext
forks["Forks and outside<br/>pull requests"]:::ext
end
subgraph vendors["Trusted vendors (third-party control planes)"]
gh["GitHub + Actions"]:::ctrl
sl["Spacelift"]:::ctrl
dp["Doppler"]:::ctrl
tg["Twingate controller and relays"]:::ctrl
doc["DigitalOcean"]:::ctrl
end
subgraph supply["Supply chain"]
acts["Pinned actions"]:::warn
cli["Pinned Doppler CLI (SHA-256)"]:::warn
apt["Twingate apt repo (pinned key)"]:::warn
ubu["Ubuntu mirrors (signed)"]:::warn
end
subgraph mine["Operator controlled"]
laptop["Operator laptop<br/>Twingate client, SSH key"]:::you
drops["Connector droplets"]:::cloud
end
inet -. "B1: no inbound" .-x drops
forks -. "B3: push policy never proposes;<br/>no credentials on proposed runs" .-> sl
laptop -- "B8: IdP sign-in,<br/>per-resource ACL" --> tg
tg --> drops
sl -- "B7: human confirms apply" --> doc
dp --> sl
acts --> gh
cli --> sl
apt --> drops
ubu --> drops
classDef you fill:#132a1f,stroke:#4ade80,color:#dcfce7
classDef ext fill:#3b1d1d,stroke:#f87171,color:#fde2e2
classDef ctrl fill:#1e1b3a,stroke:#a78bfa,color:#ede9fe
classDef cloud fill:#0f2a3d,stroke:#38bdf8,color:#e0f2fe
classDef warn fill:#3a2a0f,stroke:#fbbf24,color:#fef3c7
Boundary numbers are shared with Threat model exercise, step 2, which draws all eight (B1 to B8). This diagram shows only the four that matter most for the threats below, so it skips B2, B4, B5 and B6.
| # | Threat | Controls | Residual |
|---|---|---|---|
| T1 | Internet scanning, brute force, exploit of an exposed service | Zero public inbound rules (the only inbound rule of any kind is tag-scoped connector-to-connector TCP 22 inside the VPC, see T12), attached by tag at creation; connectors are outbound-only; sshd keys only, one user | None from the network side today |
| T2 | Stolen Twingate credentials | Sign-in delegated to the identity provider (enforce MFA there); access limited to one host and port per resource; SSH key still required on the host | An attacker with both the identity and the SSH key gets a shell on a connector |
| T3 | Malicious pull request steals secrets from a plan | A Spacelift push policy that never proposes a run, so no pull request's code runs in Spacelift (tested in CI; needs a paid Spacelift plan and must be attached there); on the free plan, narrow project roots and globs so only pull requests that change a stack reach it; proposed runs get no credentials anyway; branch protection; one writer | Until the policy is attached, script checks only guard honest code. Either way, anyone who can push to main is trusted |
| T4 | Leaked Doppler service token | Read-only, one config, 90-day expiry, write-only in Spacelift, masked in logs | Reads all prd secrets until revoked |
| T5 | Connector token theft from the droplet | File is root-only 0600; one token per connector, revocable | Accepted: user-data is readable by any local process via the metadata endpoint; lockdown is a follow-up |
| T6 | Connector token theft from state | Marked sensitive; state lives only in Spacelift | Accepted: anyone who can read stack state sees it; restrict state access in Spacelift |
| T7 | Compromised GitHub Action | Every action pinned by commit SHA; permissions: {} by default; CI holds no cloud credential |
A compromised pinned commit is still trusted until the pin changes |
| T8 | Tampered Doppler CLI download | Pinned version, pinned SHA-256 per architecture, fail closed | None beyond trusting the pinned release |
| T9 | Tampered Twingate package | Repository key checked against a pinned fingerprint before being trusted; apt verifies signatures | Trusting Twingate's signing key |
| T10 | Vendor compromise (Twingate, Spacelift, Doppler) | Narrow scopes; E2E encryption to connectors; separate SSH key gate | Accepted: inherent to using hosted control planes; see Why ZTNA and not a VPN |
| T11 | Runaway bill | Cost gate allowlist; approved totals in review; billing alerts at $10 and $25 | Bandwidth overage and resources created outside the repo |
| T12 | Lateral movement between droplets | DigitalOcean firewalls filter VPC-internal traffic; only connector-to-connector SSH by tag is allowed | Workload rules in stage 3 must keep the same discipline |
| T13 | Lockout (both connectors down) | Two connectors with staggered reboots and updates | Accepted: no console password by design; recovery is rebuilding the stateless droplets through Spacelift |
| T14 | Doxxing the operator through the public repo | Account identifiers, ranges and names in Doppler; SSH key comment stripped; secret scanning in CI | Human error in a commit message or doc |
Accepting a risk is a decision, so each one is written down where a reviewer will see it:
- Connector tokens in state and in user-data (T5, T6).
- The metadata endpoint is not yet restricted to root (T5).
- Hosted control planes are trusted (T10).
- No break-glass console path (T13).
- The Spacelift script's run-type checks are not a security boundary (T3). The push policy is the boundary for pull requests, and it lives in Spacelift, so the copy in the repository is only as current as the last time someone pasted it in.
- Stage 2 connects the cloud back to the homelab SIEM. That turns a compromised connector from "a cheap droplet" into "a foothold with a path home", so the home-side connector must sit on a VLAN that can reach only what it needs, and cloud-to-home access gets its own Twingate group and service account.
- Stage 3 adds workloads with real data, which brings backups, patch cadence and per-service firewall rules into scope. Two of its pieces widen the model on purpose: Portainer's access to the Docker socket is root on the host, so whoever can sign in to Portainer owns the droplet (mitigated by reaching it only through Twingate, as the admins group); and the self-hosted Matrix bridge is the first thing that must accept an inbound connection from outside the VPC, from the etke.cc homeserver, which cannot run a Twingate client. How to admit it (one narrow public rule, or a private path from etke.cc) is an open decision; until it is made, no port is opened. The daemons hold API tokens for the author's social accounts, so a compromised host can post as the author until those tokens are rotated in Doppler. Moving their state into a Spaces bucket adds one key per daemon, each scoped to its own prefix, and a bucket an attacker could read or wipe (versioning plus a copy off the account is the answer to the wipe). Running some jobs as DigitalOcean Functions puts spend outside the cost gate, which widens T11's residual by whatever the usage page shows.
-
Anyone else getting write access to the repository makes T3 real for
mainitself, and required reviews become mandatory first. The push policy already keeps pull requests out of Spacelift once attached.
Next: Lessons learned, step 20 of the core path on the Reading order page. To build a threat model yourself, the security track starts at the Threat model exercise.
Source for these pages lives in docs/wiki/ and is published by the Wiki workflow on every merge to main. Corrections are welcome as pull requests; edits made in the wiki editor are overwritten on the next publish. MIT licensed, see LICENSE in the repository.
Core path: read in this order
- Start here if you are new
- What is the cloud
- Networking basics
- Identity, secrets, zero trust
- Infrastructure as code
- Git and pull requests
- Containers and Docker (optional)
- How money works
- Enterprise habits
- Architecture overview
- Why ZTNA and not a VPN
- Twingate
- DigitalOcean network and firewalls
- Droplet hardening
- OpenTofu, stacks and modules
- Spacelift
- Doppler and secrets
- CI, tests and the cost gate
- Threat model
- Lessons learned
- Roadmap
Track: build it yourself
Track: understand the design
The rest of this track is on the core path; see Reading order.
Track: security and threat modelling
- Threat model exercise
- Threat Dragon exercise
- Threat frameworks: STRIDE, ATT&CK, ATLAS
- Troubleshooting index
The Threat model is core step 19.
Track: detection and monitoring
- A small example: the enterprise landscape
- Detection, Wazuh and the landscape
- Cloud telemetry: what and why (unverified draft)
- Multiple sites and identity (unverified draft)
- Telemetry crosses the trust boundary (unverified draft)
- Reading a dashboard as an operator (unverified draft)
- Build your own mini SOC (unverified draft)