Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

docs/THREAT-MODEL.md

Gunnar threat model

This is Gunnar's threat model and security assurance case. It says what Gunnar protects, where the trust boundaries are, who might attack it, and which code stops each attack. It describes the code on master as it is. Anything that is planned or missing is listed under Residual risks and known gaps, never as a mitigation.

A citation such as crates/gunnar-core/src/sandbox.rs resolve_repo_path names a file and a symbol or phrase that file contains. xtask/tests/threat_model.rs checks every such citation on every test run, so a citation that goes stale fails the build. Code outside this repository is named by crate and version, for example gunnar-pack 2.0.0 (edda).

To report a vulnerability, see SECURITY.md.

Scope

The products

Product Crate What it is In this model
gunnar crates/gunnar The server: git over SSH and smart HTTP, the gRPC control plane, the store, replication to a twin Yes
gunnar-cli crates/gunnar-cli The command-line tool: gunnar-cli control … against a server, and encrypted repositories (gunnar-cli crypt …) Yes
gunnar-ui the gunnar-ui repository The web console Only as a boundary: it is a client of the control plane

The estate pieces in this repository are covered where they touch the server: crates/gunnar-front (the front VM's PID 1: the WireGuard relay, the twin's certification, the egress door), crates/gunnar-pairing-door, and crates/gunnar-monetize-guest (Gunnar's WASM plugin for monetize).

Out of scope: the internals of gunnar-ui, monetize and its WASM host, the nordisk-pairing crate, the operations of the hosted service at gunnar.rs, and the kernel and hypervisor below Gunnar.

The builds

The default build is the AIRGAP install. It makes no outbound connection.

Build What it adds Where
default engine-znippy, http, replication crates/gunnar/Cargo.toml default = ["engine-znippy", "http", "replication"]
online outgoing webhooks, company SSO with its IdP fetches, pull mirrors over HTTPS crates/gunnar/Cargo.toml online = ["webhooks", "sso"
federation online plus server-side mirroring from other forges (the git client half) crates/gunnar/Cargo.toml federation = ["online", "client"]
public-site, mcp the console's public site and MCP connector; the server serves neither crates/gunnar/Cargo.toml public-site = []
engine-gix the gix storage engine instead of znippy crates/gunnar/Cargo.toml engine-gix = ["gunnar-server/engine-gix"]
metered open monetize; opt-in only, never in the default or an extra crates/gunnar/Cargo.toml metered = ["gunnar-server/metered"

An outbound extra also needs its explicit configuration before it dials anything. The test xtask/tests/the_three_products.rs each_product_carries_the_named_extras_over_an_airgap_default holds the extras on all three products.

Gunnar runs in three shapes: a container, an OS-less appliance (Gunnar is PID 1, with no shell and no OS users), and a service on an operating system. A bare gunnar serves on loopback only (crates/gunnar/src/main.rs a_bare_gunnar_serves_on_loopback).

Assets

Asset Where it lives What must hold
Repository content and history the store under the data root (the znippy archive by default), LFS objects Integrity and availability; confidentiality for private repositories
Encrypted repositories ciphertext only on the server; the key ring is wrapped to the members' ML-KEM-768 + X25519 keys (crates/gunnar-crypt/src/lib.rs Quantum-safe is the default) Confidentiality against the server and its operator
Refs the store's ref table Integrity: no unauthorised move, force-push or delete
SSH host key a sealed secret (crates/gunnar-core/src/host_key.rs mint_ed25519) Confidentiality; clients pin it
User SSH keys, passkeys, principals and grants the identity table and ACL (crates/gunnar-core/src/auth.rs AuthTable) Integrity
Access tokens stored only as a SHA-256 hash (crates/gunnar-core/src/auth.rs pub fn token_hash) Confidentiality of the secret, which the server never keeps
The key-encrypting key and sealed secrets secrets.kek beside the data, or handed in from outside (crates/gunnar-core/src/seal.rs GUNNAR_SECRET_KEK); metered builds add named sealed slots (crates/gunnar-server/src/stores.rs sealed_slot_put) Confidentiality
Audit trail, account audit log, transparency log, transaction log the data root Integrity: tampering must be detectable
The twin a second Gunnar that follows the primary's transaction log, which carries the KEK (crates/gunnar-server/src/twin_tls.rs carries the KEK as seq 1) Everything the primary holds
Release signing key and provenance a YubiKey (PIV slot 9c), slsa/release-signing-key.pub, attestations/ Integrity of what users install

Trust boundaries

                         untrusted network
   git over SSH        git smart HTTP        gRPC control plane        browser
   (port 2222)         (port 8080, TLS       (127.0.0.1:50051 by          |
        |               optional)             default)                    v
        |                   |                     |  <--- sshsig ---  gunnar-ui console
        v                   v                     v       per call    (other repository)
 +------------------------------------------------------------------------------+
 |  gunnar (the server process; PID 1 on the appliance)                         |
 |   [B1] path sandbox + ACL      [B2] wire parsers + ceilings                  |
 |   [B3] push policy gates       [B4] sealed secrets under the KEK             |
 +-------+-----------------------------+-----------------------------+----------+
         | [B5] filesystem              | [B6] twin link              | [B7] outbound
         v                              |  mutual TLS, inside         |  (extras only):
   data root: archive, txlog,           |  WireGuard                  |  webhooks, SSO IdP,
   secrets log, secrets.kek             v                             v  HTTPS mirrors
                                   twin gunnar                   egress door on the front

 [B8] estate: the front (gunnar-front: WireGuard relay, pairing, twin certification);
      the monetize ONE box: Gunnar pulls signed orders; the WASM guest runs in its host
 [B9] supply chain: crates.io, Cargo.lock, vendor/, the release runner, the YubiKey
Boundary On the other side How Gunnar authenticates it
B1, B2: SSH any network client SSH public key looked up in the ACL key table; anonymous only for reading public repositories
B1, B2: smart HTTP any network client access token (Basic or Bearer), refused over cleartext unless allowed; anonymous as for SSH
B1: gRPC control plane the CLI, the console, the estate's machines an sshsig over each call by a key in the ACL key table; optional mutual TLS
B3: push policy an authenticated pusher's pack and commands pre-receive gates before refs move
B5: filesystem the local host file modes; secrets sealed under the KEK
B6: twin link the twin mutual TLS with a certificate minted at pairing
B7: outbound IdPs, webhook receivers, mirror sources TLS; SSO egress refuses private addresses
B8: estate the front and the monetize ONE box pairing certificates; repair orders signed with Ed25519
B9: supply chain upstream crates and the build host locked graph, advisory gate, signed release provenance

Attackers

Id Attacker Can Wants
A1 Anonymous network attacker reach the SSH and HTTP ports, send any bytes read private data, write anything, crash the box
A2 Authenticated low-privilege user push and fetch where granted, call the control plane reach repositories or refs beyond their grants, exhaust resources
A3 Malicious repository content push a crafted pack, refs or objects (through A2), or be fetched by a mirror memory or CPU exhaustion, path escape, parser bugs
A4 Compromised twin speak on the replication link read all data, feed bad data back on a recharge
A5 Compromised dependency or build input change code that Gunnar links or builds with run code in the server or in a release
A6 Local host user read and write files on the host steal secrets, rewrite logs
A7 Insider operator hold a server-scope grant, run the box read customers' repositories, hide what they did

Mitigations

A1: anonymous network attacker

Threat Mitigation Code
Path traversal in the repository argument One path sandbox turns the argument into validated components, never a joined path; it rejects .., absolute paths, NUL, control characters and over-long names crates/gunnar-core/src/sandbox.rs pub fn resolve_repo_path
Anonymous access Anonymous gets Read on a public repository and nothing else; every caller goes through the same check crates/gunnar-core/src/auth.rs pub fn may(
SSH password guessing Public keys only; at most 6 attempts per connection and 1 s per rejection crates/gunnar-ssh/src/config.rs max_auth_attempts: 6
SSH key exchange recorded now, broken later Hybrid ML-KEM-768 + X25519 key exchange first, OpenSSH strict key exchange on; the host key is ed25519 only; the server links no russh below 0.64.1 crates/gunnar-ssh/src/server.rs MLKEM768X25519_SHA256, crates/gunnar-core/src/host_key.rs mint_ed25519, xtask/tests/ssh_server_advisory_floor.rs
Connection floods One admission gate for SSH, HTTP and gRPC: 256 concurrent transfers, 32 per principal, 5 for anonymous callers; idle sessions end after 600 s crates/gunnar-ssh/src/config.rs DEFAULT_MAX_CONCURRENT_TRANSFERS, crates/gunnar-ssh/src/admission.rs DEFAULT_MAX_ANONYMOUS_TRANSFERS, crates/gunnar-server/src/grpc_gate.rs the same gate as git
Endless want/have lines The request is counted as it is framed and cut at 8 MiB crates/gunnar-wire/src/limits.rs DEFAULT_REQUEST_LIMIT_BYTES
Gzip bomb in an HTTP body Both the compressed and the inflated size are bounded (16 MiB) crates/gunnar-http/src/body.rs MAX_GZIPPED_REQUEST_INPUT_BYTES
A credential the server cannot read A malformed Authorization header is refused, never served as anonymous; a token over cleartext counts only behind a TLS proxy or native TLS crates/gunnar-http/src/auth.rs pub fn credential, crates/gunnar-http/src/auth.rs pub fn over_tls
Forged or replayed control-plane calls Loopback by default; every call carries an sshsig bound to the method, a timestamp inside the skew window and a single-use nonce crates/gunnar-common/src/cli.rs default_value = "127.0.0.1:50051", crates/gunnar-grpc/src/auth.rs gunnar-sshsig-v2, crates/gunnar-grpc/src/auth.rs ReplayGuard
Guessing a token 256 bits from the OS generator, with a findable prefix crates/gunnar-core/src/auth.rs pub fn mint_token_secret
Unexpected outbound traffic The default server dials nothing, tested at runtime and in the build graph crates/gunnar-server/tests/airgap_runtime.rs the_default_server_starts_serves_and_dials_nothing, crates/gunnar-recipe/tests/airgap_default_builds.rs every_default_build_links_only_the_approved_outbound_clients

A2: authenticated low-privilege user

Threat Mitigation Code
Writing without a write grant receive-pack needs Level::Write, upload-pack needs Level::Read, both from the one ACL crates/gunnar-core/src/auth.rs pub fn may(
Ref names that escape or confuse Every pushed name is validated as git does (.., control characters, .lock, component length), and refs/heads/x cannot sit beside refs/heads/x/y crates/gunnar-store/src/refname.rs pub fn validate, crates/gunnar-wire/src/receive_pack.rs fn validate_ref_name, crates/gunnar-store/src/refname.rs pub fn check_no_name_conflict
Rewriting protected history Branch protection (no force-push, no delete, allowed principals), required SSH-signed commits, ref-name policy crates/gunnar-policy/src/gates/protection.rs BranchProtectionGate, crates/gunnar-policy/src/gates/signed.rs SignedCommitsGate, crates/gunnar-policy/src/gates/refname.rs RefNameGate
Oversized pushes Per-object, per-blob, per-push and per-pack ceilings, measured from object headers without inflating payloads crates/gunnar-policy/src/gates/limits.rs impl Gate for LimitsGate
Filling the disk Every chunk of a pushed pack asks the data set's space ledger first; a refused chunk cuts the stream crates/gunnar-server/src/push_space.rs struct NoRoom
An admin enrolling a key on someone else's principal The target principal must sign the key being added crates/gunnar-core/src/key_attestation.rs attested by that principal
Webhooks to arbitrary targets Only an admin of the scope may configure one crates/gunnar-grpc/src/service/webhook.rs GrantLevel::Admin
Running code on the server Gunnar is only a git server: it builds nothing and runs no pushed code (builds run in dwarves through Build Thing) crates/gunnar-cli/tests/git_only.rs no_build_runner_and_no_build_verbs
Large LFS objects 8 GiB per object, 10 000 objects per batch crates/gunnar-lfs/src/limits.rs DEFAULT_MAX_OBJECT_BYTES

A3: malicious repository content

Threat Mitigation Code
Pack bomb held in memory A pushed pack over 64 MiB is spooled to disk while it arrives crates/gunnar-server/src/storage.rs SPOOL_ABOVE
A pack entry claiming a huge object The gix engine indexes with a 1 GiB single-allocation ceiling that is on by default (gunnar-pack 2.0.0 (edda) IndexOptions::alloc_limit_bytes); the fetch path refuses an object over the same ceiling from its header, before reading content crates/gunnar-wire/src/limits.rs DEFAULT_OBJECT_LIMIT_BYTES
The default engine's indexer running the box out of memory The indexer pauses when available memory falls under 20 % (znippy-plugin-git 0.2.1 memguard) outside this repository
Delta cycles A chain that returns to an offset it already visited is refused as corrupt (znippy-plugin-git 0.2.1 resolve::CycleGuard) outside this repository
Graph walks on huge repositories A walk budget bounds the selection's memory crates/gunnar-wire/src/limits.rs DEFAULT_WALK_LIMIT_BYTES
Path tricks in trees (.git entries, symlinks, case folding) The server has no worktree: the appliance links no gix worktree, index, filter, config, protocol or command crate, so no checkout and no git subprocess exist to exploit crates/gunnar-cli/tests/feature_split.rs FORBIDDEN_IN_THE_APPLIANCE, xtask/src/guard.rs FORBIDDEN_GIX_FEATURE_TOKENS
Plaintext pushed into an encrypted repository The server checks that every blob is an envelope and the key ring is well formed; it reads formats, never keys crates/gunnar-crypt/src/policy.rs pub fn check_blob, crates/gunnar-policy/src/gates/encrypted.rs impl Gate for EncryptedGate
Parser bugs Eight fuzz targets for the parsers that read a stranger's bytes: pack framing, objects, receive and upload requests, envelopes, crypt files, commit signatures, sshsig events fuzz/README.md pack_scan, fuzz/fuzz_targets/
Memory-safety bugs unsafe_code = "forbid" in gunnar-core, gunnar-ssh, gunnar-store, gunnar-wire, gunnar-http, gunnar-crypt, gunnar-lfs, gunnar-client and gunnar-forge crates/gunnar-wire/Cargo.toml unsafe_code = "forbid"

A4: compromised twin

Threat Mitigation Code
A stranger posing as the twin or the primary Mutual TLS with a certificate minted at pairing; the twin's private key never leaves it, and the twin pins the issuer and refuses to follow in the clear crates/gunnar-server/src/twin_tls.rs Bootstrap.TwinCsr
Eavesdropping on the link The link runs inside WireGuard, linked into the binary crates/gunnar-server/src/wg.rs boringtun
Repair verbs fired by a stranger A repair order counts only when signed with Ed25519 (nordisk-order-v1) for this box and newer than the last one; the check is the shared monetize pump crates/gunnar-repair/src/order.rs nordisk-order-v1, crates/gunnar-repair/src/order.rs pub fn decode
Recharging from a box that is not the twin, or from a stale one The plan checks the source is a twin and how fresh it is before anything is copied crates/gunnar-repair/src/plan.rs pub fn source_is_a_twin, crates/gunnar-repair/src/plan.rs pub fn freshness

A5: compromised dependency or build input

Threat Mitigation Code
A changed dependency slipping in Every build and gate runs --locked against the committed Cargo.lock; only the crates.io registry is allowed deny.toml unknown-registry = "deny"
A known-vulnerable dependency cargo xtask cve runs cargo-deny over the resolved lock; every ignore states its reason; the OSV triage file is held equal to deny.toml; Renovate proposes updates xtask/src/cve.rs 0-known-CVE gate, xtask/tests/osv_triage.rs, renovate.json
A forbidden crate entering the server The dependency guard reads the resolved graph and bans openssl, libgit2 and others everywhere, and gix porcelain in the server xtask/src/guard.rs FORBIDDEN_ANYWHERE, xtask/src/guard.rs FORBIDDEN_IN_SERVER_GRAPH
A patched dependency drifting One vendored patch, documented and tested Cargo.toml the one patched dependency, vendor/gix-parallel
A tampered release One signed provenance statement per release names every artifact by sha256; the key was generated on a YubiKey and never leaves it docs/SLSA.md How to verify a release, slsa/release-signing-key.pub
The WASM guest Gunnar's monetize plugin asks for no host grants and holds no key or socket; it is signed and built from the same commit as the server crates/gunnar-monetize-guest/src/lib.rs grants = []

A6: local host user

Threat Mitigation Code
Secrets copied out with the data Secrets are sealed with AES-256-GCM, and the key name is the AAD, so a sealed value cannot be moved to another slot crates/gunnar-core/src/seal.rs pub fn seal, crates/gunnar-core/src/seal.rs The **AAD is the secret's key name**
Reading the KEK The KEK file is mode 0600, or it is handed in from outside and never written to disk crates/gunnar-core/src/seal.rs 0o600, crates/gunnar-core/src/seal.rs pub const KEK_ENV
Stolen token table Only hashes are stored crates/gunnar-core/src/auth.rs pub fn token_hash
A shell on the box The appliance has no shell, no OS users and no authorized_keys; Gunnar is PID 1 crates/gunnar-core/src/auth.rs No OS users

A7: insider operator

Threat Mitigation Code
Reading a customer's repositories A person's server-scope grant counts for nothing inside a customer's account; a look needs the account admin's approval with a fresh passkey step-up crates/gunnar-core/src/auth/operator_access.rs pub fn request_operator_access, crates/gunnar-core/src/auth/operator_access.rs pub fn decide_operator_access_stepped
Hiding what was done The control plane's audit trail is a hash chain; the account admin has its own audit log crates/gunnar-core/src/audit.rs pub struct AuditChain, .nornir/account-audit-operator-privacy.md
Rewriting push history Pushes go into an RFC 6962 Merkle log with signed tree heads, checkable offline crates/gunnar-core/src/transparency.rs pub fn sign_tree_head
Reading encrypted repositories Files are sealed on the client; the server holds ciphertext only .nornir/encrypted-repos.md ## 7. What the server sees

Residual risks and known gaps

  1. Bus factor 1. One person holds the maintainer and release-manager roles, the forge's admin key and the release signing key (GOVERNANCE.md Known gap (bus factor)).
  2. Advisory floors cover only what is already known. Every committed lock is held above the gix and russh versions with published advisories: gix 0.90 for GHSA-q54r-jf2p-rfr2 (xtask/tests/gix_advisory_floor.rs no_committed_lock_resolves_gix_below_0_90) and russh 0.65 for the 2026-10-30 russh advisories, which reach the SSH server too (xtask/tests/ssh_server_advisory_floor.rs every_lock_builds_the_ssh_server_on_a_russh_without_advisories). A new advisory is red only once the vulnerability database names it; until then the floor holds an old version as readily as a new one.
  3. Delta chain depth is not capped on read in the default engine. Cycles are refused, but a chain of depth d costs d inflates. A user with write access can make reads of their objects slow.
  4. Push size limits are off unless configured. LimitPolicy defaults to no ceiling (crates/gunnar-policy/src/config.rs pub max_pack_bytes: Option<u64>), and the gate runs after the pack was received and indexed. Until a limit is set, only disk space admission bounds a push.
  5. The gRPC control plane has no TLS by default. Calls are signed, but responses are in the clear unless it stays on loopback or a private interface, runs inside WireGuard, or uses --grpc-tls-self (crates/gunnar-server/src/serve.rs pub grpc_tls: Option).
  6. No private-address refusal for webhooks and HTTPS mirrors. SSO egress refuses private addresses (crates/gunnar-server/src/sso_egress.rs pub struct GuardedResolver); we found no such check for webhook targets or mirror URLs (extra online). A scope admin could aim them at internal services.
  7. The KEK sits beside the data by default. Someone with the disk, root or memory can read every sealed secret. Hand the KEK in with GUNNAR_SECRET_KEK to avoid that.
  8. A compromised twin is a full disclosure. It holds all data and the KEK, and a recharge from it restores what it holds.
  9. The monetize order key is a trust anchor. Whoever holds it can sign fence, adopt and purge orders. Its check lives in the shared pump outside this repository.
  10. Encrypted repositories still leak. Sizes, timing, file counts and the shape of history are visible, and with Level 1 also file names and commit messages (.nornir/encrypted-repos.md ## 8. What still leaks). In the browser, the server delivers the code that opens the keys (.nornir/encrypted-repos.md ## 9. The trust gap: the server delivers the wasm).
  11. Release provenance is below SLSA L3. The release runner and build image have no provenance of their own, and there is no transparency log (docs/SLSA.md What still keeps us below L3).
  12. Supply-chain policy gaps. deny.toml allows git dependencies (deny.toml unknown-git = "allow"), though the lock has none today, and it only warns on duplicate versions.

Reporting a vulnerability

Write to gunnar@gunnar.rs with a subject that starts with SECURITY:. Do not open a public issue. We acknowledge within 14 days and aim to fix within 60 days. The full policy, the supported versions and how fixes are announced are in SECURITY.md.