Threat model: framework, CLI and ecosystem
Status: Accepted (2026-09-14) · Amended by: ADR-0036 (audit metadata controls)
Context#
apistock generates authentication, handles secrets, installs third-party recipes, runs on developer machines and publishes packages under a vanity import path. The threat model must cover the framework itself, not only apps built with it. This is an architecture-level model; specialist review and penetration testing are still required before 1.0.
Assets#
Developer machines (SSH keys, cloud and GitHub credentials), application source code, generated authentication code, end-user data in generated apps, the apistock.dev domain and DNS, the GitHub organisation and release pipeline, the module index.
Trust boundaries#
CLI ↔ Go module proxy; recipe → owned code; apistock.dev → go-import resolution; localhost dev services; CI workflows; app ↔ identity providers; tenant ↔ tenant; public API ↔ ops API.
Threats and controls#
| # | Threat | Control | Milestone |
|---|---|---|---|
| 1 | apistock.dev domain or DNS hijack redirects imports for new versions |
Registrar lock, DNSSEC, hardware-key 2FA on registrar and DNS accounts, CAA records, scheduled CI check of go-import content |
Before any public release |
| 2 | Template injection through user input | Names validated with go/token.IsIdentifier; field types from an allowlist; generated code parsed before writing |
v0.1 |
| 3 | Recipe writes outside the project | All writes through os.Root; path validation |
v0.1 |
| 4 | Recipe executes code at install time | Declarative operations only; no exec, shell or download operation | v0.1 |
| 5 | Malicious or compromised recipe injects a backdoor | Diff preview, clean git tree, risky new imports highlighted, trust levels for community modules | v0.1 / after 1.0 |
| 6 | Tampered module download | Go module proxy and checksum database; lock file records versions | v0.1 |
| 27 | Tampered or wrong earlier release used as an upgrade's merge base, smuggling code into "unchanged" files | Earlier templates come only from a git tag or commit of an apistock checkout or from the module proxy with checksum verification on; rendered as text, never compiled or run; every rebuilt file checked against its hash in apistock.lock; files that don't match are compared 2-way and can only conflict (ADR-0050) |
v0.5 |
| 28 | Upgrade silently overwrites or drops developer edits | Hash-proven base; 3-way merges with conflict markers; migrations never merged; clean git tree required; work happens on branch aps-upgrade/<version> and conflicts are never committed |
v0.5 |
| 29 | Retention used to erase the audit trail: an operator shortens audit.retention to delete evidence |
Bounded to at least 30 days; changing it needs a reason and records a setting change in history and the audit log; every deletion records a retention.purged event with the row count and cutoff (ADR-0051) |
v0.5 |
| 30 | Maintenance mode abused or locking staff out | Changing maintenance.enabled needs ops.settings.write, 2FA and a reason, and is audited; health checks, sign-in and /ops stay open so it can be turned off; go run ./cmd/api maintenance off works without /ops |
v0.5 |
| 31 | Operational endpoints or tools leaking secrets or infrastructure detail | GET /ops/system reports no connection strings, dependency host names, environment variables or settings values, and fixed error descriptions instead of driver messages; aps doctor never prints .env values |
v0.5 |
| 7 | Compromised CLI release | Releases built only in CI with OIDC; Sigstore signatures and provenance; protected tags; two-person approval for security-sensitive code | v0.1 |
| 8 | Maintainer account takeover | Required 2FA in the GitHub organisation; CODEOWNERS; branch protection |
Now |
| 9 | GitHub token theft through the CLI | CLI never stores tokens; delegates to gh; generated workflows use least-privilege permissions, actions pinned by SHA, no pull_request_target |
v0.1 |
| 10 | Secrets committed | .env* git-ignored; gitleaks in generated CI; .env.example placeholders only |
v0.1 |
| 11 | Localhost services abused from a browser (CSRF, DNS rebinding) | Services bound to 127.0.0.1; custom dev console (v1.1) checks Host header and uses a session token | v0.2 / v1.1 |
| 12 | Account enumeration, credential stuffing | Uniform responses, rate limits, breached-password hook | v0.2 |
| 13 | Session theft | Hashed tokens, __Host- cookies, rotation, idle and absolute expiry, revocation |
v0.2 |
| 14 | OAuth attacks (CSRF, code injection, token substitution) | PKCE, state, nonce, issuer and audience checks, verified-email-only linking | v0.3 |
| 15 | TOTP secret disclosure or code replay | Encrypted secrets with key rotation, single-use codes, rate limits | v0.3 |
| 16 | Passkey phishing or origin confusion | Relying-party ID and origin allowlist validation | v0.3 |
| 17 | Cross-tenant data access | Four isolation layers (ADR-0023) | v0.4 |
| 18 | Privilege escalation to ops | Platform roles separate from org roles; /ops/* requires 2FA; optional internal port |
v0.5 |
| 19 | Sensitive data in logs, audit or ops responses | Log IDs not emails; config.Secret redaction; audit metadata allowlist |
v0.2 |
| 20 | SSRF through outgoing requests (webhooks, avatar fetch) | Safe HTTP client blocking private, link-local and metadata addresses | When the feature ships |
| 21 | Vulnerable dependencies | govulncheck in repository CI, generated CI, and module checks |
v0.1 |
| 22 | Open-core dependency changes (for example River Pro, Atlas Pro) | Dependencies behind modules; licences recorded; swap path documented | Ongoing |
| 23 | Runtime settings abused to weaken security (for example very long session or code expiry) or to leak secrets | No secret type in settings; bounds declared per setting plus hard limits inside library modules; ops.settings.write permission (2FA from v0.3); reason, history and audit event per change |
v0.2 |
| 25 | Invitation takeover: a forwarded, leaked or guessed invitation link used by someone else, or a member inviting with a higher role | Token of 256 bits stored as a hash, single use, expiring; accepting needs a signed-in account whose verified email is the invited one; nobody invites above their own role | v0.4 |
| 26 | Organisation enumeration and probing through IDs or error codes | 128-bit random organisation IDs; the same 404 org_not_found for missing, deleted and other organisations; resources of other organisations return their own 404 |
v0.4 |
| 24 | Job controls abused: destructive jobs run on demand, schedules set to overload the database, security jobs (session purge, retention) disabled | ops.jobs.write and ops.jobs.run permissions (2FA from v0.3); reason required to disable or reschedule; minimum 1-minute interval and bounded timeout and attempts; audit event and history per change; job arguments never returned by ops APIs |
v0.2 |
Residual risk#
Owner: project maintainer. Each accepted risk is recorded here with a review date. An external security review of authentication, sessions, tenancy and the generator is required before 1.0.
Consequences#
SECURITY.mdprocess in place before the repository is public.- Every milestone's definition of done includes its rows from this table.
v0.1 status (2026-09-14)#
| # | Status |
|---|---|
| 1 Domain/DNS hijack | Open, maintainer action before any public release: registrar lock, DNSSEC, hardware-key 2FA, CAA records, go-import monitoring |
| 2 Template injection | Done: name and module path validation, templates rendered with fixed data, Go output parsed by go/format |
| 3 Writes outside project | Done: os.Root for app creation and template generation |
| 4 Code execution at install | Done: aps new runs only go mod tidy and git init |
| 5 Malicious recipes | Not yet applicable: no third-party recipes; diff preview arrives with aps add |
| 6 Tampered downloads | Done: Go module proxy and checksum database; lock file records hashes |
| 7 Compromised CLI release | Ready: release workflow with keyless Sigstore signing and build provenance; takes effect on the first cli/v* tag |
| 8 Maintainer takeover | Open, maintainer action: require 2FA in the apistockhq organisation, branch protection, CODEOWNERS |
| 9 GitHub token theft | Done: CLI stores no tokens; workflows use read-only permissions, SHA-pinned actions, no persisted credentials, no pull_request_target |
| 10 Secrets committed | Done: .env* ignored in generated apps; gitleaks in CI |
| 19 Sensitive data in logs | Done for v0.1 scope: config.Secret redaction, access logs without query strings, validation errors never echo values |
| 21 Vulnerable dependencies | Done: govulncheck in CI for every module |
v0.2 status (2026-09-15)#
| # | Status |
|---|---|
| 11 Localhost services | Done for PostgreSQL, Mailpit and Grafana: compose.yaml binds them to 127.0.0.1 in the repository and both presets |
| 12 Account enumeration | Done (ADR-0038): identical responses for register, resend and forgot-password; invalid_credentials for unknown addresses and wrong passwords after the same hashing work; 10 login attempts per address per 15 minutes; 5 attempts per code; 60 auth requests per IP per minute; breached-password hook (PasswordChecker) |
| 13 Session theft | Done (ADR-0038): 256-bit tokens stored as SHA-256; __Host- Secure HttpOnly SameSite=Lax cookies with cross-origin protection; idle (14 d) and absolute (90 d) expiry clamped to hard limits; revocation, logout-all, sessions ended on password change, reset and deletion |
| 18 Privilege escalation to ops | Partial: /ops/* requires a session whose platform roles grant each operation's permission (deny by default, roles read on every request, grants audited); OPS_TOKEN removed (ADR-0038). Required 2FA for ops roles and the optional internal port remain |
| 19 Sensitive data in logs | Done for new modules: database URLs never appear in errors, query spans record SQL text but not arguments, validation errors never echo values, job arguments never appear in ops responses, ops token never printed. Audit metadata (modules/auditpg, ADR-0036): values under sensitive keys (password, secret, token, otp, recovery_code, …) redacted at any depth, size bounded, text sanitised; audit events append-only through a trigger. A per-action metadata allowlist was considered and replaced by recorder-side redaction. Email (ADR-0037): the Resend API key and SMTP password are config.Secrets from the environment, never flags, runtime settings or error text; aps add mail saves typed secrets only to a git-ignored .env (mode 0600) and never prints them; SMTP credentials are sent only over TLS or to a local server; test email audit events omit the recipient; development email goes to Mailpit, never real people |
| 22 Open-core dependencies | River (MPL-2.0) and goose (MIT) used without Pro features; robfig/cron/v3 (MIT) swap path is River's PeriodicSchedule |
| 23 Runtime settings abuse | Done: no secret type, declared bounds, reasons, versions, history and audit events (modules/settings) |
| 24 Job controls abuse | Done: permissions per operation, reasons to disable or reschedule, 1-minute minimum interval, bounded timeout and attempts, history and audit events, arguments hidden (modules/jobs) |
Reviewed against the code for v0.2's definition of done (rows 12, 13, 19, 23 and 24), 2026-09-15: each mitigation above is in place and tested. Notes from the review:
- Row 12: the breached-password hook is
authusecase.Config.PasswordChecker. No checker is configured by default; an app chooses one (a local list, or a k-anonymity API) ininternal/app/module_auth.go. - Row 19: the auth use cases' log line for an email that couldn't be queued named its attribute
email, which reads like an address; the value was always the email's kind (verification_code), never the address. It is nowemail_kind. - Row 19: seed data (ADR-0042) prints the administrator's random password once to the developer's terminal, never to logs, files or the audit log, and refuses to run in production.
- Row 24: disabling requires a reason, a disabled job can't be run now (409) and leaves the schedule, and each change is in the job's history and audit log (
TestJobsThroughOps).
v0.3 status (in progress, 2026-09-15)#
| # | Status |
|---|---|
| 15 TOTP secret disclosure or code replay | Done (ADR-0043): secrets encrypted with AES-256-GCM under AUTH_ENCRYPTION_KEYS, with key IDs, bound to the user ID and re-encrypted by rotate-auth-keys; a code is accepted only for a later time step than the last one used, checked and recorded in one statement (safe across instances); sign-in challenges allow 5 attempts in 5 minutes and count toward the per-address login limit; recovery codes are hashed and single-use; secrets, codes and recovery codes never appear in logs or audit metadata |
| 18 Privilege escalation to ops | Required 2FA done (ADR-0043): platform_admin and ops_viewer grant their permissions only to sessions verified with a second factor, in every environment; the rule is code, not a runtime setting; other roles' permissions are unaffected. The optional internal port remains (v0.5) |
| 16 Passkey phishing or origin confusion | Done (ADR-0044): the relying party ID and allowed origins come from the environment (not runtime settings), each origin must be https (http only for localhost in development) on the RP ID or a subdomain; every response's origin, RP ID hash, challenge, signature and user-verification flag are checked by modules/auth/passkey; ceremonies are single use and bound to their purpose, user and sign-in challenge; counters that don't increase are refused and audited; Android apps are allowed only by configured certificate fingerprints |
| 14 OAuth attacks | Done (ADR-0046): web sign-ins keep a single-use, 10-minute state on the server bound to an HttpOnly __Host-oauth cookie (login CSRF), a nonce and a PKCE S256 verifier (Google); ID tokens are checked by modules/auth/social for signature against the provider's keys, issuer, an audience among the configured client IDs, expiry, an age under 10 minutes and the nonce; native apps must use a server-issued single-use nonce (hashed for Apple); return_to is limited to the API's and APP_CORS_ORIGINS origins and results go in the URL fragment; linking requires a provider-verified email and removes the password and sessions of an account that never verified its address (pre-account hijacking); the second factor still applies; Apple tokens are revoked on deletion and Apple's signed notifications are verified |
v0.4 status (done, 2026-09-15)#
| # | Status |
|---|---|
| 17 Cross-tenant data access | Done in examples/full-multi and multi-tenant generated apps (ADR-0048): every org use case starts with orgs.RequireMember, which reads the membership on each call; org-scoped repository methods take the organisation ID in every query; org-scoped tables have org_id NOT NULL with a cascading foreign key and UNIQUE (org_id, id) for composite references; cross-organisation denial tests are generated for every org-scoped resource (aps gen resource --scope org); a drift test keeps full-multi identical to full-single outside the files organisations change. Row-level security remains the v1.1 option |
| 25 Invitation takeover | Done: 256-bit tokens stored as SHA-256 in the URL fragment of the link, single use, replaced on resend, revocable, expiring (orgs.invitation_ttl, 1 to 30 days); accepting requires a signed-in account whose verified email equals the invited address; nobody invites, promotes or removes above their own role and only owners manage owners; 20 invitations per organisation per hour; every step is audited without email addresses in metadata |
| 26 Organisation enumeration | Done: organisation IDs carry 128 random bits; non-members, deleted and missing organisations all get 404 org_not_found, checked before the resource is read; a resource ID from another organisation returns that resource's 404 |
| 18 Privilege escalation to ops | Organisations keep their own role catalog: RequireMember replaces the actor's platform permissions with the member's org-role permissions for org operations, org roles never grant /ops permissions, and platform roles grant no access to organisations' data |
v0.5 status (done, 2026-09-15)#
| # | Status |
|---|---|
| 27 Tampered earlier release | Done in aps upgrade (ADR-0050): git archive of a validated tag or commit (never an option or range) extracts only regular files under the templates through os.Root; go mod download is refused when checksum verification is off for apistock.dev/cli; every rebuilt file is checked against apistock.lock, a v1 lock must match completely, and a v2 lock with no matching file stops the upgrade. Tests: TestExtractTemplatesStaysInside, TestReleaseFromCheckoutRejectsRefs, TestChecksumPolicy, TestUpgradeFromV1Lock |
| 28 Upgrade drops edits | Done: cli/internal/merge keeps every line the developer added (TestPlan); unproven bases can't take the template silently; dirty trees are refused, conflicts exit 1 uncommitted on the branch; an app edited with aps at v0.4.0 upgrades with every edit kept (TestUpgradeFromV040) |
17 Cross-tenant data access after aps add orgs |
Done: converted data lands only in its owner's personal workspace, which only that owner belongs to; converted organisation IDs carry 128 random bits like orgs.NewID's; the converted schema has the same org_id NOT NULL, cascading foreign key and UNIQUE (org_id, id) as a new multi-tenant app, and the converted app passes the multi-tenant suite including the cross-organisation denial tests (TestAddOrgsConvertsADatabase). Resources the developer generated stay user-scoped, as they were |
| 18 Privilege escalation to ops | Every ops use case calls authorize before touching a dependency, checked for every exported method without a session, without permissions and needing 2FA (TestEveryOperationAuthorizesFirst); every /ops operation declares bearer security and 401 and 403 (TestOpsOperationsDeclareSecurity) |
| 29 Erasing the audit trail | Done: audit.retention bounded to 30 days–10 years with a required reason; retention.purged recorded per deletion (TestWorkerDeletesInBatchesAndRecords) |
| 30 Maintenance mode abuse or lockout | Done: reason required, open routes and break-glass command (TestMaintenanceMode, TestMaintenanceCommand) |
| 31 Leaks from ops endpoints and tools | Done: TestOpsSystem checks the report holds no connection string; TestDoctorFindsProblems checks aps doctor never prints a .env secret |