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
| Asset | Why it matters |
|---|---|
| Promoted artifacts and their tags in the CommonLayer registry | Consumers 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 treasuries | Money held for projects and owed to people |
| Payout capability (provider credentials, payout runs) | Moves real money out |
| Accounts, sessions, API tokens | Identity and authority of every actor |
| Role bindings and assignments | Decide who may approve, publish and be paid |
| Private security reports and embargoed findings | Undisclosed vulnerabilities |
| Personal data (emails, billing profiles, payout references, tax status) | Privacy obligations |
| Audit log | Accountability and tamper evidence |
Secrets: SecretBox key, registry token signing key, promoter password, webhook secrets, provider keys | Compromise 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
| Boundary | Between |
|---|---|
| B1 | Clients on the internet and the API (sessions, tokens, public reads) |
| B2 | Project CI and the submission endpoint |
| B3 | Untrusted artifact content (manifests, layers, signatures, attestations, SBOMs, charts, scanner output) and the worker |
| B4 | Inbound webhooks (GitHub, payment providers, registry notifications) and the API |
| B5 | The platform and outbound destinations it does not control (webhook endpoints, repository files, OIDC providers, vulnerability feeds) |
| B6 | Registry clients, the registry and its token realm |
| B7 | The application and its data stores, secrets and operators |
| B8 | The software supply chain of the platform itself (dependencies, tools, CI) |
| B9 | Roles 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
| STRIDE | Threat | Mitigation in code |
|---|---|---|
| S | Credential stuffing, password guessing | Argon2id 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) |
| S | Session theft | Opaque 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) |
| S | API token theft | Tokens 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) |
| S | Account takeover through OAuth/OIDC linking | State, 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) |
| T | Cross-site request forgery | Double-submit CSRF token derived from the session secret plus Origin/Referer allowlist (crates/commonlayer-auth/src/http/middleware.rs) |
| T | Script injection in the portal | Nonce-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) |
| R | Denial of a sensitive action | Every state-changing service method writes an audit entry in the same transaction; denials of sensitive permissions are audited (crates/commonlayer-audit/src/recorder.rs) |
| I | Account enumeration | Registration, 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 |
| I | Existence of private resources | Services 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) |
| I | Leaks through public reads | Public reads are separate *_public methods returning public projection types; see privacy rules |
| D | Request floods | Body 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, D | A client chooses its own rate-limit bucket or audit address with a forged X-Forwarded-For | The 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) |
| D | Rate 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 |
| S | Login 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) |
| E | Privilege escalation through role APIs | One 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
| STRIDE | Threat | Mitigation in code |
|---|---|---|
| S | Forged CI identity | GitHub 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 |
| E | A compromised CI token publishes malicious content | A 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 |
| D | Submission floods | Per-project submission limit (crates/commonlayer-artifacts/src/service/submissions.rs, artifacts.submission_rate_limited) and the request-level buckets below |
| D | Any 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) |
| S | A 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
| STRIDE | Threat | Mitigation in code |
|---|---|---|
| T | Content substituted under a digest | Fetched 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) |
| T | Immutable release tags moved | Database trigger refuses moving or deleting <version>-r<revision> tags; every move is recorded by trigger (0006_artifacts.sql) |
| S | Signature by the wrong identity | cosign 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, D | Hostile SBOMs, charts, attestations | Size, 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, E | Hostile scanner or cosign behaviour or output | Subprocesses 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 |
| I | A 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) |
| I | An 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) |
| T | A 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) |
| I | Raw reports served as active content | Downloads use Content-Disposition: attachment and a fixed JSON content type (crates/commonlayer-artifacts/src/http/mod.rs) |
B4 · Inbound webhooks
| STRIDE | Threat | Mitigation in code |
|---|---|---|
| S | Forged GitHub delivery | HMAC-SHA256 over the raw body verified in constant time before parsing (crates/commonlayer-github/src/domain/signature.rs) |
| S | Forged payment event | Stripe 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 |
| S | Forged registry notifications | Bearer secret, required in production (crates/commonlayer-registry/src/http/mod.rs, crates/commonlayer-config/src/sections/registry.rs) |
| T | Replay and duplicate delivery | Unique (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) |
| T | Refunds 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 |
| D | Oversized payloads | Per-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
| STRIDE | Threat | Mitigation in code |
|---|---|---|
| I, E | SSRF through webhook endpoint URLs, manifest URLs or OIDC provider URLs | SafeClient: 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) |
| I | Manifest sync used to reach other hosts | Files 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) |
| I | Leaking secrets in notifications | Transactional 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
| STRIDE | Threat | Mitigation in code |
|---|---|---|
| S | Registry token forgery | ES256 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) |
| E | Pushing to the trusted registry | push 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 |
| D | Registry 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) |
| I | Pulling non-public artifacts | Pull scope only for repositories of public projects (crates/commonlayer-registry/src/domain/access.rs) |
B7 · Data stores, secrets, operators
| STRIDE | Threat | Mitigation in code |
|---|---|---|
| T | Ledger tampering through the application or an ordinary database client | Append-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) |
| R | Audit log rewritten | Append-only trigger; hash-chain checkpoints verified by clctl audit verify (crates/commonlayer-audit/src/checkpoint.rs) |
| T, R | The 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) |
| I | Key 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) |
| I | One 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 |
| I | Private reports and webhook secrets read from a database dump | Encrypted 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) |
| I | Secrets in logs or audit diffs | Secret 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 |
| T | Unsafe production configuration | Validation 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
| STRIDE | Threat | Mitigation in code |
|---|---|---|
| T | Compromised build tools for images | Tools 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) |
| T | Compromised GitHub Actions in project workflows | Actions pinned to full commit SHAs, permissions: {} at the top, least privilege per job (templates/project-template/.github/workflows/) |
| T | Compromised 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
| STRIDE | Threat | Mitigation in code |
|---|---|---|
| E | A malicious maintainer publishes unverified software | No user, maintainer or CI token can push to the registry; content arrives only through the verification pipeline (crates/commonlayer-registry/src/domain/access.rs) |
| E | A maintainer pays themselves | Maintainers 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) |
| E | One 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, E | A 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, I | Token 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) |
| T | Refunds 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) |
| E | A maintainer approves their own security exception | Service check and CHECK (decided_by IS DISTINCT FROM requested_by) (0008_security.sql) |
| E | One 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) |
| E | Self-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) |
| E | A project admin redirects a replacement's money | Replacement has no path to the ledger; a test asserts every balance and transaction is unchanged (crates/commonlayer-maintenance/tests/it/no_financial_effect.rs) |
| E | A platform admin adjusts the ledger without trace | Adjustments 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) |
| E | A platform admin pays out alone | Payout 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.
| STRIDE | Threat | Mitigation in code |
|---|---|---|
| E | The App's Administration permission is used beyond managing appointed maintainers | The 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 |
| T | CommonLayer writes to a maintainer's repository | The 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 |
| I | The installation token leaks through git | git 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) |
| S | Someone 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 |
| D | A hostile repository exhausts the worker during a backup | Size 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) |
| E | A required guardrail is waived to get a project approved | Waivers 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, S | A 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) |
| S | A 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) |
| S | The 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) |
| D | The 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) |
| E | A 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, E | The 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) |
| E | A succession moves money or is decided by automation | Successions 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.
| # | Threat | Current state | Suggested direction |
|---|---|---|---|
| O1 | Ledger 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 ledger | Keep 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 |
| O2 | Audit log rewritten together with its checkpoints | Partly 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 database | Publish or export checkpoint digests to an external, append-only location |
| O5 | Self-assessed not_affected below the confirmation threshold | High 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 audited | Lower the threshold if medium findings should need a second person |
| O6 | Small rewards through two colluding project members | Since 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 rationale | Accept as residual risk, lower the limit, or add a per-recipient cap: a decision for the operating entity |
| O7 | Compromise 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 content | Network isolation of the worker (WP-50); a separate promoter process holding the signing key; move provider calls of the API into worker jobs |
| O11 | Provenance verification of GitHub artifact attestations | The 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 pipeline | Run a real release and adjust discovery or verification |
| O12 | Platform images are not signed or attested by the means the platform requires of projects | The release workflow signs and attests them; it has never run | Run it once CL_BASE_IMAGE and arm64 are available |
| O13 | Vulnerabilities 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 advisories | Keep the "watch upstream advisories" duty in the project documents; consider a second matcher for declared components |
| O14 | Vulnerabilities in the platform's own images | The 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 findings | Build with cargo auditable; update cosign when a release with the fixes exists |
| O15 | Areas not covered by the security review | Registry 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 rounding | A follow-up review |
| O16 | The 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 repository | Move 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-Foronly fromserver.trusted_proxies, and the portal forwards it only whenBEHIND_TRUSTED_PROXYis 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).