Threat model ​
Status: updated 2026-10-14 (hosted edition operations and billing) and 2026-10-13 (Bitbucket, Gitea, Forgejo and release hardening); earlier updates added GitLab, Azure DevOps and work trackers and consolidated the baseline. Reviewed with every change that adds an entry point, a write path or an agent capability. Exit criterion for such a change: every threat has a mitigation mapped to a test or control in the column "Test / control". An independent penetration test has not taken place yet.
Feature-specific analyses extend this document: triage-and-replies.md (TR, RP, CH, DT1–DT2) and fix-agent-and-sandbox.md (SB, FC, SC, FX, AC, DT5).
1. System and trust zones ​
| Zone | Contents | Trust |
|---|---|---|
| Z0 untrusted content | Webhook payloads, issue/PR text, diffs, repository files, LLM output, identity-provider responses | Never trusted; never instructions |
| Z1 agent runtime | LLM calls with read-only tools | No write tokens |
| Z2 publisher | Validated, typed outputs; installation tokens | Only path to external writes |
| Z3 sandbox | Untrusted repository code, scanners, fix-agent tests | No tokens, no secrets, egress denied by default; separate runner service with signed jobs; Docker with runsc, Firecracker, or Kubernetes pods with the gVisor RuntimeClass |
| Control plane | API, worker, Postgres, Valkey, identity providers (OIDC), observability stack | Authenticated, least privilege |
Entry points: POST /webhooks/github, per-connection webhooks POST /webhooks/{gitlab,azure-devops,bitbucket,bitbucket-dc,gitea,forgejo,jira,linear,azure-boards}/:connectionId, /setup/github/*, /auth/* (GitHub and OIDC), /api/*, CLI bins on the server (keys, dsar, auth oidc test, break-glass), GitHub, GitLab, Azure DevOps, Bitbucket, Gitea, Forgejo, Jira, Linear and Atlassian/Entra OAuth responses, OIDC discovery/JWKS/token/userinfo responses, the product-telemetry endpoint (outbound, opt-in), in the hosted edition only: the public sign-up, email verification, status and sandbox-progress routes, POST /webhooks/mollie, Mollie and VIES responses, the SMTP relay and the status webhook (outbound), metrics and traces (OTLP), the dev stack ports (bound to 127.0.0.1).
2. Assets ​
| Asset | Where | Protection | Test / control |
|---|---|---|---|
| GitHub App private keys, client/webhook secrets | provider_apps.encrypted_credentials | AES-256-GCM envelope, context-bound (provider_apps:<id>), key rotation (ADR-0008) | crypto.test.ts, encrypted-columns.test.ts, keys.int.test.ts |
| OIDC client secrets | identity_providers.encrypted_client_secret | Encrypted with context identity_providers:<id>; returned only as a 4-character hint | encrypted-columns.test.ts; oidc-identity-providers.int.test.ts |
| GitHub user tokens | auth_sessions.encrypted_provider_token | Encrypted, ≤ 8 h, deleted on logout/expiry; OIDC sessions store no provider token | auth.int.test.ts |
| Installation tokens | Valkey sealed cache | Encrypted, expire ~5 min before GitHub expiry | maintenance.int.test.ts (stale entries) |
| GitLab, Azure DevOps, Bitbucket, Gitea and Forgejo tokens, Entra client secrets, connection webhook secrets | provider_connections.encrypted_credentials | Encrypted with context provider_connections:<id>, key rotation via encrypted-columns.ts; 4-character hint; webhook secret shown once | provider-connections.int.test.ts, encrypted-columns.test.ts |
| Tracker tokens (Jira API tokens and OAuth tokens, PATs, Linear keys, Entra secrets) and tracker webhook secrets | tracker_connections.encrypted_credentials, encrypted_webhook_secret | Encrypted with context tracker_connections:<id>, key rotation covered; 4-character hint; secret shown once | apps/api/src/routes/admin/trackers.int.test.ts ("keeps credentials encrypted"), encrypted-columns.test.ts |
| LLM provider keys | llm_credentials.encrypted_secret | Encrypted, masked in responses, never logged; hosted tenants use version 3 blobs bound to the tenant (HS13) | llm-credentials.int.test.ts, crypto.test.ts ("tenant keys") |
| Hosted SMTP password and status webhook secret | saas_settings.encrypted_secret | Encrypted, registered in encryptedColumns, so quavon keys rotate covers them | saas.int.test.ts ("stores SMTP and status webhook secrets encrypted …"), encrypted-columns.test.ts |
| Mollie API key | Environment / QUAVON_MOLLIE_API_KEY_FILE | Never in the database, never logged; live_ keys refused outside production | hosted/config/src/config.test.ts |
| Tenant data (repos, PRs, issues, people, findings, analytics) | Postgres tenant tables | RLS ENABLE + FORCE, withTenant | rls.int.test.ts, isolation suites (13b-A) |
| Repository source code in the code index | code_chunks.content and code_chunks.search (plaintext), code_symbols, code_index_files paths | RLS ENABLE + FORCE, withTenant; deleted when the index mode is set to off, the repository is removed or the tenant is deleted; at rest only volume or disk encryption (CX1) | rls.int.test.ts, code-index.int.test.ts (worker: tenancy, purge) |
| Session and CSRF tokens | Browser cookies; only SHA-256 hashes stored | HttpOnly/Secure/SameSite=Lax, double-submit CSRF | auth.int.test.ts |
| Audit log | audit_log | Append-only trigger, hash chain per tenant and instance | audit.int.test.ts |
QUAVON_ENCRYPTION_KEY | Environment / *_FILE | Never in the database; rotation procedure | keys.int.test.ts, ADR-0008 |
| Release artefacts (images, chart, SBOM) | CI, registry | Pinned base digests, SBOM, image scan; signing planned | .github/workflows/ci.yml |
3. STRIDE ​
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| S1 | Forged webhooks | HMAC-SHA256 over raw body with the app's secret; unknown app → 401; host header only selects the secret | webhooks.int.test.ts |
| S2 | Session hijacking / fixation | Opaque random tokens, hashes at rest, rotation at login, HttpOnly/Secure/SameSite=Lax, 8 h cap | auth.int.test.ts, process E2E |
| S3 | Login CSRF / code injection | state bound to a pre-login cookie, PKCE S256, single-use Valkey entry | auth.int.test.ts |
| S4 | Setup takeover | One-time hashed setup tokens with purpose setup, CSRF-bound manifest state, single setup claim | setup.int.test.ts |
| S5 | Break-glass abuse | Server-side CLI only, single use, 15 min link, 1 h session, instance administration only, audited | contract.test.ts, process E2E |
| S6 | Setup takeover via the existing-app registration | POST /setup/github/register requires an unused setup token (checked before any outbound call), a matching Origin, a bare hostname (no IPs, ports or userinfo), refuses when a review app exists, and in the enterprise edition rejects private hosts; token state endpoint does not distinguish unknown, used and expired tokens; concurrent registrations serialised (13b-C) | setup.int.test.ts |
| T1 | Tampering with audit history | Append-only trigger for all roles, hash chain, /audit/verify | audit.int.test.ts |
| T2 | Tampering with ciphertexts | AES-GCM authentication with context binding | crypto.test.ts |
| T3 | PR changes the reviewer's rules | Rule files and .quavon/config.yml are read only from the PR's base commit; rules can only add expectations, never suppress findings or change format, severity or thresholds; file-rule severity capped at high; config disable cannot remove UI rules; rule text never reaches the summary model; YAML loaded without aliases. Residual risk: PRs targeting a base branch the contributor controls | rules-context.test.ts, rules unit tests, review-publish.int.test.ts |
| T4 | Script injection in the dashboard via model output or repository text | SPA renders previews and findings as text or allowlisted React elements only (no raw HTML), links only for http(s); Caddy CSP default-src 'self' without inline scripts or eval, frame-ancestors 'none', Referrer-Policy: no-referrer | Playwright with the production CSP; Caddyfile |
| R1 | Repudiation of admin actions | Every mutation audited with actor, action, target, IP (real client IP via TRUST_PROXY) | generated route-table audit test (routes.int.test.ts) |
| I1 | Cross-tenant data access | RLS forced on every tenant table; tenant-scoped routes authorised per request; system access restricted (ADR-0006); IDOR fuzzing over every route with ids | rls.int.test.ts, route-table matrix, idor.int.test.ts (13b-A) |
| I2 | Unpublished findings exposed on public repos | Repo roles require org membership; check-run output on public repos shows counts only | auth.test.ts (isAllowed); publisher.int.test.ts |
| I3 | Secrets in logs or responses | Pino redaction, no request bodies logged, secrets masked, gitleaks in CI; entropy-based redaction for unknown token formats (13b-C) | redact.test.ts, admin API tests, secrets.test.ts (tooling) |
| I4 | SSRF via configurable URLs (LLM base URLs, GHES hosts, ICS feeds, OIDC issuers, telemetry endpoint) | https required except explicitly private model hosts; redirects refused; enterprise edition rejects private, loopback, link-local, CGNAT and metadata addresses at save time and inside the socket DNS lookup (no rebinding window); guarded fetch for GitHub App validation in the enterprise edition (13b-C) | llm-credentials.int.test.ts, endpoint-guard.test.ts, validate.test.ts |
| I5 | Tenant uses the operator's model credentials | Provider clients always get the tenant key and base URL explicitly; Bedrock ambient credentials refused; env fallback tested | client.test.ts |
| I6 | Installation token or host compromise via repository checkout | Token only via a credential helper bound to the expected host, allowlisted git environment, GIT_ALLOW_PROTOCOL=https, no redirects/hooks/fsmonitor/submodules/symlinks, fsck, size and time limits, path traversal and .git/ reads refused; worker image non-root with read-only root filesystem and /tmp tmpfs | checkout.test.ts, workspace.test.ts, container E2E |
| I7 | Forged or spoofed publication state | Reconcile trusts markers only on reviews authored by the app's own bot login; per-comment markers map comments to findings | publisher.int.test.ts |
| I8 | Cross-tenant leakage through Valkey keys or caches | Documented key prefixes; tenant-scoped keys embed the tenant id; sandbox and egress ACL users cannot read quavon:* | valkey-users.acl; Valkey isolation suite (13b-A) |
| D1 | Webhook floods / unsigned spam | Body limit, signature check before DB writes, per-IP limit on failed deliveries, negative cache for unknown apps, outbox absorbs bursts | security.int.test.ts |
| D2 | Brute force on auth endpoints | Rate limits per IP (/auth, /setup) and per session (/api), break-glass fails closed when Valkey is down; sliding-window limiter and Valkey command timeout (13b-C) | security.int.test.ts |
| D3 | GitHub secondary-limit bans | Budget gate per installation, documented 403/429 handling | budget.int.test.ts (queue), provider tests |
| D4 | Lost events / duplicate effects | Transactional outbox, idempotent handlers, chaos test | chaos.int.test.ts |
| E1 | Privilege escalation via missing checks | Every route must declare a permission or the API refuses to start; matrix test per route | contract.test.ts, routes.int.test.ts |
| E2 | Over-privileged GitHub access | Review app without contents: write; Fixer app separate and opt-in; scoped installation tokens | provider tests, fix-pr.int.test.ts |
| E3 | BYPASSRLS misuse | @quavon/db/system import allowlist (lint), named operations on spans; quavon_app cannot SET ROLE, disable RLS or truncate the audit log | ESLint rule, rls.int.test.ts, RLS bypass suite (13b-A) |
4. OWASP Top 10 for LLM Applications ​
The rows follow the 2025 list, which is the current edition on genai.owasp.org as of 2026-10-13 and the basis of promptfoo's owasp:llm plugins. A 2026 edition (reported as published on 3 or 4 August 2026) is described by secondary sources, but its official numbering could not be verified on genai.owasp.org: the project page still lists the 2025 entries, and secondary sources disagree on the position of "Hidden Context Exposure" (08 or 09). The 2026 column below uses the most frequently reported order and is unverified; re-check it before publishing externally.
| 2025 | Risk | 2026 (unverified) | Control | Test / control |
|---|---|---|---|---|
| LLM01 | Prompt injection | LLM01 Prompt Injection | Z0 content wrapped with untrusted() (random boundary, invisible/bidi characters stripped); reader/writer split for replies; safe outputs; no write tokens in agent context; containment rather than detection | untrusted.test.ts; eval injection items (violations gate 0); redteam-smoke-v1 and E2E red team (13b-A) |
| LLM02 | Sensitive information disclosure | LLM02 Sensitive Information Disclosure | redact() before logging and before prompt logs; provider error bodies never stored; raw prompts only with tenant debug opt-in, encrypted, TTL ≤ 7 days; secret scan of outgoing bodies | redact.test.ts, secrets.test.ts (publisher) |
| LLM03 | Supply chain | LLM04 Supply Chain | Exact-pinned dependencies, licence gate, OSV-Scanner, image scan, SBOM; model providers chosen and keyed by the tenant; no third-party model weights shipped | licenses.test.ts, vulns.test.ts, ci.yml (sbom, image-scan) |
| LLM04 | Data and model poisoning | LLM05 Data and Model Poisoning | No training on customer data; feedback learning only adjusts bounded per-repository parameters, counts only actors with write permission, never suppresses security findings (FB1–FB3); rules only from the base branch (T3) | learning.test.ts, apply.test.ts, feedback.int.test.ts, replay gate |
| LLM05 | Improper output handling | LLM10 Improper Output Handling | Publisher validates findings, maps anchors to the head diff, offers a one-click suggestion only for the exact lines the model quoted (found once near the finding in the head file, commentable as a range) and only when no replaced line disappears without a counterpart, otherwise shows the code as a plain block, sanitises markdown (HTML stripped, mentions neutralised, link hosts allowlisted, no images), blocks and audits outgoing bodies with secrets, labels every comment as automated | sanitize.test.ts, render.test.ts, suggestion.test.ts (incident of a committed suggestion that deleted a required line), publisher.int.test.ts; publisher-output assertions in the red team (13b-A); smoke item smoke-v1/034 under the fix-validity gate. Residual risk: a replacement that rewrites a quoted line into a wrong line with the same leading name, or a deletion the model marks as intended, is still committable; the author reviews every suggestion before committing it |
| LLM06 | Excessive agency | LLM03 Excessive Agency | Review agents only read; GitHub writes only through the publisher (lint rule), never APPROVE/REQUEST_CHANGES; shadow and approval modes; Fixer tier opt-in; assignment apply behind legal sign-off | tools.test.ts, publisher.int.test.ts, assignment-flow.int.test.ts, ESLint rule |
| LLM07 | System prompt leakage | LLM08 Hidden Context Exposure | No secrets, credentials or tenant data of other tenants in prompts or tool schemas; system-prompt fragments are a red-team violation kind | redact.test.ts; red-team canary and system-prompt checks (13b-A) |
| LLM08 | Vector and embedding weaknesses | LLM09 Vector and Embedding Weaknesses | Tenant-scoped embeddings under RLS; search filtered by tenant and repository | rls.int.test.ts; embedding isolation suite (13b-A) |
| LLM09 | Misinformation | LLM07 Misinformation | Replies need verified citations from the same repository or abstain; acceptance results marked inferred and never blocking; review never approves; visible automated-output notice | reply.test.ts, check.test.ts (acceptance), render.test.ts |
| LLM10 | Unbounded consumption | LLM06 Unbounded Consumption | Tool-loop step/token/wall-clock limits; rolling EUR budgets per tenant and repository with downgrade or block; atomic budget reservation under concurrency (13b-C); queue fairness; budget alerts (11b) | budget.test.ts (llm), budget.int.test.ts (queue) |
5. Feature summaries ​
Sandbox, deterministic scanners, Issue→Draft PR and acceptance criteria (details in fix-agent-and-sandbox.md):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| SB | Repository code escapes the sandbox, reads secrets or reaches the network | Separate runner service with signed jobs and its own Valkey ACL user; images by digest; gVisor runsc (systrap without KVM) or Firecracker; no tokens, no .git, egress none by default; limits enforced by the runner | escape.int.test.ts (runc locally, runsc/Firecracker in CI), signing.test.ts |
| FX | Fix agent pushes harmful or unauthorised changes | Trigger by write users only (re-checked at publish); fixed commands from policy; patch from an in-memory overlay validated outside the sandbox; Fixer app, new bot branch, draft PR only, approval by default | patch.test.ts, fix-pr.int.test.ts, fix.int.test.ts |
| AC | Injected issue text skews acceptance results | Deterministic extraction first, inferred marking, untrusted wrapping, evidence verified against the diff, never blocking | extract.test.ts, check.test.ts |
Feedback learning:
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| FB1 | Feedback poisoning by outsiders | Dismissal signals count only from actors with write permission; reactions only on private repositories; suppressions are proposals until a maintainer activates them, expire, never apply to security findings or critical severity | feedback.int.test.ts, apply.test.ts |
| FB2 | Calibration drift silences a category | Minimum sample size per scope; shadow mode; offline replay gate | replay.test.ts, replay eval gate |
| FB3 | Cross-tenant learning | Tenant tables under forced RLS; no shared parameters; feedback never trains models | rls.int.test.ts |
Expertise-aware assignment (ADR-0014):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| AS1 | SSRF through a user-supplied ICS feed URL | https only, no credentials in the URL, guarded fetch in every edition, redirects refused, 10 s timeout, 1 MiB cap, calendar content types only; URL encrypted, never logged or returned | availability/index.test.ts, assign.int.test.ts |
| AS2 | Mention spam or markdown injection through suggestion comments | Fixed templates and typed facts; logins, patterns and teams as code spans; sanitised, secret-scanned, marker-reconciled; at most one comment per subject | explain/index.test.ts, assignment.int.test.ts (publisher) |
| AS3 | Per-person inference from shadow or stored data | Only selected people with facts stored, no scores in any API; shadow rows visible to self-hosted instance admins only and to nobody in the hosted edition; per-person endpoints 404 in team-only and works-council mode | privacy/index.int.test.ts, assignment.int.test.ts (API) |
| AS4 | Assignment leaves shadow or writes without the legal and co-determination gates | effectiveAssignmentMode checked in the API, the job and the publisher; works-council mode without agreement is off; apply needs a per-repository opt-in | assignment.test.ts (shared types), assignment-flow.int.test.ts |
Generic OIDC login (ADR-0013):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| ID1 | Group-name collision across identity providers grants roles | oidcGrants uses only mappings of the session's identity provider | identity.test.ts ("ignores mappings of another identity provider") |
| ID2 | SSRF through discovery-supplied endpoints (JWKS, token, userinfo) | Every OIDC call through the guarded customFetch, redirects refused; enterprise: https and no private addresses; issuer normalised before the uniqueness check | endpoint-guard.test.ts; oidc-login.int.test.ts (enterprise private issuer), oidc-check.test.ts, oidc-identity-providers.int.test.ts |
| ID3 | Login CSRF, code injection, token substitution | State, nonce and PKCE S256 bound to a pre-login cookie; iss (RFC 9207), aud, azp, exp and signature validated; only response_type=code; plain PKCE never sent | oidc-login.int.test.ts (wrong or replayed state, missing cookie, wrong nonce, wrong iss parameter and claim, wrong aud, foreign azp, expired token), oidc-dex.int.test.ts |
| ID4 | Stale IdP groups or disabled provider keep access | Grants computed per request from session groups and current mappings; session ≤ 8 h; disabled provider rejects sessions; deleting a provider cascades to its sessions | oidc-login.int.test.ts |
| ID5 | A provider bound to one tenant is used in another | Mapping, listing and grants require enabled AND (bound_tenant_id IS NULL OR = tenant); enterprise edition requires a binding | oidc-login.int.test.ts ("ignores mappings once the provider is bound to another tenant"), oidc-identity-providers.int.test.ts |
| ID6 | Group overflow or truncation changes the granting group | More than 200 groups or a group longer than 256 characters rejects the login | identity.test.ts, oidc.test.ts, oidc-login.int.test.ts |
Team analytics:
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| AN1 | Per-person inference via small groups or differencing of overlapping scopes | k-anonymity per point and dimension cell (k ≥ 5), no head counts, no person dimension; tenant and team scopes only with tenant:manage; differencing is a documented residual risk | analytics.test.ts; packages/db/src/analytics/analytics.int.test.ts (4 vs 5 authors, dimension cells, shared authors, bots), apps/api/src/routes/analytics/analytics.int.test.ts, apps/web/e2e/analytics.spec.ts |
| AN2 | CSV formula injection in exports | Escaping as in the audit export; cache-control: no-store; suppressed values never exported | audit.test.ts (escaping); apps/api/src/routes/analytics/analytics.int.test.ts ("exports CSV with formula escaping and without suppressed points") |
| AN3 | Cost data exposure | Cost metrics only on /costs routes with tenant:manage / repo:manage; never split by person | apps/api/src/routes/analytics/analytics.int.test.ts ("separates cost metrics from team metrics"); route-table fixtures |
| AN4 | Self view becomes a per-person view for others | Self view only for the person, opt-in, never in works-council or team-only mode; 404 for every other case including administrators | analytics.test.ts (selfViewAllowed); apps/api/src/routes/analytics/analytics.int.test.ts (self view 404 cases, works-council refusal) |
Distribution, observability and product telemetry:
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| K8S1 | Sandbox pod reaches the control plane, Valkey, Postgres or the internet | Sandbox namespace with default-deny NetworkPolicy; egress only to the egress proxy for registries jobs during setup; Pod Security restricted; runner refuses a RuntimeClass whose handler is not runsc | tooling/ci/kind/e2e.sh (12 NetworkPolicy probes); backends/kubernetes/spec.test.ts |
| K8S2 | Runner service account over-privileged | Namespaced Role limited to pods and pods/attach; read-only runtimeclasses; self-check with SelfSubjectAccessReview | backends/kubernetes/index.test.ts (RBAC self-check) |
| K8S3 | Autoscaler credentials read job data | Dedicated Valkey ACL user limited to queue-length commands on quavon:*:wait | deploy/helm/codelotse/tests/render.sh |
| MT1 | Metrics leak tenant or personal data, or explode in cardinality | METRICS attributes exclude tenant, repository, person and installation login | metrics.test.ts, job-runner.metrics.test.ts, webhooks.metrics.int.test.ts |
| TL1 | Product telemetry sends data without consent or more than shown | Off by default; only instance admins enable it, only in self_hosted, only after a preview exists; the sent body equals the stored preview; strict schema with bucketed counts only; enterprise and air-gap hard off | analytics.test.ts (productTelemetryUnavailable, countBucket); product-telemetry.test.ts (property test), apps/worker/src/telemetry/product.int.test.ts, apps/api/src/routes/admin/instance/telemetry.int.test.ts |
| TL2 | Telemetry endpoint used for SSRF or redirect | https only, no userinfo, no redirects, no cookies, 10 s timeout, no retries | apps/worker/src/telemetry/product.int.test.ts |
Data subject requests:
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| DS1 | Deleting the wrong person through a reused login | Delete requires the provider external id | DsarDeleteSubject contract; packages/db/src/dsar/dsar.int.test.ts |
| DS2 | Export file or audit entry exposes the subject | Export file mode 0600; audit entry stores a hash of the subject | packages/db/src/dsar/dsar.int.test.ts, apps/api/src/bin/dsar.int.test.ts |
| DS3 | Incomplete deletion leaves person-linked data | Personal-data registry with a completeness test over all tables | personal-data.test.ts |
Hardening:
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| HD1 | DNS rebinding during GitHub App validation and manifest conversion | Enterprise edition uses the guarded fetch for both | setup.int.test.ts (rebinding resolver), github-apps.int.test.ts |
| HD2 | Concurrent review-app registrations | Advisory lock with re-check under the lock | setup.int.test.ts (race test) |
| HD3 | Rate-limit bursts at window edges, silent fail-open on a stalled Valkey | Sliding-window log, 250 ms timeout, throttled warning and the quavon.rate_limit.store_unavailable counter and alert; break-glass fails closed | rate-limit.int.test.ts, security.int.test.ts |
| HD4 | High-entropy secrets without a known prefix in logs | Logs and prompt logs also redact high-entropy tokens (git SHAs and SHA-256 digests exempt) | logger.test.ts, redact.test.ts |
| HD5 | Revoked GitHub user tokens keep read access | A 401 on membership refresh ends the session (auth.session_revoked) | auth.int.test.ts ("ends the session instead of using cached roles when GitHub revoked the token") |
| HD6 | Sandbox Valkey user lists queue key names (tenant, repository and job ids) | -scan on the sandbox ACL user | packages/queue/src/isolation.int.test.ts ("hides quavon key names from the sandbox user") |
Code hosts: GitLab and Azure DevOps Repos:
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| PV1 | Forged GitLab or Azure DevOps webhooks | Per-connection secret (X-Gitlab-Token / basic-auth password), constant-time comparison before any DB write; unknown connection and bad secret both return 401 without detail; failed-delivery rate limit | apps/api/src/routes/webhooks/connection.int.test.ts |
| PV2 | Replayed deliveries | Idempotency key (X-Gitlab-Event-UUID, Azure notification id) in webhook_deliveries, unique per provider, host and delivery | connection.int.test.ts |
| PV3 | Theft of code-host tokens | Encrypted per connection (see assets), 4-character hint, webhook secret shown once | provider-connections.int.test.ts, encrypted-columns.test.ts |
| PV4 | SSRF through the connection base URL | No credentials, query or fragment; https only (http only for private hosts in the self-hosted edition); enterprise edition requires a public host resolved via assertPublicEndpoint, guarded fetch | provider-connections.int.test.ts |
| PV5 | Cross-tenant use of a connection | Tenant RLS on provider_connections; composite FK (tenant_id, installation_id); all connection routes in the IDOR fuzz | rls.int.test.ts, idor.int.test.ts |
| PV6 | Installation collisions across hosts | Installation uniqueness (provider, base_url, external_id) | packages/installations/src/index.int.test.ts, migrate.int.test.ts |
| PV7 | Bidi or zero-width spoofing in published comments (red team F3, closed) | The publisher strips U+200B–200F, U+202A–202E, U+2060–206F, U+FEFF and similar characters; tracker writes use the same set | packages/eval/src/redteam/redteam.test.ts ("strips bidi and zero-width control characters"), packages/publisher/src/tracker/text.test.ts |
| PV8 | Silent loss of findings on hosts without a capability | Capability-driven degradation: fenced suggestion block, commit status plus summary comment, audited SARIF skip | publisher.int.test.ts, sarif.int.test.ts, render.test.ts |
Code hosts: Bitbucket Cloud, Bitbucket Data Center, Gitea and Forgejo:
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| PV9 | Forged Bitbucket, Gitea or Forgejo webhooks | HMAC-SHA256 over the raw body with the per-connection secret (X-Hub-Signature, X-Gitea-Signature, X-Forgejo-Signature), constant-time compare before any DB write; unknown connection, wrong kind, tampered body and missing signature all return 401 without detail; failure rate limit | apps/api/src/routes/webhooks/signed.int.test.ts |
| PV10 | Replayed deliveries | Delivery id (X-Request-UUID, X-Request-Id, X-Gitea-Delivery, X-Forgejo-Delivery) in webhook_deliveries per connection; duplicates answered 200 without enqueue | signed.int.test.ts |
| PV11 | Theft of Bitbucket, Gitea or Forgejo tokens | Same encrypted connection storage as PV3 (context provider_connections:<id>), 4-character hint, secret shown once | provider-connections.int.test.ts |
| PV12 | SSRF through the base URL | As PV4; the Bitbucket Cloud base URL is pinned to https://bitbucket.org | provider-connections.int.test.ts |
| PV13 | Cross-tenant use of the new connection kinds | Tenant RLS; route fixtures rotate through all connection kinds in the IDOR fuzz | idor.int.test.ts, routes.int.test.ts |
| PV14 | Another app's comments treated as ours on Bitbucket Cloud (no /user for access tokens) | App-user accounts mapped to [bot] logins; thread linking only matches comment ids recorded as posted by us | normalize-bitbucket.test.ts, contract suite |
| PV15 | Findings lost on hosts without multi-line comments, suggestions or thread resolution | As PV8; Forgejo thread resolution refused explicitly; Code Insights annotations stripped of invisible characters | contract suite, packages/git-provider/src/bitbucket/provider.test.ts, Forgejo/Gitea E2E |
| PV16 | Comment ids colliding across connections of one tenant (GitLab, Gitea, Forgejo and Bitbucket Data Center ids are unique per server only), so thread linking, resolution and feedback act on the wrong comment or none | posted_comments unique on (tenant_id, pull_request_id, external_id) (migrations 0029 expand, 0030 contract); every lookup by comment id is scoped by pull request | packages/publisher/src/publisher.int.test.ts ("records equal comment ids from two connections of one tenant"), Forgejo and Gitea E2E in one tenant |
Work trackers: Jira, Linear, Azure Boards:
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| TK1 | Forged tracker webhooks | Per-connection secret, constant-time HMAC (Jira X-Hub-Signature, Linear-Signature) or hashed basic-auth compare (Azure Boards), Linear timestamp tolerance 60 s, verification before parsing, 401 without body for unknown connection, wrong source and bad signature, failure rate limit | apps/api/src/routes/webhooks/trackers.int.test.ts, packages/trackers/src/keys-webhooks.test.ts, tracker contract suite |
| TK2 | Replayed or duplicated deliveries; another tenant suppressing deliveries by guessing ids | Delivery id prefixed with the connection id, unique in webhook_deliveries; outbox job in the same transaction | trackers.int.test.ts |
| TK3 | Trusting webhook content | Only the issue id is taken; the ticket is re-read with the connection credentials | apps/worker/src/processors/tracker-sync.int.test.ts |
| TK4 | SSRF through tracker base URLs | https only (http for on-premises trackers in the self-hosted edition), no credentials or query; enterprise: public-address check at create and guarded fetch (DNS pinned, no redirects) on every request | apps/api/src/routes/admin/trackers.int.test.ts ("validates the kind, credentials and base URL") |
| TK5 | Jira OAuth 3LO CSRF or code injection | 256-bit state hashed in Valkey, 10 min, single use, bound to the starting admin; internal redirects only; site matched against accessible resources | trackers.int.test.ts ("connects Jira Cloud through OAuth 3LO …") |
| TK6 | Refresh-token race with rotating tokens | Refresh under SELECT … FOR UPDATE with re-check | tracker-sync.int.test.ts ("refreshes expiring Jira OAuth tokens once") |
| TK7 | Sync loops or write amplification | Agreed values and pending writes, in-flight projection and deferral, failed writes retried rather than reverted, per-change idempotency keys | packages/trackers/src/sync/engine.test.ts, tracker-sync.int.test.ts ("survives a bidirectional stress run without sync loops") |
| TK8 | Content injection into trackers | ADF built from an AST, HTML escaped, wiki control characters escaped, links only http(s)/mailto, control/bidi characters stripped, secret scan on comments and created tickets | packages/trackers/src/format/format.test.ts, packages/publisher/src/tracker/text.test.ts |
| TK9 | Prompt injection through ticket text | Ticket text goes through the acceptance pipeline (untrusted(), evidence verification) | apps/worker/src/processors/tracker-acceptance.int.test.ts, ac-smoke-v1 injection items |
| TK10 | Tracker writes outside the publisher | Tracker writes only through submitTrackerWrite/publishTrackerWrite; lint rule covers updateFields, addComment, createIssue outside packages/trackers and packages/publisher | ESLint rule (eslint.config.js) |
| TK11 | Tenant isolation and tracker rate limits | RLS on all tracker tables, all new routes in the IDOR fuzz; budget gate per host and connection, header-driven blocks, delayed jobs | rls.int.test.ts, idor.int.test.ts, contract test "turns rate limit responses into a delayed retry" |
Release hardening:
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| RH1 | Tampered or substituted release images | Base images pinned by digest; CycloneDX SBOM and grype scan (--only-fixed --fail-on high) per image; release.yml pushes by digest, signs with cosign keyless and attaches SLSA build provenance and SBOM attestations; nothing is published until a registry is configured | .github/workflows/release.yml, apps/cli/src/commands/release/dry-run.test.ts, quavon release dry-run |
| RH2 | Attribution or private metadata in release notes | Notes use conventional-commit subject lines only; bodies, trailers and "generated by" footers are never copied | apps/cli/src/commands/release/notes.test.ts |
| RH3 | Lost events or duplicate external writes when workers crash or Valkey, Postgres or the model endpoint fail | Outbox in the webhook transaction, BullMQ stalled-job recovery, idempotent review runs and publish intents; review publishes serialised per repository and head commit, so pull requests sharing a head commit create one check run | tests/chaos/src/chaos.chaos.test.ts (nightly hardening.yml), packages/publisher/src/publisher.int.test.ts ("creates one check run when two pull requests with the same head publish at once") |
| RH4 | Webhook floods delaying acknowledgements | Signature check before DB writes, one short transaction per delivery; ack p99 measured at nominal and peak rates | tests/load/src/ingress.load.test.ts (nightly) |
| RH5 | Upgrades that break stored data or the previous release | Checksummed migrations, expand/contract lint, upgrade matrix with seeded data from every supported release | tests/upgrade/src/upgrade.upgrade.test.ts, tests/upgrade/src/releases.test.ts |
| RH6 | Pilot reports exposing individuals | Team and tenant aggregates through the analytics query with k-anonymity; no logins or ids in any output | apps/cli/src/commands/pilot/pilot.int.test.ts |
| RH7 | Deterministic findings (secrets, SAST) withheld while the model endpoint is down | After the last attempt with a retryable model error, a review with deterministic findings completes without the model and says so in the summary; mode (shadow, approval, auto) and publish intents unchanged | apps/worker/src/processors/review.int.test.ts ("publishes deterministic findings only when the model stays unavailable"), packages/agents/src/review/pipeline.test.ts, tests/chaos/src/chaos.chaos.test.ts |
Hosted edition: sign-up, lifecycle, support access, status, fairness (enterprise edition only):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| HS1 | Someone claims an organisation they do not administer | Claim needs tenant:manage, which the membership sync grants only to org owners and admins (or the account owner) of an installation of that tenant; GitHub principals only; conditional unclaimed → active update | hosted/api/src/routes/saas/saas.int.test.ts ("claims the provisioned tenant …" with a second claim answering 409, "rejects anonymous and non-admin callers …") |
| HS2 | Data of organisations that never agreed to the service is stored | Unclaimed tenants ignore every non-lifecycle event; unclaimed tenants are deleted after 7 days | hosted/worker/src/saas/saas.int.test.ts ("creates an unclaimed tenant and processes nothing until it is claimed", "removes unclaimed tenants after seven days") |
| HS3 | Forged or replayed owner email verification | 256-bit single-use token, only the SHA-256 hash stored, 48 h expiry, verification by explicit POST, 10/min/IP | saas.int.test.ts ("… verifies the owner email once", "expires verification links …"); hosted/web/e2e/hosted.spec.ts ("confirms the owner email only on request") |
| HS4 | SMTP downgrade or header injection | STARTTLS required when configured (refused if not offered), TLS mode, CR/LF in headers rejected, password encrypted in saas_settings | mail.test.ts, saas.int.test.ts ("stores SMTP and status webhook secrets encrypted …") |
| HS5 | Silent operator access to tenant data | Access only through an approved, time-boxed grant; the requester cannot approve; break-glass cannot request; enterprise-only authorizer wrapper | saas.int.test.ts ("needs tenant admin approval …", "ends access when the approved time runs out …", "refuses break-glass sessions …") |
| HS6 | Support session changes data or takes an export | The wrapper applies only to non-mutating requests; export is a POST | saas.int.test.ts (owner email and export answer 403 during an active session) |
| HS7 | Unaudited support reads | Every request under a grant appends support_access.read with route and grant id | saas.int.test.ts (exact audit sequence) |
| HS8 | Cross-tenant access through the hosted routes | Tenant-scoped routes under withTenant, forced RLS on the new tables, IDOR fuzz over both editions with tenant admin and instance admin principals; black-box isolation fuzz over HTTP against a kind deployment of the hosted edition | hosted/api/src/idor.int.test.ts, hosted/db/src/rls.int.test.ts (new tables), hosted/deploy/tests/isolation-fuzz.ts (run by hosted/deploy/tests/staging-kind.sh) |
| HS9 | Export leaks secrets or other tenants | DSAR secret-column rule, RLS-scoped reads, tenant admin only, 5/min | saas.int.test.ts ("exports every tenant table without secrets and nothing of other tenants") |
| HS10 | Incomplete deletion | Delete in foreign key order over every tenant table except the kept ones (audit_log, saas_tenants, billing_accounts, invoices), tombstone without owner data, uninstall after the grace period and before delete | hosted/worker/src/saas/saas.int.test.ts ("uninstalls the app and deletes every tenant row …", "orders every tenant table …") |
| HS11 | Status endpoint leaks internals or is abused | Fixed shape (states only), cached 15 s, 60/min/IP; webhook signed, public HTTPS targets only, no redirects | saas.int.test.ts ("serves a cached public health summary …"); worker ("sends signed incident webhooks on status changes only") |
| HS12 | A noisy tenant starves others | Tenant-level round-robin in the routed dispatcher, tenant-fair outbox claims | routed.int.test.ts ("does not let a tenant with many installations starve another tenant"), dispatcher.int.test.ts ("claims the rows of a quiet tenant in the first batch behind a flood") |
| HS13 | A hosted tenant runs on an untested or foreign model key | The enterprise gateway refuses keys without a successful test; edits clear the flag; the per-tenant key binds the blob to its tenant | tenant.int.test.ts ("refuses keys that never passed a connection test …"), llm-credentials.int.test.ts, crypto.test.ts ("tenant keys") |
| HS14 | Installation id enumeration on the public check | Answers only known for the shared app, no account data, 30/min/IP | saas.int.test.ts ("publishes the shared app install link …") |
| HS15 | Deletion cancelled by someone else, or after data removal started | tenant:manage + CSRF, never under support access (mutation), row lock, only while delete_after is later than the database clock, 409 afterwards, audited | hosted/api/src/routes/saas/saas.int.test.ts ("cancelling a requested deletion"), idor.int.test.ts, staging-kind.sh fuzz |
| HS16 | Cancellation silently resumes processing for installations suspended for other reasons | Only installations suspended at the request time are resumed | saas.int.test.ts ("keeps installations suspended …") |
| HS17 | A cancel races the uninstall or hard delete, a cancelled tenant loses its installation, or a pending deletion is still charged | The GitHub App is uninstalled only after the grace period, so a cancelled deletion keeps its installation and reinstalling cannot create a second tenant. Before the uninstall and the hard delete the worker re-checks deletion_pending and delete_after <= clock_timestamp() under a row lock (FOR UPDATE), the same lock the cancel route takes; once the worker has seen the deadline pass, the cancel route refuses. No renewal is charged while a tenant is deletion_pending | hosted/worker/src/saas/saas.int.test.ts ("uninstalls the app and deletes every tenant row only after the grace period", "keeps the installation of a tenant that cancels its deletion during the grace period"), hosted/billing/src/lifecycle.int.test.ts ("runs from trial to active, renews …": no renewal while deletion_pending) |
Hosted edition: plans, metering, Mollie billing, invoices, sandbox progress (enterprise edition only):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| BL1 | Forged Mollie webhook | Only a form id is accepted (tr_ or invoice_), queued through the outbox; the worker re-fetches the object with our key and finds the tenant in our own tables (customer or invoice id), never from the body; unknown ids ignored; failures rate limited per IP | hosted/api/src/routes/billing/billing.int.test.ts (Mollie webhook), hosted/billing/src/lifecycle.int.test.ts |
| BL2 | Replayed webhooks activating twice | billing_events unique on (mollie_id, status); activation only while checkout_payment_id matches, then cleared | lifecycle.int.test.ts (duplicate, ignored) |
| BL3 | Double charging | Idempotency-Key on every POST; invoice unique on (tenant, kind, period_start); the issue job skips invoices that have a Mollie id; permanent provider errors set error instead of retrying | lifecycle.int.test.ts (second issue skipped), mollie/client.test.ts |
| BL4 | Cross-tenant billing access | Forced RLS on billing tables, tenant:manage (summary tenant:view), tenant-scoped authorisation | hosted/db/src/rls.int.test.ts, billing.int.test.ts (cross-tenant 403 for every route) |
| BL5 | Mollie key disclosure or a live key in tests | *_FILE, never logged, live_ refused outside production, recorded fixtures only | hosted/config/src/config.test.ts |
| BL6 | Price or plan tampering by a tenant | The catalogue is server config; clients send plan key and interval only; amounts are computed on the server | lifecycle.int.test.ts (amount match), billing.int.test.ts (unknown plan 400) |
| BL7 | False B2B or VAT claims | VAT ID format per country, VIES check with timestamp and consultation number, refusal when invalid or unconfirmed | issuer-vies.test.ts, lifecycle.int.test.ts (VIES), tax.test.ts |
| BL8 | Public progress reveals customers or revenue | Rounded down to 5 %, capped at 100, 0 below five paying tenants, at most one recompute per hour, timestamp truncated to the hour, snapshot table without tenant column or identifiers | usage.int.test.ts, pure.test.ts, hosted/db/src/rls.int.test.ts (columns), billing.int.test.ts |
| BL9 | Load or timing attacks on the public progress endpoint | Served from Valkey or the stored row, never counts subscriptions, strict per-IP bucket, Cache-Control: public, max-age=300 | billing.int.test.ts (watched database, rate limit) |
| BL10 | CORS abuse | Allowlisted origins only, GET only, no credentials, Vary: Origin | billing.int.test.ts |
| BL11 | SSRF or path injection towards Mollie or VIES | Fixed hosts, redirects refused, ids validated before use in paths, timeouts | mollie/client.test.ts ("refuses redirects and malformed ids") |
| BL12 | Using features outside the plan (including sandbox) | Plan guard in review, SAST and triage, and job gating; hosted tenants without a plan are read-only | review.int.test.ts, triage.int.test.ts, hosted/worker/src/billing.int.test.ts, lifecycle.int.test.ts |
| BL13 | Output silently lost when a limit is hit | Shadow degrade or a recorded skip with reason; running runs are never re-checked, so security findings still publish | review.int.test.ts ("never re-checks running runs") |
| BL14 | Data loss on non-payment | Grace period, then read-only; no deletion | lifecycle.int.test.ts (grace → read-only, invoices kept) |
| BL15 | Read-only bypass | API preHandler answers 402 for tenant mutations except billing, /saas and support access, so an admin can always pay, export, delete or revoke support access | billing.int.test.ts (read-only tenants) |
| BL16 | Usage double counting or manipulation | Outbox event in the work transaction; rollups recomputed from source tables; finalised months immutable | usage.int.test.ts |
| BL17 | Seat list exposes individuals | Admin only; provider and login only; no per-person activity | billing.int.test.ts (member 403) |
Hosted edition: reminders, mandate revocation, retention purge, test invoices (enterprise edition only):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| BL18 | Reminder flood or duplicate dunning mails | One reminder per day of graceReminderDays (0, 7, 12) and failed period; audit entry written in the same transaction under a row lock; re-check before sending | hosted/billing/src/offboarding.int.test.ts ("mails the billing email on days 0, 7 and 12 once per failed period") |
| BL19 | Reminder mail header injection or address leak in logs | Shared SMTP client rejects CR/LF in headers; recipient only from the tenant's own billing account; errors logged without the address | mail.test.ts, pure.test.ts (graceReminderMail) |
| BL20 | Mandate stays valid after deletion or cancellation | Hourly revocation for hard-deleted tenants and ended cancellations, so within about an hour; 404/410 count as done; retried on failure; audited | offboarding.int.test.ts ("revokes the mandate once a cancelled subscription has ended", "revokes the mandate with retries …"), mollie/client.test.ts |
| BL21 | Invoice records deleted too early or kept forever | Purge only for hard-deleted tenants, Berlin calendar-year cutoff, configurable years (default 8, pending the tax adviser), account only after the last invoice and a cleared mandate; audited | offboarding.int.test.ts (boundary at 2034-12-31T23:00Z, "never purges the invoices of tenants that still exist"), pure.test.ts (retentionCutoff) |
| BL22 | Test-invoice script used against the live account | Only test_ keys; aborts on a non-test response mode; no live calls in tests | test-invoices.test.ts |
Hosted edition: payment method change, terms acceptance, webhook flood (enterprise edition only):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| BL23 | Payment method change used to activate or extend a plan, to take over another tenant's mandate, or leaves the old mandate valid | tenant:manage + CSRF, only for active or past_due plans, audited; the €0.01 first payment is tagged purpose: payment_method in metadata set by the server and is only accepted while checkout_payment_id matches; it replaces the stored mandate and never changes plan, period or seats; the tenant comes from our own customer id; every other mandate of the customer is revoked afterwards (retried with the job, audited) and the €0.01 is refunded with an idempotency key | hosted/billing/src/lifecycle.int.test.ts ("replaces the payment method of a paid plan …", "marks a failed payment method change …"), billing.int.test.ts ("starts a payment method change …"), mollie/client.test.ts |
| BL24 | A checkout without accepted terms, or accepting outdated terms | acceptTerms: true (literal) and the current terms.version are required (400 or 422 otherwise); acceptance is audited with actor, IP and version in the transaction that records the checkout | billing.int.test.ts ("saves billing details, starts the Mollie checkout …"), lifecycle.int.test.ts (terms_outdated, audit row), hosted/config/src/config.test.ts (https terms URL required), hosted/web/e2e/billing.spec.ts |
| BL25 | Webhook flood: valid-looking ids each queue a job that calls the Mollie API | Every request on /webhooks/mollie counts against 600/min per IP before the body is parsed (429 with Retry-After, metric outcome rate_limited); malformed requests additionally 30/min per IP; repeated ids collapse to one outbox row per minute; Mollie retries refused calls | billing.int.test.ts ("limits every delivery per IP with a generous bucket", "rate-limits repeated malformed deliveries") |
CI failure analysis (ci-analysis.md):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| CI1 | Injected instructions in CI logs, check names or annotations (fork authors control what their CI prints) | Every input wrapped in untrusted blocks with the CI threat model in the system prompt; model text cleaned (links, images, HTML, mentions removed) and sanitised again by the publisher; locations only when the file exists at the head and the line is inside it; on public repositories auto becomes approval | ci.test.ts (agents), ci.int.test.ts ("downgrades auto to approval on public repositories", "drops locations that do not exist at the head"), ci-smoke-v1 (injection, unverified locations), red team ci items |
| CI2 | Secrets printed in CI logs reach the model, the database or the comment | redact() over the whole log before the excerpt; only the redacted excerpt goes to the model; raw logs are never stored (ci_analyses keeps check metadata and the verified result only); the publisher secret scan blocks a comment that still contains one | ci.test.ts ("redacts secrets"), ci-smoke-v1 (ci_prompt_secret_leaks = 0), red team items 073 and 074 (secret blocked) |
| CI3 | SSRF or token leak through the log redirect | The job-log request does not follow redirects; the Location is fetched without the GitHub token, https only, no further redirects, through the guarded fetch in the enterprise edition (no private addresses at connect time), 60 s timeout, 20 MiB cap with the tail kept; download failures fall back to annotations | provider.int.test.ts ("sends no credentials to the blob host", "rejects a log redirect to a private address", "rejects a log redirect to plain http"), check-runs.test.ts |
| CI4 | Cost from noisy or flapping CI | Three-minute debounce with one outbox key per head; one analysis per pull request and head (unique row); at most three checks; per-pull-request daily limit; budget gate; hosted plan gate | ingest.int.test.ts ("routes one debounced ci analysis per failed head"), ci.int.test.ts ("analyses each head only once", daily limit) |
| CI5 | Loops on CodeLotse's own check run | Check runs whose app slug is CodeLotse's are skipped before analysis | ci.int.test.ts ("skips its own checks …") |
Notifications (ADR-0016, notifications.md):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| NT1 | SSRF through a channel URL | Slack (hooks.slack.com) and Teams (*.environment.api.powerplatform.com) host allowlist, https only, no credentials or fragment, checked when saving and again before every send; Slack and Teams always through the guarded fetch; generic webhooks https only, assertPublicEndpoint and the guarded fetch in the enterprise edition; no redirects, 10 s timeout, at most 64 KiB of the answer read | notifications.test.ts (shared-types), transport.test.ts ("refuses stored targets …", redirects), notifications.int.test.ts ("rejects private webhook targets and blocks them when testing") |
| NT2 | Leak of webhook URLs or signing secrets | URL and signing secret encrypted with the context notification_channels:<id> and covered by keys rotate; views show the host and the last four characters only; the signing secret is returned only on create and rotate; errors and logs never contain the URL | notifications.int.test.ts ("stores Slack URLs encrypted …", "returns the webhook signing secret only on create and rotate"), notify.int.test.ts (publisher: "never logs or stores the target URL"), encrypted-columns.test.ts |
| NT3 | Content injection into Slack, Teams or e-mail (mentions, links, header injection) and leaking code | Metadata only (repository, number, title, counts); control and invisible characters removed; Slack &, <, > escaped (neutralises <!channel> and mentions); Teams Markdown escaped; e-mail plain text with fixed subjects; secret scan before sending (skipped on a hit) | render.test.ts, transport.test.ts ("skips messages that look like they contain a secret") |
| NT4 | Spoofed or replayed webhooks to receivers | HMAC-SHA256 over <timestamp>.<body> in X-CodeLotse-Signature, 32-byte random secret per channel, rotatable; delivery id per event for de-duplication; verification recipe with a five-minute window | render.test.ts (signWebhookPayload), transport.test.ts ("posts signed JSON to a local HTTPS receiver") |
| NT5 | E-mail or chat spam | Only tenant:manage users create channels (audited); at most 20 recipients; events only, no free text; one delivery per channel and event key; approval digest at most hourly per channel; test sends at most every 10 s per channel | notifications.test.ts (shared-types), notify.int.test.ts (worker: digest interval, one digest per channel), notifications.int.test.ts (409 on a second test) |
Browser push (ADR-0020, notifications.md):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| PU1 | SSRF through a subscription endpoint | Endpoint must be https on the push services of the major browsers (host allowlist), no port, credentials or fragment; checked when subscribing and again before every send; guarded fetch, no redirects, 10 s timeout, at most 64 KiB read | push.test.ts (shared-types), publisher push transport tests |
| PU2 | Push to people who lost access, or about repositories they cannot see | Recipients from stored memberships refreshed within seven days; admin-only events for admins; repository events need a repository role; checked at submit and again before sending | push.int.test.ts (publisher: eligibility matrix, recheck before sending) |
| PU3 | Leak of content through browser vendors' push services | Metadata only, rendered like other channels; secret scan before sending; payload encrypted end to end (RFC 8291) with per-message keys | webpush.test.ts (RFC 8291 test vector), render and transport tests |
| PU4 | Managing or testing someone else's subscription | Routes under /api/me, cookie sessions only, rows filtered by the signed-in user; other users' ids answer 404; audited; test at most every 10 s per subscription; at most ten subscriptions per person and tenant | API push subscription integration tests, IDOR fuzz |
| PU5 | Phishing or open redirect through a notification click | Messages carry a path, not a URL; the schema and the service worker accept only same-origin paths | push.test.ts (shared-types), web e2e |
| PU6 | Theft of subscription secrets | Endpoint and keys encrypted with the context push_subscriptions:<id>, covered by keys rotate; views show the host and a hash; VAPID private key only in configuration secrets | encrypted-columns.test.ts, config tests |
Pull request descriptions (configuration.md):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| PD1 | Injected instructions in a public pull request title, description or diff make the description publish links, mentions or HTML | Title, description (without our own block) and diff only inside untrusted blocks with the review threat model; areas must be changed paths or their directories; text reduced to one line without fences; the publisher sanitises the block like other output and runs the secret scan; on public repositories auto becomes approval; the title is never changed by a review | describe.test.ts (agents), description.int.test.ts (publisher: forged markers and links, secret blocked, public approval), describe-smoke-v1, red team items 084–089 |
| PD2 | Overwriting text the author wrote | Only the text between the start and end marker is replaced; zero markers use the template placeholder or the chosen position; any other marker layout is skipped as malformed without writing; a deleted block is an opt-out for the pull request until @codelotse describe | describe.test.ts (shared-types: placeholder, top, bottom, replace, two starts, end before start, CRLF), description.int.test.ts ("skips malformed markers …", "stops after the author removed the block …") |
| PD3 | Lost author edit in the read-modify-write of the body | The body is read fresh right before the write and the head must still be the reviewed one. GitHub, GitLab and Gitea have no compare-and-swap for pull request bodies, so an author edit within that window can be lost; recorded as residual risk | description.int.test.ts ("skips … stale heads") |
| PD4 | Model text forges the end marker or our publication marker | The publisher replaces every <!-- in model text and escapes <, so neither marker can be written by the model; the block ends with the publication marker for reconciliation | description.int.test.ts ("neutralises forged markers …"), red team items 085 and 088 |
| PD5 | Editing the description starts a review loop | Our edit fires pull_request.edited, which updates the stored pull request; reviews are keyed by head SHA, so no review starts | route.ts key per head SHA, review_runs unique per pull request and head |
Summary diagrams (configuration.md):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| DG1 | Injection through diagram labels | The model returns nodes and steps only; CodeLotse generates ids and keeps letters, digits and .,_/()- in labels (40 characters), drops the reserved word end and edges to unknown nodes | diagram.test.ts, red team items 090–093 |
| DG2 | Links, scripts or init directives in Mermaid (click, href, %%{init}, style, :::) | Not expressible by the builder; model text cannot open code fences in the summary or the description; the publisher renders a Mermaid fence only when every line matches the builder grammar and shows other Mermaid blocks as text; violations.ts checks every Mermaid line | sanitize.test.ts ("renders only generated diagrams as Mermaid …"), pipeline.test.ts, redteam.test.ts |
| DG3 | Rendering cost on the code host | At most 10 nodes, 15 steps and 2,000 characters; only when the policy asks for it and the host renders Mermaid | diagram.test.ts ("caps labels, nodes, edges and size") |
Prompts for coding agents (configuration.md, commands.md):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| AH1 | Prompt injection into the user's local coding agent through finding text produced from untrusted pull request content | Fixed framing header (untrusted content, verify first, run no commands, open no links); finding text between fixed BEGIN/END FINDINGS delimiters whose spoofed copies are replaced; URLs, <!--, control, format (bidi, zero-width, tag) and variation-selector characters removed; every agent only pre-fills the prompt and the user confirms it; CodeLotse runs nothing | agent-prompt.test.ts (shared-types); red-team items 094–101 |
| AH2 | Private code and finding text sent to cursor.com through the https://cursor.com/link/prompt query when a comment link is clicked | The web link is added to code-host comments on public repositories only (repository visibility from the run context, unknown values count as private); the web interface uses only local schemes (cursor://, claude-cli://, vscode://, codex://) | render.test.ts ("links the Cursor web prompt only on public repositories"), publisher.int.test.ts ("adds agent prompts to comments and links Cursor only on public repositories") |
| AH3 | Forged finding markers or a fence breakout from the prompt block into the comment | Code fence one backtick longer than the longest run in the prompt; <!-- replaced in the prompt; the block is placed before the transparency label and the real finding marker, which stays the last marker; model-written deep links (claude-cli://, codex:// and any other scheme) are treated as foreign links by the sanitiser | render.test.ts ("adds a collapsed prompt to each comment before the label and marker"), sanitize.test.ts; red-team items 096, 097, 100 |
| AH4 | Oversized comments or links | At most 2,500 characters per finding and 8,000 per prompt (at most 20 findings); a link is left out when it exceeds its limit (Claude Code 5,000 characters of prompt, other links 10,000 encoded characters); the secret scan covers the prompt and the decoded Cursor link | agent-prompt.test.ts, publisher.int.test.ts ("blocks secrets that appear only in the agent prompt"), redteam.test.ts (decoded Cursor links) |
Code index (ADR-0017, code-index.md):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| CX1 | Stored source code leaks across tenants or outlives the repository | Six tenant tables with forced RLS and withTenant; foreign keys with cascade on repository removal; tenant deletion walks all tenant tables; the hourly code_index maintenance purges snapshots and blobs when the mode is off; chunk content is plaintext (full-text search needs it) and is protected at rest by volume or disk encryption, which the hosted edition requires | rls.int.test.ts (all six tables), code-index.int.test.ts (worker: "keeps index rows inside the tenant and removes them with the repository", maintenance purge) |
| CX2 | Malicious repository content: huge files, many files, parser bombs | Work tree capped at 512 MiB; at most 50 000 files per repository and 256 KiB per file (instance settings); binary blobs are stored without chunks; 1 s parse deadline per file and a 10-minute budget per build (time_budget); the WASM parser runs through guarded and falls back to line windows; 4 000 characters per chunk; storage cap per tenant (storage_cap) | chunk.test.ts, files.test.ts, code-index.int.test.ts ("stops at the storage cap", "caps the number of files") |
| CX3 | Code sent to an embeddings endpoint | Only in the hybrid mode, only when pgvector exists and only to the tenant's routed embeddings tier; there is no default model; calls go through @quavon/llm with the guarded fetch in the enterprise edition | code-index.int.test.ts ("indexes lexically when no embeddings model is routed", "stores embeddings in hybrid mode …") |
| CX4 | Cost of embeddings | Calls are recorded with the feature index in llm_calls and admitted by the tenant budget guard; BudgetExceededError stops embedding and keeps the snapshot; at most 20 000 chunks per run; unchanged blobs are never embedded again | code-index.int.test.ts ("… caps them per run") |
| CX5 | Path traversal or symlinks in the indexed tree | Paths from git ls-tree must pass isSafeRelativePath and must not point into .git; symlinks (mode 120000) and submodules are skipped; files are read with readContained (O_NOFOLLOW, inside the work tree) | files.test.ts ("… skips what is not indexed"), workspace.test.ts |
Cross-repository context (ADR-0017):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| XR1 | A review receives code of another tenant | The resolver runs in a tenant transaction (RLS) and filters every query by tenant_id as well; links reference repositories through composite tenant foreign keys; linked code is read only from the targets' index snapshots inside withTenant | links.int.test.ts ("never resolves repositories or links of another tenant"), rls.int.test.ts (repository_links) |
| XR2 | Private code leaks into comments on a public repository | A target is used only when its visibility is not narrower than the source's: a public source gets public targets only, an internal source no private targets; unknown values count as public for the source and private for the target | links.test.ts, links.int.test.ts ("never gives a public source private or internal targets"), review.int.test.ts (worker: no [linked] block after the source turns public), linked-smoke.test.ts and the clean item smoke-v1/033 |
| XR3 | A pull request or a repository author links code the app should not use | .quavon/config.yml is read at the base commit, so a pull request cannot add links; config names resolve only within the same installation (other_installation otherwise); links across installations and pattern repositories need an admin link; targets of suspended installations are skipped | links.int.test.ts ("reports every status …", "prefers the repository of the same installation …") |
| XR4 | Instructions planted in linked code | Linked snippets and usages are rendered inside the untrusted repository context block like every other context; the finder prompt says linked code is never part of the diff; output sanitising and the secret scan apply unchanged | red-team items 106–109 (linked_repo) |
| XR5 | Cost and prompt size | At most 5 linked and 3 pattern repositories per review (over_limit); linked context takes at most 25 % of the context budget; no extra embedding call (the review's query vector is reused, semantic search only for targets indexed with the same model) | links.test.ts ("caps linked and pattern repositories separately"), linked.test.ts ("keeps linked context within its share of the budget") |
MCP server (ADR-0018, mcp-server.md):
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| MS1 | Access token leak or theft | 256-bit random tokens with the clt_ prefix, which gitleaks, findSecrets and redact detect; only the SHA-256 hash is stored; the secret is shown once and the list shows the last four characters; expiry 1–90 days; revocable by the owner and tenant admins; accepted only in the Authorization header, never in the query string; never logged (Caddy redacts credentials in access logs, the API logs no request headers) | tokens.test.ts, mcp-tokens.int.test.ts ("shows the secret once and stores only its hash and hint"), mcp.int.test.ts ("… never logs the token"), secrets.test.ts (tooling) |
| MS2 | Cookie CSRF or DNS rebinding against /mcp | Bearer routes ignore cookies and never accept a session principal; an Origin header must equal PUBLIC_URL; cookie routes never accept bearer principals | contract.test.ts ("bearer routes"), mcp.int.test.ts ("rejects missing, revoked, expired …") |
| MS3 | Privilege beyond read | Six read-only tools, no write path, no outbound calls; bearer routes may only declare authenticated and check repo:view per call; repository roles capped at reader; one tenant per token; tenant switch off by default; break-glass cannot create tokens; instance admin is never carried over | contract.test.ts, mcp.int.test.ts ("binds grants to the token tenant and caps repository roles at reader"), mcp-tokens.int.test.ts ("refuses break-glass sessions") |
| MS4 | Stale grants after offboarding | GitHub users: no refresh without a session; the cached membership must be younger than 24 h (MCP_TOKEN_GRANT_MAX_AGE_MS), otherwise 401 with "sign in to CodeLotse to refresh access". OIDC users: grants computed per request from current mappings and the provider state; the group snapshot is refreshed at each sign-in. Deleting the user deletes the tokens | mcp.int.test.ts ("stops serving a GitHub token …", "deletes tokens with their user"), oidc-login.int.test.ts ("refreshes the group snapshot at sign-in …") |
| MS5 | Cross-tenant access or IDOR through tool arguments | withTenant on every query; each repository-scoped call resolves the repository inside the token tenant and checks repo:view; unknown and forbidden answer the same "Not found."; finding ids must belong to the repository; token routes in the IDOR fuzz | mcp.int.test.ts ("… answers not found for forbidden repositories"), idor.int.test.ts, routes.int.test.ts |
| MS6 | CodeLotse carries prompt injection into IDE agents | Every tool result is JSON inside an untrusted block (random boundary, invisible characters stripped) after redact; server instructions and the fix prompt frame findings as data to verify, not instructions | mcp.int.test.ts (untrusted wrapper, redacted key) |
| MS7 | Denial of service | Per-token limit of 120 requests a minute and 600 per IP; results capped at 32 KiB, findings at 50 per search and 100 per review, code hits at 20; 64 KiB request bodies; stateless JSON responses | mcp.int.test.ts ("… rate-limits per token …"), rate-limit.ts |
MCP servers as review context (mcp-context.md, ADR-0019); awaiting sign-off, MCP_CONTEXT_SIGNED_OFF is false:
| # | Threat | Mitigation | Test / control |
|---|---|---|---|
| MC | SSRF through server URLs, credential misdirection, prompt injection and tool poisoning through MCP output, exfiltration through tool arguments, unwanted egress | Streamable HTTP only with the guarded fetch in every edition (no redirects, configured origin only, private hosts only through QUAVON_MCP_PRIVATE_HOSTS on self-hosted instances); encrypted credentials; SHA-256 pinning of admin-approved tools with flat schemas; finder-only tools, untrusted and redacted text results; secret and e-mail checks on arguments; call and byte budgets; never on public repositories; QUAVON_MCP_CONTEXT defaults to disabled | MC1–MC12 in mcp-context.md; mcp.test.ts, review-mcp.int.test.ts, mcp-servers.int.test.ts, red-team items 106–114 |
6. Open risks (tracked) ​
MCP server: GitHub memberships of token owners refresh only through a web sign-in, so an offboarded GitHub user keeps read access for up to 24 h (MS4). OIDC group changes reach tokens at the next sign-in; removed mappings and disabled providers apply at once. Tokens are bearer secrets: anyone holding one reads what its owner can read until it expires or is revoked.
MCP context (once signed off): findings on private repositories may paraphrase external context, servers may misreport tool annotations, and search terms derived from private pull requests reach the configured servers by design (MC6, MC7, MC5).
Code index: chunk content and its search vector are stored in plaintext. Protection at rest depends on volume or disk encryption of the PostgreSQL server (CX1).
Pull request descriptions: an author edit made between our fresh read and the write can be overwritten, because the supported hosts offer no compare-and-swap on pull request bodies (PD3).
Rotated or deleted webhook secrets remain valid for up to 60 s per process.
IdP group changes take effect at the next sign-in (≤ 8 h), like GitHub role changes.
Analytics differencing across overlapping scopes (tenant minus team, team A minus team B) is mitigated by per-point k and administrator-only scopes, not eliminated.
gVisor on the production host runs with the
systrapplatform without KVM; the sandbox boundary is a user-space kernel, not a virtual machine.No independent penetration test yet.
The OWASP LLM Top 10 2026 numbering is unverified (section 4).
Audit-log retention and pseudonymisation on erasure requests await counsel.
LLM budget checks are advisory under concurrency: calls already in flight can overshoot a cap by at most their own cost, bounded by worker concurrency. Reservation-based admission is still open. Plan limits are checked at run start, so a month can overshoot a plan limit by the runs in flight.
Secret redaction in logs is pattern- and entropy-based; short or low-entropy secrets can still pass, and model-visible text is not entropy-redacted.
A compromised model can quote parts of its system prompt in published outputs (no secrets or tenant data in prompts); (red team F2).
CI failure analysis: secrets in CI logs are redacted by pattern only; a secret without a known pattern can reach the model provider. Self-hosted instances download job logs from wherever GitHub redirects, including internal storage of a GHES host.
GitLab, Azure DevOps, Bitbucket, Gitea and Forgejo deliveries that are missed are not reconciled; the next push triggers a fresh review. GitLab draft notes belong to the token user, so concurrent or crashed runs can publish leftover drafts together.
Jira Data Center deliveries without
X-Atlassian-Webhook-Identifierare deduplicated by body hash. Atlassian code exchange and Entra token requests are not budget-gated. Tracker webhook secrets are decrypted per request.The self-hosted edition can point code-host and tracker connections at internal hosts (by design, like LLM endpoints). The same applies to generic notification webhooks.
Notifications: the recipients of an e-mail channel see each other; the signing secret is shown once and anyone who copies it can sign requests until it is rotated; a delivery whose target accepted the request but whose status update failed is sent again on retry (receivers de-duplicate by
X-CodeLotse-Delivery).Prompts for coding agents: a user can still accept a malicious instruction that survives the framing in their agent. Claude Code resolves
repoonly from clones it has seen, otherwise it opens in the current directory.Reviews without deterministic findings still end as
failedafter the last attempt while the model endpoint is down; the next push reviews again.Bitbucket Cloud and Data Center are verified against fixtures built from the API documentation only (no accounts; Data Center needs a licence).
Release signing and provenance have not run yet: the registry and the signing identity are open decisions, so
release.ymlpublishes nothing.Hosted edition: mandate revocation, the retention purge and grace reminders run in the hourly billing cycle, so each can lag its trigger by up to an hour. If the commit fails after the mail server accepted a reminder, the reminder is sent again in the next cycle.
Hosted edition: the default invoice retention of 8 years is pending confirmation by Quavon's tax adviser. The purge deletes only Quavon's copies; Mollie keeps its own invoice records.
Hosted edition: cancelling a deletion keeps the data but not the model keys and connection secrets, which are deleted when the deletion is requested.
Hosted edition: per-tenant model-key KEKs are derived, not stored, so deleting a tenant does not crypto-shred its blobs; deletion relies on the hard delete and backup expiry. Restoring a backup must re-run tenant deletions.
Hosted edition: the status webhook cannot report a Valkey outage (the monitor runs on the job queue); the pull endpoint covers it.
Mollie and VIES are verified against fixtures built from their documentation only; reverse-charge fields in Mollie sales invoices are unconfirmed (see the VAT treatment page of the hosted edition's billing documentation, not part of this repository).