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
- 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)).
- 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.
- 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.
- 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.
- 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).
- 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.
- 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.
- A compromised twin is a full disclosure. It holds all data and the KEK, and a recharge from it restores what it holds.
- 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.
- 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).
- 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).
- 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.