Skip to content
CommonLayer
Documentation menu
On this page

Threat model

This threat model lists what CommonLayer protects, where trust changes hands, the threats at each boundary (STRIDE), and for each threat the mitigation that exists in code, with a file reference. Threats without a mitigation in code are listed as open items rather than described as handled. It reflects the implementation after the hardening work of November 2026 (WP-70a, WP-70b, WP-70g) and the independent security review of 2026-11-10, whose 19 findings were all fixed; rows that came from the review name the finding. Every file path cited below was checked to exist (WP-70h); where a mitigation has only been exercised by tests and not against the real service (GitHub, Sigstore, Stripe), the row says so or the implementation status does.

Assets

AssetWhy it matters
Promoted artifacts and their tags in the CommonLayer registryConsumers run them; tampering is a supply-chain attack on every user
The promotion decision (verification evidence, policy results)It is what makes an artifact trustworthy
The ledger and treasuriesMoney held for projects and owed to people
Payout capability (provider credentials, payout runs)Moves real money out
Accounts, sessions, API tokensIdentity and authority of every actor
Role bindings and assignmentsDecide who may approve, publish and be paid
Private security reports and embargoed findingsUndisclosed vulnerabilities
Personal data (emails, billing profiles, payout references, tax status)Privacy obligations
Audit logAccountability and tamper evidence
Secrets: SecretBox key, registry token signing key, promoter password, webhook secrets, provider keysCompromise grants the authority above

Trust boundaries

flowchart LR
  subgraph Internet
    U[Browsers, API clients]
    CI[Project CI]
    GH[GitHub webhooks]
    PP[Payment provider webhooks]
    PULL[docker / helm]
  end
  subgraph Platform
    API[api]
    WK[worker]
    REG[registry]
  end
  subgraph Data
    DB[(PostgreSQL)]
    S3[(Object storage)]
  end
  SRC[Source registries: GHCR]
  EXT[User-supplied URLs, feeds, OIDC providers]
  U -- B1 --> API
  CI -- B2 --> API
  GH -- B4 --> API
  PP -- B4 --> API
  PULL -- B6 --> REG
  REG -- B6 --> API
  WK -- B3 --> SRC
  WK -- B5 --> EXT
  API -- B7 --> DB
  WK -- B7 --> DB & S3
BoundaryBetween
B1Clients on the internet and the API (sessions, tokens, public reads)
B2Project CI and the submission endpoint
B3Untrusted artifact content (manifests, layers, signatures, attestations, SBOMs, charts, scanner output) and the worker
B4Inbound webhooks (GitHub, payment providers, registry notifications) and the API
B5The platform and outbound destinations it does not control (webhook endpoints, repository files, OIDC providers, vulnerability feeds)
B6Registry clients, the registry and its token realm
B7The application and its data stores, secrets and operators
B8The software supply chain of the platform itself (dependencies, tools, CI)
B9Roles inside the platform (maintainers, project admins, organization admins, platform admins)

Threats and mitigations

Paths are relative to the repository root.

B1 · Clients and the API

STRIDEThreatMitigation in code
SCredential stuffing, password guessingArgon2id with configured parameters (crates/commonlayer-auth/src/domain/password.rs); per-account and per-IP login limits and exponential lockout (crates/commonlayer-auth/src/domain/lockout.rs, src/adapters/rate_limit.rs); identical answers and timing for unknown accounts (src/service/account.rs)
SSession theftOpaque 32-byte tokens, only SHA-256 stored; __Host- cookie with Secure, HttpOnly, SameSite=Lax; idle and absolute expiry; rotation on login and privilege change; revocation on password reset, suspension and role change (crates/commonlayer-auth/src/service/mod.rs, src/http/cookies.rs, src/service/authenticate.rs)
SAPI token theftTokens stored hashed, shown once, optional expiry, scope intersection and project/organization restriction; never-by-token permissions for ledger adjustment, payout approval, user management, settings and credential changes (crates/commonlayer-core/src/authz/token.rs, src/authz/permissions.rs)
SAccount takeover through OAuth/OIDC linkingState, PKCE (S256) and nonce stored hashed and single-use; linking to an existing account only with an active session or a provider-verified email equal to a verified account email; an unverified account with that email blocks (crates/commonlayer-auth/src/service/oauth.rs, src/adapters/oauth.rs)
TCross-site request forgeryDouble-submit CSRF token derived from the session secret plus Origin/Referer allowlist (crates/commonlayer-auth/src/http/middleware.rs)
TScript injection in the portalNonce-based CSP with strict-dynamic (apps/web/src/proxy.ts, apps/web/src/lib/security/headers.ts); Markdown rendered without raw HTML and sanitised (apps/web/src/lib/markdown/); API responses carry a deny-all CSP and nosniff (crates/commonlayer-httpkit/src/headers.rs)
RDenial of a sensitive actionEvery state-changing service method writes an audit entry in the same transaction; denials of sensitive permissions are audited (crates/commonlayer-audit/src/recorder.rs)
IAccount enumerationRegistration, resend, reset and login answer identically for unknown emails (crates/commonlayer-auth/src/service/account.rs, test accounts::responses_do_not_reveal_whether_an_email_exists). Usernames are public by design
IExistence of private resourcesServices answer 404 instead of 403 for non-public projects, private reports, embargoed findings and drafts; anonymous requests to non-public operations get 401 before body parsing (crates/commonlayer-app/src/http.rs)
ILeaks through public readsPublic reads are separate *_public methods returning public projection types; see privacy rules
DRequest floodsBody limit (1 MiB default), 30 s timeout (crates/commonlayer-app/src/http.rs); global per-principal and per-IP limits (crates/commonlayer-auth/src/http/middleware.rs)
S, DA client chooses its own rate-limit bucket or audit address with a forged X-Forwarded-ForThe API honours the header only when the direct peer is in server.trusted_proxies (default: none) and takes the right-most entry that is not a trusted proxy (client_ip in crates/commonlayer-auth/src/http/middleware.rs; tests rate_limits::client_ip_trusts_forwarded_for_only_from_trusted_proxies). The portal forwards a visitor's address on its server-side calls only when declared to run behind a trusted proxy (BEHIND_TRUSTED_PROXY, apps/web/src/lib/api/forwarding.ts; chart value web.behindTrustedProxy); Compose trusts no proxy by default. A forged header reaching the API through the portal's /api rewrite was found and fixed in WP-70g and re-checked against the stack. Residual: a deployment that trusts the portal without an ingress that appends the real address lets clients choose their bucket (ADR-0040)
DRate limiting disappears during a Valkey outage (former O3)A failed Valkey hit is decided by the per-process in-memory limiter instead of allowing the request; every fallback counts in commonlayer_rate_limiter_fallbacks_total{bucket} and /readyz shows rate_limiter as degraded (crates/commonlayer-auth/src/adapters/rate_limit.rs FallbackRateLimiter, crates/commonlayer-app/src/context.rs; test rate_limits::a_valkey_outage_falls_back_to_per_process_limits). During an outage N replicas allow up to N× a limit
SLogin CSRF / forced login into the attacker's account through a forwarded OAuth callback (security review HIGH-6)The state is bound to the starting browser: a random value in a short-lived HttpOnly, SameSite=Lax cookie (__Host-cl_oauth) is part of the associated data of the sealed PKCE verifier, required at the callback and cleared (crates/commonlayer-auth/src/service/oauth.rs, src/http/handlers.rs, src/http/cookies.rs; test oauth::a_callback_is_accepted_only_in_the_browser_that_started_the_login)
EPrivilege escalation through role APIsOne permission matrix checked in services (crates/commonlayer-core/src/authz/policy.rs); last organization admin protected by service and deferred trigger (0003_organizations.sql); admins cannot change their own role or status (crates/commonlayer-auth/src/service/admin.rs); service accounts can never hold a platform role (CHECK in 0002_identity.sql); authorization tests per crate (tests/it/authz.rs)

B2 · Project CI and submissions

STRIDEThreatMitigation in code
SForged CI identityGitHub Actions OIDC verified against GitHub's JWKS: RS256 only, issuer, audience, expiry and not-before with leeway, forced JWKS refresh at most every 30 s (crates/commonlayer-github/src/adapters/oidc.rs); the token's repository must be a verified repository of the project
EA compromised CI token publishes malicious contentA submission names a digest, not a tag; it must come from the source repository in the manifest; the whole verification policy runs (signature by the registered identity, attestations, scan gate) before anything is promoted (crates/commonlayer-artifacts/src/domain/policy.rs, src/service/pipeline.rs). The workload principal holds only project.artifacts.submit on one project
DSubmission floodsPer-project submission limit (crates/commonlayer-artifacts/src/service/submissions.rs, artifacts.submission_rate_limited) and the request-level buckets below
DAny account exhausts a project's submission bucket and blocks its CI (security review MEDIUM-3)submissions_per_project counts only attempts by actors allowed to submit, keyed by the resolved project id; other attempts count per principal in submissions_denied (REST and gRPC, crates/commonlayer-app/src/workload.rs submission_rate_limit; test hardening::unrelated_accounts_cannot_exhaust_a_projects_submission_bucket)
SA squatted numeric GitHub id hijacks or blocks another project's CI login (security review MEDIUM-5)Verification asks GitHub for the numeric id's owner/name and refuses a mismatch (projects.repository_id_mismatch); ids are unique among verified links only; the Actions OIDC login also requires the token's owner/name to equal the link's (crates/commonlayer-projects/src/service/repositories.rs, src/adapters/mod.rs, 0016_repository_ids.sql, crates/commonlayer-app/src/workload.rs; tests repositories::numeric_github_ids_are_checked_and_cannot_be_squatted, seams::actions_oidc_bearers_become_workload_actors). Links verified before this change are not re-checked

B3 · Untrusted artifact content

STRIDEThreatMitigation in code
TContent substituted under a digestFetched manifests and blobs are re-hashed and compared; uploads to the destination are hashed while streaming and never committed on mismatch; the destination is re-verified before promotion (crates/commonlayer-artifacts/src/service/pipeline.rs, crates/commonlayer-registry/src/adapters/oci.rs, src/adapters/mirror.rs)
TImmutable release tags movedDatabase trigger refuses moving or deleting <version>-r<revision> tags; every move is recorded by trigger (0006_artifacts.sql)
SSignature by the wrong identitycosign verifies against the manifest's issuer and identity; the platform re-checks the identity; foreign signatures fail signature.identity; signature checks are never waivable (crates/commonlayer-artifacts/src/adapters/cosign.rs, src/domain/naming.rs, src/domain/policy.rs)
T, DHostile SBOMs, charts, attestationsSize, count and field-length caps (crates/commonlayer-artifacts/src/domain/sbom.rs: 32 MiB, 50 000 components; src/domain/chart.rs: 10 MiB package, 50 MiB unpacked, 5 000 entries); strict parsing; nothing is executed
T, D, EHostile scanner or cosign behaviour or outputSubprocesses without a shell, with argument vectors, cleared environment, fresh temporary directory, timeout and bounded output; strict JSON parsing; report size cap (crates/commonlayer-security/src/adapters/process.rs, crates/commonlayer-artifacts/src/adapters/cosign.rs). The parsers and the subprocess adapter were outside the scope of the security review
IA clean scan hides vulnerabilities in software built from source (former O13, in part)Projects declare source-built components in the manifest (build.components) and images ship declarations for CommonLayer's own helpers; generate-sbom.sh adds them to both SBOMs with CPEs and purls, and scan.sh scans the image and the SBOM, both gating (ADR-0039, templates/project-template/scripts/generate-sbom.sh, templates/project-template/scripts/scan.sh). Only Grype matches the declared components (by CPE); Trivy evaluates none of them. Known-vulnerable declarations were shown to fail the gate (WP-70f, locally)
IAn embargoed vulnerability id appears on a public digest page through a scan-gate message (security review MEDIUM-7)The public digest projection keeps only the count of blocking findings (crates/commonlayer-artifacts/src/service/catalog.rs; test hardening::the_public_digest_page_does_not_name_blocking_findings)
TA requested tag shaped like another version's release tag stays mutable (MEDIUM-1); a resumed submission promotes on a stale evaluation (MEDIUM-2)Release-shaped tags other than the submission's own are refused (artifacts.reserved_tag); resuming a failed submission re-runs scan, evaluation, mirroring and promotion (crates/commonlayer-artifacts/src/service/submissions.rs; tests in crates/commonlayer-artifacts/tests/it/hardening.rs)
IRaw reports served as active contentDownloads use Content-Disposition: attachment and a fixed JSON content type (crates/commonlayer-artifacts/src/http/mod.rs)

B4 · Inbound webhooks

STRIDEThreatMitigation in code
SForged GitHub deliveryHMAC-SHA256 over the raw body verified in constant time before parsing (crates/commonlayer-github/src/domain/signature.rs)
SForged payment eventStripe t=,v1= signature with 300 s tolerance over the raw body; the simulator uses the same scheme and is refused in production (crates/commonlayer-funding/src/adapters/signature.rs, src/adapters/stripe.rs, src/adapters/simulated.rs); a contribution is confirmed only by a verified event
SForged registry notificationsBearer secret, required in production (crates/commonlayer-registry/src/http/mod.rs, crates/commonlayer-config/src/sections/registry.rs)
TReplay and duplicate deliveryUnique (provider, event_id) and GitHub delivery id; duplicates acknowledged and counted, never applied twice; every ledger effect keyed; refund and dispute references applied once even when they post nothing (LOW-4, funding_provider_withdrawals); a bank reference confirms one contribution only (LOW-3) (0010_funding.sql, 0011_github.sql, 0020_funding_hardening.sql, crates/commonlayer-funding/src/service/webhooks.rs)
TRefunds and disputes of subscription payments never reach the ledger (HIGH-3)invoice_payment.paid links the invoice payment to its PaymentIntent and charge (funding_payment_aliases); unmatched pi_/ch_ references are resolved through Stripe's invoice-payments list (crates/commonlayer-funding/src/adapters/stripe.rs, src/service/webhooks.rs; tests in crates/commonlayer-funding/tests/it/stripe.rs). Tested against a mock built from Stripe's reference only
DOversized payloadsPer-router body limits (payments.webhook_max_bytes 1 MiB, github.webhook_max_bytes 25 MiB, registry events 1 MiB); processing is asynchronous

B5 · Outbound requests

STRIDEThreatMitigation in code
I, ESSRF through webhook endpoint URLs, manifest URLs or OIDC provider URLsSafeClient: own DNS resolution, refusal if any address is loopback, private, link-local, CGNAT, multicast or metadata (IPv4 and IPv6, mapped addresses), connection pinned to the vetted address, no redirects, no credentials in URLs, HTTPS in production, time and size caps, environment proxies ignored (crates/commonlayer-net/src/lib.rs, src/ranges.rs); webhook URLs validated at creation and at send time (crates/commonlayer-notifications/src/service/endpoints.rs)
IManifest sync used to reach other hostsFiles are fetched only from the registered repository's host, every URL component validated, only from a verified primary repository (crates/commonlayer-projects/src/adapters/mod.rs)
ILeaking secrets in notificationsTransactional tokens only in the mail body, as a URL fragment; embargoed findings, private reports and restricted events never go to webhooks or GitHub issues (crates/commonlayer-notifications/src/domain/catalog.rs)

B6 · Registry and token realm

STRIDEThreatMitigation in code
SRegistry token forgeryES256 tokens signed with the platform key, 5-minute lifetime, kid thumbprint, expiry and not-before checked against the platform clock; tampered tokens refused by the real registry in tests (crates/commonlayer-registry/src/service/token.rs)
EPushing to the trusted registrypush and delete are granted only to the promoter identity; anonymous callers get pull on public repositories only (crates/commonlayer-registry/src/domain/access.rs). The worker signs its own push tokens with the token key; the API holds the same key (it is the token realm) and checks the promoter password at the realm, so a compromise of either process can mint push tokens (O7). Verified with a real Distribution in the publish drill: an anonymous token request for pull,push was granted pull only
DRegistry token endpoint floods (former O4)Per-IP bucket registry_token (300/min) applied on /registry/token (crates/commonlayer-app/src/workload.rs; test seams::the_registry_token_realm_is_rate_limited_per_ip)
IPulling non-public artifactsPull scope only for repositories of public projects (crates/commonlayer-registry/src/domain/access.rs)

B7 · Data stores, secrets, operators

STRIDEThreatMitigation in code
TLedger tampering through the application or an ordinary database clientAppend-only triggers, balance trigger with non-negative internal accounts, guard on ledger_balances, unique idempotency keys (0010_funding.sql); only ledger.rs writes ledger tables (crates/commonlayer-funding/src/ledger.rs); hourly reconciliation (src/service/reconciliation.rs)
RAudit log rewrittenAppend-only trigger; hash-chain checkpoints verified by clctl audit verify (crates/commonlayer-audit/src/checkpoint.rs)
T, RThe application's own database credentials switch the ledger and audit protections off (former O1/O2, in part)The processes connect as a runtime role that owns nothing; migrations run with database.migration_url, then clctl migrate grants the runtime role exactly its DML: append-only tables INSERT only, ledger_balances and artifact_tag_history written only by SECURITY DEFINER triggers, never TRUNCATE/TRIGGER/REFERENCES/CREATE/ownership (crates/commonlayer-database/src/roles.rs, migrations/0014_runtime_role.sql). clctl db check-role fails unless least privilege (apps/cli/src/ops.rs); every process warns at start-up outside development, sets commonlayer_db_privileged_connection and reports database_role in /readyz (crates/commonlayer-app/src/db_role.rs). Tests: crates/commonlayer-database/tests/it/roles.rs; the acceptance workflows and the seed run as the runtime role (crates/commonlayer-app/tests/it/world.rs)
IKey rotation of SecretBox (former O10)Sealed values carry a key id (v2 format, header authenticated); decryption tries the key the id names, and v1 values (no id) are tried with the current and every secrets.previous_encryption_keys key; clctl secrets rotate re-seals webhook secrets and private-report bodies and messages under the current key, idempotently and audited (crates/commonlayer-core/src/crypto.rs, crates/commonlayer-app/src/secrets.rs, crates/commonlayer-security/src/service/rotation.rs, crates/commonlayer-notifications/src/service/rotation.rs; test crates/commonlayer-app/tests/it/hardening.rs)
IOne process's compromise reveals every integration secret (former O7, in part)Each binary validates and keeps only the secrets it uses (ProcessKind, Settings::for_process in crates/commonlayer-config/src/lib.rs, applied in AppContext::build): the scheduler holds none, the worker no inbound webhook secrets and no promoter password, the API no NVD key and no SMTP password; per-process matrix in docs/status/WP-70a.md
IPrivate reports and webhook secrets read from a database dumpEncrypted with SecretBox (AES-256-GCM, associated data binds the row) (crates/commonlayer-core/src/crypto.rs, crates/commonlayer-security/src/service/reports.rs, crates/commonlayer-notifications/src/service/endpoints.rs)
ISecrets in logs or audit diffsSecret types print as ***; logging redaction and audit diff denylist (crates/commonlayer-observability/src/logging.rs, crates/commonlayer-core/src/redaction.rs, crates/commonlayer-audit/src/recorder.rs); Settings has a redacting Debug
TUnsafe production configurationValidation refuses to start with the simulated provider (also in staging, security review LOW-6), a session cookie without the __Host- prefix outside development and test (LOW-5), http URLs, no cache, no encryption key, no registry secrets, insecure registries or migrate-on-start in production (crates/commonlayer-config/src/sections/*.rs; tests in crates/commonlayer-config/src/tests.rs)

B8 · Supply chain of the platform

STRIDEThreatMitigation in code
TCompromised build tools for imagesTools installed with pinned versions and SHA-256 checksums (scripts/dev/install-tools.sh); base images pinned by digest; upstream tarballs verified by SHA-256 and, for nginx, OpenPGP (templates/project-template/image/build/fetch-source.sh)
TCompromised GitHub Actions in project workflowsActions pinned to full commit SHAs, permissions: {} at the top, least privilege per job (templates/project-template/.github/workflows/)
TCompromised Rust or npm dependency (former O9)Lockfiles committed; cargo deny check (licenses, bans, sources, advisories over the build graph; deny.toml) and cargo audit --deny warnings (lockfile; one justified, dated ignore in .cargo/audit.toml) run in CI and have passed on every push since commit 7ce0cb7 (.github/workflows/ci.yml, .github/workflows/security.yml), as have pnpm audit and gitleaks. OpenSSF Scorecard and dependency review are skipped while the repository is private, and code scanning results are kept as workflow artifacts because code scanning is not enabled (ADR-0044). The platform images' SBOMs list no Rust crate (O14)

B9 · Roles inside the platform

STRIDEThreatMitigation in code
EA malicious maintainer publishes unverified softwareNo user, maintainer or CI token can push to the registry; content arrives only through the verification pipeline (crates/commonlayer-registry/src/domain/access.rs)
EA maintainer pays themselvesMaintainers cannot propose or approve rewards for themselves, cannot approve payout runs, cannot override their own eligibility or resolve their own appeal; payout runs need two platform admins (crates/commonlayer-funding/src/domain/reward.rs, 0010_funding.sql, 0009_maintenance.sql)
EOne maintainer funds rewards alone, repeatedly, for an account they control (security review HIGH-1)The proposer never counts as an approver, for every reward (service and trigger); a contribution carries at most one open or funded reward (unique index) (crates/commonlayer-funding/src/domain/reward.rs, src/service/rewards.rs, 0020_funding_hardening.sql; test hardening::a_proposer_never_approves_and_a_contribution_is_rewarded_once) (ADR-0045)
T, EA payout is made twice after an ambiguous provider error (HIGH-4), or to a destination changed after approval (LOW-2)Provider key per obligation and attempt; ambiguous outcomes stay unknown with the obligation scheduled until an administrator resolves them in a session; the destination is fixed at planning and shown to the approver (crates/commonlayer-funding/src/service/payouts.rs; tests in crates/commonlayer-funding/tests/it/hardening.rs) (ADR-0041). Against the simulator and a mock only
E, IToken scopes and project restrictions bypassed on ownership paths (HIGH-5)Ownership paths (own contributions, refunds, subscription cancellation, own private reports and appeals) require user.self.read/user.self.manage on the own scope, so a token's scopes apply (crates/commonlayer-funding/src/service/contributions.rs, crates/commonlayer-security/src/service/reports.rs; tests hardening::ownership_paths_honour_token_scopes_and_restrictions, a_reporters_token_scopes_limit_access_to_their_private_report)
TRefunds checked against balances they do not reserve (MEDIUM-4); settlement applies stale eligibility (MEDIUM-6)Pending refunds hold what they will take (funding_refund_holds, under the finance lock, released on confirmation or failure, lapse after 7 days); settlement takes the finance lock before reading occupancy and eligibility (crates/commonlayer-funding/src/service/contributions.rs, src/service/cycles.rs; tests stripe::pending_refunds_reserve_what_they_will_take, hardening::settlement_reads_eligibility_under_the_finance_lock)
EA maintainer approves their own security exceptionService check and CHECK (decided_by IS DISTINCT FROM requested_by) (0008_security.sql)
EOne project admin satisfies every two-person rule through service accounts they create (security review HIGH-2)Service accounts never hold the human-only permissions (Permission::HUMAN_ONLY in Actor::can, crates/commonlayer-core/src/authz/); deciding services require a person (Actor::require_human: exceptions, not_affected confirmations, contribution reviews, waivers; funding's reward approvals) and platform approvals an interactive session (Actor::require_human_session); only a person in a session creates service accounts (tests crates/commonlayer-core/tests/it/authz_matrix.rs, crates/commonlayer-security/tests/it/confirmations.rs, crates/commonlayer-maintenance/tests/it/human_decisions.rs, crates/commonlayer-auth/tests/it/tokens.rs)
ESelf-assessed not_affected closes a high-impact finding (former O5)A not_affected statement (manual or imported VEX) about a finding at or above security.not_affected_confirmation_min_severity (default high, runtime setting) is pending: no coverage, the finding stays open, the scan gate still blocks, it is not published, until a second person with triage rights confirms; never the author (service, audited denial, CHECK) (crates/commonlayer-security/src/service/confirmations.rs, 0015_assessment_confirmation.sql; tests crates/commonlayer-security/tests/it/confirmations.rs)
EA project admin redirects a replacement's moneyReplacement has no path to the ledger; a test asserts every balance and transaction is unchanged (crates/commonlayer-maintenance/tests/it/no_financial_effect.rs)
EA platform admin adjusts the ledger without traceAdjustments need an interactive session, a written justification of at least 20 characters, post against world/adjustments, and are audited (crates/commonlayer-funding/src/service/ledger_admin.rs)
EA platform admin pays out alonePayout run approval by a second platform admin, enforced by service and CHECK (0010_funding.sql)

B10 · Third-party repositories (plan 13, WP-80)

The GitHub App's Administration permission on maintainers' repositories is a significant trust: it could add or remove collaborators and change settings on repositories CommonLayer does not own.

STRIDEThreatMitigation in code
EThe App's Administration permission is used beyond managing appointed maintainersThe only Administration calls are four named methods of one trait (add_collaborator, remove_collaborator, cancel_invitation, listing collaborators/invitations; crates/commonlayer-github/src/adapters/host.rs), each audited (github.repository.access_*) with a metric. Reconciliation is a pure function over the appointments and the grants CommonLayer recorded (src/domain/reconcile.rs, exhaustive test over 48 combinations): a collaborator without a CommonLayer grant is never removed, the repository owner never (test access_is_reconciled_without_touching_people_commonlayer_did_not_add). No call changes repository settings
TCommonLayer writes to a maintainer's repositoryThe code path that talks to third-party repositories contains no push: a structural test reads the sources of the host, mirror and backup modules and fails on any push invocation (crates/commonlayer-github/tests/it/stewardship.rs the_third_party_code_path_contains_no_push). The only push is the stewardship-hosted restore into the operator's own organization, in its own adapter with its own credential (src/adapters/stewardship.rs), held by the worker only
IThe installation token leaks through gitgit runs with a cleared environment, HTTPS only (GIT_ALLOW_PROTOCOL), the remote validated to the exact GitHub host; the token travels as an http.extraHeader set through GIT_CONFIG_COUNT/GIT_CONFIG_VALUE_0, never in a URL or the argument vector, and is scrubbed from every error (src/adapters/subprocess.rs, src/adapters/git.rs). A test runs git through a wrapper that records argv and fails if the token appears in argv, the URL, an error, a log line or an audit diff (the_installation_token_never_reaches_argv_urls_or_errors)
SSomeone binds another person's installation to their account (a forged callback, installation_id in the query)The install redirect carries a signed, single-use state (stored hashed, 15 minutes); at the callback the installation is bound only after the OAuth code exchange and GitHub's list of the person's installations includes it; the query parameter alone is never trusted, and refusals are audited (src/service/onboarding.rs; test the_callback_binds_only_installations_github_lists_for_the_person). The picker lists only repositories on which GitHub reports the person as admin
DA hostile repository exhausts the worker during a backupSize watch and deadline on the mirror (github.backup_max_bytes, github.backup_timeout_secs), refs/pull/* not fetched, the bundle verified before upload (size_and_time_limits_stop_a_backup); onboarding scans read files through the API and never execute them; the parsers for Dockerfiles, workflows and chart values are tested on malformed input (crates/commonlayer-github/tests/it/rules.rs)
EA required guardrail is waived to get a project approvedWaivers need platform.projects.review and an interactive session (human only), a reason of at least 20 characters, apply to one failing required rule of a project in review, are audited and public (src/service/onboarding.rs waive_rule; crates/commonlayer-app/tests/it/stewardship_authz.rs)
E, SA succession hands the repository to someone the project did not appoint (a leaving owner transfers to any account that installs the App; a confirm without a transfer; a stranger pushes a public bundle into their own repository and has it verified) (WP-80 review F1)The case records the owner account at opening; transferred needs a different owner account and the successor rule: an active maintainer other than the leaving person is admin of the repository on GitHub through their linked identity (numeric account id compared), or the repository is in the configured stewardship organization; a restore additionally needs a new, unlinked, managed repository, all checked before anything is compared; webhook refusals keep the case open, store and audit the reason once (crates/commonlayer-projects/src/service/succession.rs, crates/commonlayer-github/src/service/stewardship.rs successors; tests stewardship_review::a_transfer_closes_the_case_only_into_the_hands_of_a_maintainer, a_restore_needs_a_new_unlinked_managed_repository_held_by_a_maintainer)
SA renamed GitHub login, taken by someone else, inherits a person's standing (onboarding admin check, invitations, successor rule) (review F5)Stored logins are hints: GitHub's permission answer must name the bound numeric account id, an invitation is sent only when GET /users/{login} still answers with the linked id, and removals name the login GitHub lists for the bound account now (crates/commonlayer-github/src/domain/access.rs admin_by_account, src/domain/reconcile.rs; tests stewardship::a_login_held_by_another_account_is_never_invited, domain::reconcile a_removal_uses_the_current_login_of_the_bound_account)
SThe installation callback binds an installation and then fails the identity link, leaving a binding the person was told failed (review F2)The identity link is decided before binding; auth.identity_taken or auth.github_identity_mismatch bind nothing and are audited as github.installation.bind_refused (crates/commonlayer-app/src/stewardship_http.rs; test stewardship_review::the_callback_binds_nothing_when_the_identity_is_refused)
DThe repository picker fans out to one GitHub call per repository of every installation inside a request (review F3)Paged (at most 50), permission checks only for the page and at most 8 at a time, per-person buckets on the picker and on every route that scans or queues a repository job (crates/commonlayer-app/src/workload.rs STEWARDSHIP_BUCKETS; test stewardship_review::the_repository_picker_is_paged_filtered_and_rate_limited)
EA project is approved without its repository ever being judged (review F4)Approval needs a finished scan of the primary repository while managed repositories are required (projects.repository_not_scanned; test stewardship_review::approval_fails_closed_without_a_finished_scan)
S, EThe development GitHub (anyone installs the App on any development account; codes are issued by the platform itself) reaches a real deployment (review F6)github.provider = "development" is refused by configuration validation outside development and test, its routes are mounted only with it, and the chart offers no key for it (crates/commonlayer-config/src/sections/github.rs; test the_development_github_is_refused_outside_development_and_test). Even there the callback's binding rules are the production ones (test workflow_h_dev_github: another person's development installation is refused)
EA succession moves money or is decided by automationSuccessions have no ledger path; the acceptance test asserts balances and the transaction count are unchanged (crates/commonlayer-app/tests/it/workflow_f_succession.rs). Confirming a transfer, planning and cancelling a restore require a person (require_human)

Per-process secrets: the stewardship token and git are the worker's only; the App's OAuth client secret is the API's only (callback). The App private key is held by the API and the worker: the API needs installation tokens for the onboarding picker (listing an installation's repositories and the person's permission), so the plan's "only the worker holds the App key" is not achieved (open item O16).

Open items

Threats for which no mitigation exists in code yet, or where the mitigation is partial. Each needs a decision or work before production. Closed since the first version of this model: O3 (rate limiting during a Valkey outage), O4 (registry token floods), O8 (OIDC submissions), O9 (dependency auditing) and O10 (key rotation); they are rows of the tables above now.

#ThreatCurrent stateSuggested direction
O1Ledger tampering by a database superuser or the owner role (disable triggers, rewrite rows)Partly mitigated (WP-70a, applied to Compose and the Helm chart by WP-70g): the application no longer holds owner rights (runtime role, see B7), so its own credentials cannot disable the triggers. A superuser or whoever holds the migration credentials still can. Reconciliation detects inconsistent changes but not consistent rewrites of entries and balances; there is no hash chain over the ledgerKeep the migration credentials out of the running deployment (deployment notes in docs/status/WP-70a.md); add a ledger checkpoint chain like the audit log's; export checkpoint digests outside the database
O2Audit log rewritten together with its checkpointsPartly mitigated (WP-70a): the application cannot alter, truncate or rewrite the audit tables (runtime role). A superuser or the owner role can still rewrite both, since checkpoints live in the same databasePublish or export checkpoint digests to an external, append-only location
O5Self-assessed not_affected below the confirmation thresholdHigh and critical statements need a second person (mitigated, see B9). Below security.not_affected_confirmation_min_severity one triager still decides; the statement is public and auditedLower the threshold if medium findings should need a second person
O6Small rewards through two colluding project membersSince HIGH-1 the proposer never approves and a contribution is rewarded once. Up to the single-approval limit (runtime setting funding.reward_single_approval_max_minor, default $100) one approval by another member still suffices, so two colluding members can reward a third account once per approved contribution from the flexible pool. Rewards are public with their rationaleAccept as residual risk, lower the limit, or add a per-recipient cap: a decision for the operating entity
O7Compromise of the worker (or the API)Partly mitigated (WP-70a): each process keeps only the secrets it uses (B7). The worker still holds the registry token signing key (it signs its own push tokens), payment provider keys and the SecretBox key, so a compromised worker can push to the registry; the API also holds the signing key (token realm) and the payment secret key (checkout, refunds and subscription changes are synchronous API calls). Consumers who verify signatures (cosign verify) detect unsigned or foreign contentNetwork isolation of the worker (WP-50); a separate promoter process holding the signing key; move provider calls of the API into worker jobs
O11Provenance verification of GitHub artifact attestationsThe template attests provenance with actions/attest-build-provenance and push-to-registry (stored through the referrers API); the pipeline discovers referrers through the tag schema and verifies attestations with cosign verify-attestation. The publish drill confirmed the path for cosign-made attestations with a key; the template's release workflow has never run, so GitHub's format has not been through the pipelineRun a real release and adjust discovery or verification
O12Platform images are not signed or attested by the means the platform requires of projectsThe release workflow signs and attests them; it has never runRun it once CL_BASE_IMAGE and arm64 are available
O13Vulnerabilities in source-built components (partly mitigated, see B3)Declared components are matched by Grype only; CommonLayer's own helpers (cl-probe, cl-kube-label) have no vulnerability feed; Valkey's vendored libraries surface only as Valkey CVEs; extensions in derived PostgreSQL images are not declared; NVD data trails upstream advisoriesKeep the "watch upstream advisories" duty in the project documents; consider a second matcher for declared components
O14Vulnerabilities in the platform's own imagesThe static Rust binaries carry no dependency metadata, so the image SBOMs and Trivy see no Rust crate (cargo-deny and cargo-audit cover them in CI). The worker image's cosign 3.1.4 carries golang.org/x/net 0.61.0 (CVE-2026-78661, high), go-chi/chi 5.3.2 and x/crypto 0.58.0 findingsBuild with cargo auditable; update cosign when a release with the fixes exists
O15Areas not covered by the security reviewRegistry retention and event ingestion, the SBOM/VEX/scanner parsers and subprocess adapter, outbound webhook sending, organization role APIs and the last-admin trigger, password-reset details (reset tokens issued before an email change stay valid), lockout and rate-limit internals, audit and log redaction coverage, the web CSP and development pages, gRPC parity for mutations, settlement roundingA follow-up review
O16The API holds the GitHub App private key (plan 13 §10 wanted it in the worker only)The onboarding picker and the callback's installation lookup run synchronously in the API with installation tokens; a compromised API can mint installation tokens and use Administration on every connected repositoryMove the picker into a worker job, or keep a short-lived, repository-limited token cache filled by the worker

Assumptions

  • TLS terminates at an ingress or proxy the operator controls, which appends the real client address; the API trusts X-Forwarded-For only from server.trusted_proxies, and the portal forwards it only when BEHIND_TRUSTED_PROXY is set.
  • The owner (migration) credentials of the database are kept out of the running deployment: only the migration job receives them.
  • The ops port (/metrics, /readyz) is reachable only from the monitoring network.
  • GitHub, Sigstore (Fulcio, Rekor) and the payment provider are trusted for what they attest; a compromise there is out of scope.
  • Repository protections (who may push tags, which workflows exist) are part of a project's trust: any workflow in the repository that runs on a matching tag can produce an accepted keyless signature (ADR-0026).