Assurance Case - NormB/sipnab GitHub Wiki
An assurance case is an argument, backed by evidence, that a system meets its security requirements. This page is sipnab's.
It answers two questions that the threat model does not:
- Were well-known secure design principles applied?
- Were the most common kinds of implementation mistake countered?
Where the answer is "only partly" or "no", this page says so. An operator can add a defense only where they know one is missing.
The case has three layers:
-
Claim. sipnab resists the attackers the threat model names.
- This holds at every trust boundary the threat model names.
- The exception is where a residual risk says otherwise.
-
Arguments. Four supports hold up that claim:
- The threat model names what an attacker wants.
- The trust boundaries name every place attacker-controlled data enters. They give the threats and mitigations at each.
- Secure design principles below shows each classic principle applied, or where it is not.
- Common implementation weaknesses below walks the 2025 CWE Top 25. It gives each entry a verdict.
-
Evidence. Every argument cites the file, and usually the function,
that implements it.
- So a reader can check the claim instead of trusting it.
- Residual risks lists what the evidence does not cover.
-
Secure design principles: eight rules for building protection
mechanisms.
- Jerome Saltzer and Michael Schroeder published them in 1975, in "The Protection of Information in Computer Systems".
- The OpenSSF Best Practices badge adds two more:
- a limited attack surface,
- input validation that uses allowlists.
-
Allowlist: a list of what a check permits.
- The check refuses anything absent from the list.
- Its opposite, a denylist, refuses only what it names. It admits everything else.
- CWE: Common Weakness Enumeration, MITRE's catalog of software weakness types. Each type has a number, such as CWE-79 for cross-site scripting.
-
CWE Top 25: MITRE's yearly ranking of the 25 weakness types behind the
most severe published vulnerabilities.
- This page uses the 2025 CWE Top 25, published 2025-12-15.
-
unsafe: a Rust keyword that marks code where the compiler cannot prove memory safety and the author must.- Code outside an
unsafeblock cannot write out of bounds, use freed memory or follow a null pointer.
- Code outside an
- Loopback, bearer token and HMAC: see the threat model's terms.
Each row:
- names a principle,
- says what it asks for,
- says where sipnab applies it.
The last cell is the verdict: Applied, or Partially applied with the gap named.
| Principle | What it asks for | How sipnab applies it | Verdict |
|---|---|---|---|
| Economy of mechanism | Keep each protection small and simple enough to check. | One token verifier serves both the REST API and MCP (src/auth.rs::verify_for). One rate limiter serves REST, MCP and HEP (src/rate_limit.rs::FixedWindowLimiter). One path resolver confines -O, the MCP file tools and the REST file root (src/capture/output_guard.rs::canonical_target). A WASM plugin gets no host imports at all, so there is no host interface to review (src/plugin/mod.rs::instantiate). |
Partially applied: the "bare file name only" check that runs before that resolver exists twice, once for MCP (src/mcp/server.rs::resolve_in_root) and once for REST (src/output/api.rs::resolve_in_file_root). The two copies agree today and nothing keeps them agreeing. |
| Fail-safe defaults | Deny by default; grant access only by explicit choice. | Every listener (REST, MCP over HTTP, HEP, metrics) is off until a flag turns it on. A listener on a non-loopback address refuses to start without a credential (src/output/api.rs::enforce_bind_auth_policy, src/capture/hep.rs::enforce_hep_bind_policy). A REST route that calls the plain guard demands the broadest scope, so a new route admits only full tokens until someone narrows it (src/output/api.rs::guard). MCP file tools refuse when the operator has set no root (src/mcp/server.rs::resolve_in_root). Scanner-kill, TFPS actions and relay queries are off by default. |
Partially applied: a loopback listener with no key configured accepts every local request (residual risk "Loopback means no authentication"), and the Landlock sandbox and seccomp filter both default to off (src/sandbox.rs::SandboxMode, src/seccomp.rs::SeccompMode). |
| Complete mediation | Check every access, every time, rather than trusting an earlier check. | Every REST handler except /health calls a guard that charges the rate limit and then verifies the token's signature, expiry, audience and scope on that request (src/output/api.rs::guard_scoped, src/output/api.rs::authenticate). HTTP MCP applies its check as a layer on the whole router, so no tool route can skip it (src/mcp/transport.rs::auth_layer). A HEP listener checks its source allowlist, rate limit and configured secret or signature on every datagram before using it (src/capture/hep.rs::verify_hmac_datagram, src/capture/hep.rs::plain_auth_check). |
Partially applied: REST calls the guard inside each handler rather than as one layer, and no test sends an unauthenticated request to every route, so a new handler that forgot the guard would pass the suite. Loopback without a key skips the check entirely (residual risk "Loopback means no authentication"). |
| Open design | Security must not depend on the design being secret. | The source is public under MIT or Apache-2.0. Tokens use HMAC-SHA256 from the hmac and sha2 crates, TLS comes from rustls, and Authentication documents the token format. The secrets are the keys, never the method. SECURITY.md invites reports. |
Applied |
| Separation of privilege | Require two independent conditions before a dangerous act. | A TFPS ban needs a token with the actions scope and a server-side setting that enables actions (src/output/api.rs::action_guard). Any transmit needs its opt-in flag and a live capture source; a capture file can never obtain the permit that creates a send socket (src/security/transmit_guard.rs::for_source). |
Applied |
| Least privilege | Run each part with the smallest set of rights it needs. | A root start drops to an unprivileged user once the capture device is open, checks that the drop took, and sets PR_SET_NO_NEW_PRIVS (src/privilege.rs::drop_privileges, src/privilege.rs::verify_dropped, src/privilege.rs::block_privilege_escalation). --chroot narrows the filesystem (src/privilege.rs::do_chroot). The scanner-kill worker clears its capabilities (src/process_isolation/worker_process.rs::clear_capabilities). Tokens carry scopes such as metrics, read and actions (src/auth.rs::SCOPE_METRICS). |
Partially applied: a non-root start after --setup-caps keeps CAP_NET_RAW and CAP_NET_ADMIN for its whole life (residual risk "Capabilities survive a non-root start"), libpcap reads the first packets before the drop (residual risk "libpcap is C, and it runs briefly as root"), and the sandbox and seccomp filter are opt-in. |
| Least common mechanism | Minimize what different users or parts share, since shared machinery is a path between them. | The scanner-kill sockets live in a separate worker process that starts with an emptied environment (src/process_isolation.rs::worker_environment). API and MCP tokens carry different audiences, so one surface's token fails on the other (src/auth.rs::AUDIENCE_MCP). sipnab keeps rate limits per peer, so one noisy client cannot spend the budget of the others. |
Partially applied: parsers, keys and tokens otherwise share one process, and exec hook children inherit sipnab's environment (residual risk "Exec hooks inherit the environment"). |
| Psychological acceptability | Make the secure way the easy way, or people route around it. | A refusal names the flag that would allow the act, as the MCP file tools and query_relay do (src/mcp/tools/relay.rs::QueryRelayParams). Every secret has a file or environment-variable form so it stays out of ps output, and --archive-password prints a warning naming the safer options (src/capture/archive/password.rs::INLINE_WARNING). A local run needs no credentials. |
Applied |
| Limited attack surface | Expose as little as possible to an attacker. | The default build leaves out the REST API, MCP, HEP, WASM plugins and archive input; the full feature adds them (Cargo.toml, [features]). A route exists only in builds with its feature (src/output/api.rs::build_router). In a full build, each listener stays closed until its flag. |
Applied |
| Input validation with allowlists | Check input against what the program allows, not against a list of known-bad values. | HTTP MCP accepts only Host values on an allowlist (src/mcp/transport.rs::serve_http). File tools accept one plain path component and nothing else (src/mcp/server.rs::resolve_in_root). REST bodies that change state reject unknown fields and anything but a JSON object (src/output/api.rs::PersistenceRequest, src/output/api.rs::set_persistence). Options with a fixed set of values parse into enumerations, so an unlisted value is an error. |
Partially applied: unknown config keys produce a warning, not an error (src/config.rs::unknown_keys), and the ban target check is a denylist of loopback, broadcast, multicast and unspecified addresses (src/security/actions/mod.rs::check_ban_address). |
The table walks the 2025 CWE Top 25 in rank order. Each verdict is one of:
- Countered: a mechanism cited in the row prevents the weakness in sipnab's code.
- Partially countered: a mechanism exists, and the row names the case it does not cover.
- Not countered: nothing prevents it.
- Not applicable: sipnab has no code of the kind the weakness needs.
CWE-787, 416, 125, 120, 476, 121 and 122 are memory-safety weaknesses. sipnab uses Rust. Safe Rust:
- checks every slice index,
- frees memory only when nothing refers to it,
- has no null references.
That leaves unsafe blocks and C libraries:
-
unsafeblocks exist only for calls into the operating system and other foreign code.-
Fault model counts them.
tests/unsafe_census_test.rsholds that count to the tree. - Every block must carry a
// SAFETY:comment that states why it is sound.-
Cargo.tomlsets clippy'sundocumented_unsafe_blockslint. - Both the pre-push hook (
.githooks/pre-push) and CI (.github/workflows/ci.yml) run clippy with-D warnings. - So a block without one fails the build.
-
-
Fault model counts them.
-
The project fuzzes every parser of untrusted bytes
(
fuzz/fuzz_targets/). A smoke tier runs in ordinary CI (tests/smoke_fuzz_test.rs). -
C code remains:
- libpcap parses every live packet before sipnab does.
- The allocator, mimalloc, is C.
- The Rust argument covers neither (residual risk "libpcap is C, and it runs briefly as root"). That is why the buffer rows below say "partially".
| Rank | CWE | What it is | Verdict | Argument and evidence |
|---|---|---|---|---|
| 1 | CWE-79 | Cross-site scripting: untrusted text reaches a web page as markup. | Countered | No REST route answers with HTML: answers are JSON, plain text (/health, /metrics) or audio. The one HTML file it writes, the call-flow export, escapes the diagram text before embedding it and has a test that tries to inject a <script> tag (src/tui/call_flow/export.rs::escape_html). The website is static and sends a Content Security Policy that allows scripts from its own origin only (website/static/_headers). |
| 2 | CWE-89 | SQL injection. | Not applicable | sipnab has no database and no SQL library in Cargo.toml. |
| 3 | CWE-352 | Cross-site request forgery: a web page makes the victim's browser send a request the victim did not intend. | Partially countered | Credentials travel in an Authorization header, never a cookie, so a browser does not attach them to a forged request. HTTP MCP refuses a Host header outside its allowlist, which defeats DNS rebinding (src/mcp/transport.rs::serve_http). Gap: the REST API has no Host check (src/output/api.rs::build_router). On a loopback bind with no key, a web page that rebinds its own name to 127.0.0.1 can read the capture through the browser and change the persistence setting. Configure a key, or keep the REST API off, on a machine where someone browses the web. |
| 4 | CWE-862 | Missing authorization: an action runs without checking that the caller may perform it. | Partially countered | Every REST handler except /health calls a guard (src/output/api.rs::guard_scoped), and HTTP MCP checks as a layer over all routes (src/mcp/transport.rs::auth_layer). Risky MCP tools also need their own opt-in flag (src/mcp/server.rs::open_capture). Gap: no test sweeps every REST route without a token, so the suite would miss a handler added without the guard (see complete mediation above). |
| 5 | CWE-787 | Out-of-bounds write. | Partially countered | See memory safety: safe Rust bounds-checks every write, and unsafe must state its soundness. libpcap is C. |
| 6 | CWE-22 | Path traversal: a name such as ../../etc/passwd reaches a file outside the intended directory. |
Partially countered | Archive extraction never uses a member's name as a path: it writes to names it generates inside a private directory (src/capture/archive/mod.rs::ExtractDir). MCP and REST file access take one plain name, resolve symbolic links and refuse a result outside the configured root (src/mcp/server.rs::resolve_in_root, src/output/api.rs::resolve_in_file_root). Gap: the check and the later open are separate steps, so a local user who can swap links inside the root in between could redirect a write (residual risk "File-tool path check and file open are separate steps"). |
| 7 | CWE-416 | Use after free. | Partially countered | See memory safety: the borrow checker rejects use after free in safe Rust. libpcap is C. |
| 8 | CWE-125 | Out-of-bounds read. | Partially countered | See memory safety. The capture-file reader also checks each record length with checked arithmetic before slicing (src/capture/pcap_reader.rs). libpcap is C. |
| 9 | CWE-78 | OS command injection: untrusted text becomes part of a shell command. | Partially countered | Exec hooks run the operator's own command through sh -c, and captured data reaches it only as SIPNAB_* environment variables, never as text spliced into the command (src/output/event_exec.rs::spawn_command, src/security/alerting.rs). Other child programs get an argument list with no shell (src/security/tfps.rs::run_bounded, src/capture/archive/password.rs::split_command). Legacy %from placeholders become a quoted "${SIPNAB_FROM}" reference that suits the quoting around the placeholder, so the value is one word with no splitting or * expansion (src/security/exec_placeholders.rs::quote_placeholders). Gap: a hook that uses a variable unquoted, or runs eval on one, reintroduces the risk inside the operator's own script (residual risk "Exec hooks inherit the environment"). |
| 10 | CWE-94 | Code injection: untrusted input becomes code the program runs. | Countered | sipnab evaluates no code from its inputs. sipnab parses a filter expression into a tree of comparisons that it evaluates itself, and refuses a malformed one (src/sip/dsl.rs::FilterExpr). The only loaded code is a WASM plugin the operator names, which runs in an interpreter with no host imports and fixed fuel and memory caps (src/plugin/mod.rs::instantiate, src/plugin/mod.rs::FUEL_PER_DIALOG). |
| 11 | CWE-120 | Classic buffer overflow: a copy with no size check. | Partially countered | See memory safety: a safe Rust copy between slices of different lengths panics instead of overflowing. libpcap is C. |
| 12 | CWE-434 | Unrestricted upload of a dangerous file type. | Not applicable | Neither the REST API nor MCP accepts a file upload. The only POST bodies are small JSON documents capped at 1 MiB (src/output/api.rs::MAX_REQUEST_BODY_BYTES). MCP can open a capture already placed in --mcp-file-root, behind its own opt-in flag. |
| 13 | CWE-476 | Null pointer dereference. | Partially countered | See memory safety: safe Rust has no null references, and a missing value is an Option the code must handle. The unwrap_used and expect_used lints flag the shortcuts that would turn a missing value into a crash (src/lib.rs). libpcap is C. |
| 14 | CWE-121 | Stack-based buffer overflow. | Partially countered | See memory safety. libpcap is C. |
| 15 | CWE-502 | Deserialization of untrusted data into objects that run code. | Countered | Untrusted JSON and TOML deserialize with serde into fixed, typed structures. sipnab uses no format that names its own types, so a document cannot choose what gets built. Bodies that change state reject unknown fields (src/output/api.rs::PersistenceRequest), and request bodies are size-capped. |
| 16 | CWE-122 | Heap-based buffer overflow. | Partially countered | See memory safety. libpcap and mimalloc are C. |
| 17 | CWE-863 | Incorrect authorization: the check exists but grants too much. | Countered | Tokens carry an audience and a scope, and the verifier checks both on every request (src/auth.rs::verify_for). A metrics token reaches only the metrics route, and a route with no explicit scope demands the broadest (src/output/api.rs::guard). Actions need the actions scope plus a server setting (src/output/api.rs::action_guard). tests/api_token_test.rs covers scope refusals, and tests/mcp_scope_test.rs the MCP side. |
| 18 | CWE-20 | Improper input validation. | Partially countered | Parsers of untrusted bytes cap line, buffer and message sizes, and the project fuzzes them (threat model, packet capture input). Gap: see the allowlist principle above: unknown config keys warn instead of failing, and the ban target check is a denylist. |
| 19 | CWE-284 | Improper access control, the parent of the authorization and authentication rows. | Partially countered | The mechanisms in the CWE-862, CWE-863 and CWE-306 rows. Gap: sipnab checks the file permissions of archive password files and the HEP TLS key only; it reads signing-key files, --tls-key and the config file without checking who else can read or write them (residual risk "No permission check on most secret files or the config file"). |
| 20 | CWE-200 | Exposure of sensitive information to someone not entitled to it. | Partially countered | sipnab keeps keys out of core dumps and swap, and derived session keys wipe themselves on drop (src/privilege.rs::disable_core_dumps, src/privilege.rs::lock_key_memory, src/rtp/srtp.rs::DerivedSessionKeys). The REST API refuses an archive password in a URL (src/output/api.rs::refuse_password_in_url_mw). Gap: HTTP MCP and the metrics endpoint have no TLS, the REST API has TLS only when configured, and authenticated clients see real identities because --redact covers vCon export only (residual risks "TLS is opt-in on every network listener" and "Authenticated clients see real data"). |
| 21 | CWE-306 | Missing authentication for a critical function. | Partially countered | A listener on a non-loopback address refuses to start without a credential (src/output/api.rs::enforce_bind_auth_policy, src/capture/hep.rs::enforce_hep_bind_policy). Gap: on loopback with no key, the REST API and HTTP MCP accept every request, including the persistence switch (residual risk "Loopback means no authentication"). |
| 22 | CWE-918 | Server-side request forgery: a caller makes the server connect to a destination the caller chose. | Countered | sipnab has no HTTP client library. It connects out only to destinations the operator gave on the command line or in the config: the HEP collector (--hep-send) and the media relay (--rtpengine-control). The relay query tool deliberately takes no address argument (src/mcp/tools/relay.rs::QueryRelayParams), and a run reading a capture file cannot obtain a transmit permit at all (src/security/transmit_guard.rs::for_source). |
| 23 | CWE-77 | Command injection in general, of which CWE-78 is the shell case. | Partially countered | The CWE-78 row applies unchanged, gap included. The config's ${NAME} expansion runs once and never recursively (src/config.rs::expand_env_vars). |
| 24 | CWE-639 | Authorization bypass through a user-controlled key: changing an id in a request reaches data that belongs to another user. | Partially countered | sipnab has one tenant: every authorized caller may see every call, so a Call-ID in a URL reaches nothing the caller could not list anyway. The one owned resource is a ban, and sipnab refuses to lift a ban it did not place (src/security/actions/service.rs, NotOwned). Gap: ownership is per sipnab instance, not per token, so any actions token can lift a ban another token placed. |
| 25 | CWE-770 | Allocation of resources without limits or throttling. | Partially countered | Dialog and stream stores cap and evict (tests/resource_bounds_test.rs). Decompression has a total byte ceiling, a depth cap and a member cap (src/capture/pcap_reader.rs::DEFAULT_MAX_GUNZIP_BYTES, src/capture/archive/mod.rs::MAX_ENTRIES). REST limits requests per client before authentication, caps bodies and times out slow requests (src/output/api.rs::REQUEST_TIMEOUT). Exec hooks cap live children (src/output/event_exec.rs::DEFAULT_QUEUE_DEPTH). Gap: failed HTTP MCP logins have no throttle (residual risk "Failed MCP logins face no throttle"), and the per-sender HEP limit is off by default. |
Writing this page against the code turned up gaps the threat model did not yet list. They stand as open work:
-
The REST API has no
Hostallowlist.- HTTP MCP has one. REST does not.
- So DNS rebinding from a browser reaches a loopback REST API that has no key (CWE-352 row).
- No test sweeps every REST route for authentication (CWE-862 row).
- The bare-file-name check exists twice, once for MCP and once for REST (economy of mechanism row).
- Ban ownership is per instance, not per token (CWE-639 row).
tests/openssf_badge_test.rs checks that:
- every principle above has its own row with a verdict,
- every entry of the 2025 CWE Top 25 has exactly one row with a verdict,
- every path and
file::namecitation names something that exists.
A new edition of the list is a deliberate change to that test and this page together.