Please do not report security vulnerabilities in public issues, discussions or pull requests.
Report privately through GitHub: Report a vulnerability (Security → Advisories → Report a vulnerability). Include:
- a description of the issue and its impact,
- steps to reproduce (endpoint, request shape,
firewall.yaml/ policy used) and, where possible, a proof of concept, - the commit or release you tested, and whether it was built with
--features ml.
What to expect:
- acknowledgement within 3 business days,
- an initial assessment and severity rating within 7 days,
- a fix or mitigation plan, coordinated disclosure, and credit in the advisory if you want it.
Supported versions: the latest release and main.
This project is a defensive tool — a firewall for LLM traffic. Relevant reports include:
- Detection bypasses (a prompt-injection / secret / PII / harmful-content payload that evades a detector it should catch), with a concrete example.
- Inspection gaps — a request or response shape the proxy forwards without inspecting.
- Over-defense regressions (benign inputs wrongly blocked) that materially raise the false-positive rate.
- Memory-safety, denial-of-service, or request-smuggling issues in the proxy (
llm-firewall) or the agent daemon (agentfw). - Weaknesses in
agentfw's local authentication (token, loopback binding) or audit-log handling.
- A default build of the proxy runs the rule layer (regex + heuristics) only; a build with
--features mlalso runs the DeBERTa classifier (injection.ml, see LLM01). A miss by the stage(s) you run is a known, documented recall limit (README benchmark), not a vulnerability on its own. - Detection is probabilistic. No classifier is perfect; see
docs/methodology.mdand the README benchmark for measured recall and false-positive rates and their honest limits. - The content-moderation layer is not a full safety system and makes no claim to detect illegal material (e.g. CSAM).
agentfwfails open on internal errors by design (a hook that wedges the agent loop gets uninstalled); this is documented in the README and is not itself a vulnerability.
Assessed 2026-10-18 against commit 9d4039f (main, after #22 and #23); findings since fixed are
marked ✅ with the PR that fixed them (#34, #35, and the scanning-coverage fix referred to as "this
PR"). Re-assess after major changes; the automated controls at the end run continuously.
Method: manual code review of the proxy request path (crates/proxy: router, handlers, pipeline,
config, streaming, moderation, agent scan), the detectors and ML loaders (crates/core), and the agent
daemon's auth/config (crates/agentfw); cargo deny --all-features check; review of the CI workflows,
deny.toml, .gitleaks.toml, deploy/; GitHub code-scanning, Dependabot and secret-scanning alert
lists and the published OpenSSF Scorecard; OWASP ZAP (zap-baseline.py and zap-api-scan.py with an
OpenAPI description of /v1/chat/completions and /v1/messages) against a release build bound to
127.0.0.1:18080 with the repo firewall.yaml and a local mock upstream; targeted manual probes
(oversized/malformed/nested bodies, long and numerous headers, request smuggling, slow and idle
connections, slow upstream, unicode/obfuscated and indirect injection variants, output-side secret
placement, upstream failure); and the repo's own benchmark results and documented limitations
(README benchmark, docs/methodology.md, docs/garak-validation.md, phase 13/14 plans).
Rating scale (residual risk, after current mitigations): High — exploitable weakness with material impact, fix now · Medium — real weakness with limited impact or partial mitigation, fix planned · Low — controls in place; minor or theoretical gaps · N/A — not applicable to this project, with the reason stated.
For a firewall, each LLM risk has two angles, rated together: (a) protection it provides to the application behind it, and (b) exposure of the firewall itself.
| # | Category | Residual risk |
|---|---|---|
| LLM01 | Prompt Injection | 🟠 Medium |
| LLM02 | Sensitive Information Disclosure | 🟠 Medium |
| LLM03 | Supply Chain | 🟢 Low |
| LLM04 | Data and Model Poisoning | 🟠 Medium |
| LLM05 | Improper Output Handling | 🟢 Low |
| LLM06 | Excessive Agency | 🟠 Medium |
| LLM07 | System Prompt Leakage | 🟠 Medium |
| LLM08 | Vector and Embedding Weaknesses | ⚪ N/A |
| LLM09 | Misinformation | ⚪ N/A |
| LLM10 | Unbounded Consumption | 🟠 Medium |
| # | Category | Residual risk |
|---|---|---|
| A01 | Broken Access Control | 🟠 Medium |
| A02 | Security Misconfiguration | 🟠 Medium |
| A03 | Software Supply Chain Failures | 🟢 Low |
| A04 | Cryptographic Failures | 🟠 Medium |
| A05 | Injection | 🟢 Low |
| A06 | Insecure Design | 🟢 Low |
| A07 | Authentication Failures | 🟢 Low |
| A08 | Software or Data Integrity Failures | 🟠 Medium |
| A09 | Security Logging and Alerting Failures | 🟠 Medium |
| A10 | Mishandling of Exceptional Conditions | 🟠 Medium |
Overall (both lists): 0 High, 12 Medium, 6 Low, 2 N/A (originally 2 High / 12 Medium / 4 Low;
LLM01 and A06 re-rated to Medium after the streaming fix in #34 and the ML-wiring fix in #35; LLM05 and
A06 re-rated to Low after the scanning-coverage fix in this PR). The project's engineering hygiene is
strong — memory-safe Rust with two unsafe blocks, no open RustSec findings that reach the build,
SHA-pinned CI, signed release images, and an audit log that verifiably records no prompt text or
credentials. The two original High ratings were about the security function itself — streamed
replies were barely inspected (fixed in #34) and the proxy never loaded the DeBERTa classifier (fixed
in #35); the request/response shapes that were still forwarded uninspected are covered by this PR.
What remains Medium is mostly deployment posture (bind/auth defaults, TLS, timeouts and limits), model
supply chain, and reply-side masking. ZAP found no High or Medium issues (details under A02).
(a) Protection
- ✅ Three-stage detector exists (
crates/core/src/detectors/injection/): regex signatures, heuristics, optional DeBERTa classifier. Policyblock-high-injection/block-ml-injectionblocks with HTTP 400 before anything reaches the upstream (policies/default.yaml); verified on a classic override phrase. - ✅ Normalization pre-pass (zero-width strip, NFKC + confusable folding; base64 opt-in) is on by default. Verified: Cyrillic-homoglyph, zero-width-split and full-width variants of a known phrase are blocked. Benchmark (rule layer, safe-guard): obfuscated recall 0.0% → 14.5%, base64 0.0% → 30.6%, at 0.00% FPR on a multilingual benign control (README "Obfuscation resilience").
- ✅ OpenAI
role: "tool"messages and the Anthropicsystemprompt are scanned as input. - ✅ The proxy attaches the ML classifier — fixed in #35. Was:
build_firewallnever calledwith_ml, so even anmlbuild ran the rule layer only (README "Default" row: 1.9% / 23.7% / 14.6% recall) while the README implied the "+ ML" figures. Now: anmlbuild loads the classifier frominjection.ml.model_dir(defaultmodels/injection, thresholdinjection.ml.threshold, default 0.5) and refuses to start if it is enabled but cannot load, unlessinjection.ml.optional: true; the startup log names the active stages. Verified with the real model: of the four probe classes that the rule layer forwarded, paraphrased and Spanish overrides are now blocked (400), benign controls pass.⚠️ Residual: leetspeak and base64-wrapped instructions are still forwarded (classifier limit; base64 decoding is an opt-in normalizer tier), and default builds — including release binaries and the Docker image — still run the rule layer only (README now says so). - ✅ Indirect content and tool definitions are scanned — fixed in this PR. Was: Anthropic
tool_result(string or nested blocks) anddocumentblocks were forwarded unscanned, so indirect injection via tool results was not covered on/v1/messages(contrary to the comment inpolicies/default.yaml); tool/function definitions were not scanned on either path; OpenAI content-part arrays were rejected with 422. Now (crates/proxy/src/pipeline.rs): Anthropictool_resultandsearch_resultcontent (recursively),documentblocks withtext,contentor base64text/*sources (decoded, scanned, re-encoded if masked) plus theirtitle/context, and on both endpoints everydescriptionintools[]/functions[](the tool's and those nested in its parameter schema) are scanned asinputwith the same detectors and policy as the prompt — the direction the pipeline already uses for retrieved and tool content. OpenAI content-part arrays are accepted:textparts are scanned and masked in place, image/audio parts forwarded unchanged;toolmessages may use either form. Regression tests (crates/proxy/tests/scan_coverage.rs) cover each shape, with benign controls (realistic tool descriptions with imperative phrasing, tool results, documents) that must pass.⚠️ Residual: documents and files the proxy cannot read as text (PDF, URL or file-id sources, OpenAIfileparts) and images are forwarded uninspected by default —input.reject_unscannable_documents: truerefuses them instead. Inmlbuilds the classifier now also sees tool descriptions; its false-positive rate on that text has not been measured. ⚠️ Each message is scored independently, so a phrase split across consecutive messages is not matched.⚠️ Known, documented ML limits that will apply once the classifier is wired in: deepset recall 41.4% (broad labels), JailbreakBench 0% (harmful goals, out of scope), base64 over-blocking on output (docs/garak-validation.md).
(b) Exposure
- ✅ The firewall has no model of its own on the request path, so it cannot be prompt-injected; the
agentfwjudge accepts only the two tokensINJECTION/DOCUMENTATIONand may only tighten a verdict.
Recommended: consider an ml variant of the release image so the "+ ML" recall is available
without building from source; pin and checksum the model (LLM04); consider a joined-conversation
scan; measure the classifier's false-positive rate on tool descriptions.
LLM01 stays Medium: the request shapes are now covered, but default builds (release binaries, Docker image) run the rule layer only, whose recall on paraphrased and multilingual injection is low.
(a) Protection
- ✅ Input: AWS key ids, GitHub PATs, Slack tokens, JWTs, private-key headers and high-entropy tokens are
detected (
crates/core/src/detectors/secret.rs); an AWS key in a prompt scored 93 and was blocked byblock-high-risk. Emails and card numbers are masked before forwarding — verified on the wire (‹EMAIL›,‹CREDIT_CARD›); a US-format phone number was not masked. - ✅ Output:
block-secret-outputblocks a secret in a non-streamed reply (verified on both endpoints, HTTP 502). - ✅ Every output surface of a non-streamed reply is scanned — fixed in this PR. Was: only
choices[0].message.content(OpenAI) and text blocks (Anthropic) were scanned; secrets in further choices (n > 1),tool_callsarguments /tool_useinput, or non-200 upstream bodies reached the client. Now non-streamed replies go through the same extraction as streams (crates/proxy/src/stream.rs,scan_reply): every choice's text andrefusal, eachtool_calls[].function.argumentsand the legacyfunction_call, and every Anthropic content block (text,tool_useinput serialised,thinking), each scanned as its own channel. Non-200 upstream bodies (streamed and non-streamed requests) are scanned on the output policy and replaced by the firewall's 502 error when they match; clean error bodies keep their status and content. ⚠️ PII masking applies to input only:decide_outputacts onblockalone, somask-piinever redacts PII in a reply (reply-side masking depends oncrates/core/src/masking.rs, being changed in a separate PR).⚠️ No provider-specific patterns for OpenAI (sk-…) or Anthropic (sk-ant-…) keys; they rely on the generic entropy gate.
(b) Exposure
- ✅ The audit log records decision, score, reason labels, OWASP/ATLAS tags and latency only — verified
over ~1,100 requests (ZAP + probes): no prompt text, no secret values, no
Authorizationheader. - ✅ Client credentials (
authorization,x-api-key, vendor headers) are forwarded only to the configured upstream (handlers.rs:88-112); the proxy never stores or injects keys of its own. ⚠️ The listener is plain HTTP and binds0.0.0.0by default, so API keys cross the network in clear unless the proxy is a loopback sidecar or behind TLS (see A04).- ✅ Upstream failures return a generic error — fixed in this PR. Was: with
fail_mode: fail_closedthe 502 body included the transport error, naming the internal upstream URL. Now the client getsupstream error (fail_closed)/bad upstream body; the detail is logged only.
Recommended: add output-side masking; add sk-/sk-ant- patterns.
LLM02 stays Medium: output coverage is closed, but replies are never masked (only blocked) and the
default plain-HTTP 0.0.0.0 listener exposes client keys in transit.
- ✅
cargo deny --all-features check: advisories, bans, licenses and sources all ok. Four documented ignores indeny.toml, all in the optionalmltree:paste(RUSTSEC-2024-0436, compile-time proc-macro, unmaintained) andzipRUSTSEC-2026-0367/0368/0369 (archive extraction; candle only reads entries and the project loads.safetensors, soextract()is unreachable). - ✅
Cargo.lockcommitted with h2 0.4.20 and rustls 0.23.46;serde_yamlreplaced byserde-saphyr, removingunsafe-libyaml(#22). Registry and git sources are allow-listed; license allow-list is explicit. - ✅ CI (#23): every action pinned to a commit SHA, least-privilege tokens, CodeQL (Rust + Actions,
security-extended), full-history gitleaks with a narrow path-and-value allowlist for synthetic fixtures, ClamAV over the source and fetched crates, Dependabot for cargo/actions/docker, digest-pinned Docker bases. Dependabot, secret-scanning and code-scanning: 0 open security alerts (the 6 open code-scanning alerts are Scorecard posture checks, below). - ✅ Fuzzing: cargo-fuzz targets for request parsing, SSE streams, detectors/masking, policy YAML, agentfw hooks and MCP manifests run on PRs and weekly (
.github/workflows/fuzz.yml,docs/fuzzing.md). ⚠️ Scorecard "Vulnerabilities" scores 1/10 because its OSV scan readsCargo.lockas a flat list: it counts the four ignored advisories above plus fivequinn-proto0.11.16 advisories (RUSTSEC-2026-0338…0342).quinn-protoappears in the lockfile via reqwest's optional HTTP/3 support but is not in the resolved build graph (cargo tree -i quinn-proto --all-features --target allis empty; it is not compiled). Not exploitable, but it is noise in every scanner.⚠️ ML weights are a separate supply chain — see LLM04.
Recommended: cargo update -p quinn-proto (≥ 0.11.17) to clear the scanner noise; drop the zip
ignores when candle moves to zip ≥ 9.
- ✅ Nothing is trained or fine-tuned on traffic; no feedback loop can poison the detectors at runtime. The default build loads no model at all.
- ✅ Models are loaded from
.safetensors(no pickle code execution). ⚠️ No integrity check on model files.scripts/fetch-model.sh:21downloads from…/resolve/main/…— an unpinned branch, no revision hash, no checksum — andHF_HOSTcan redirect to a third-party mirror. A changed upstream repository or a tampered mirror would silently replace the classifier that makes security decisions (e.g. one that always scores "safe").⚠️ The twounsafeblocks (crates/core/src/detectors/injection/ml.rs:33,crates/core/src/detectors/moderation.rs:66) memory-map weights withVarBuilder::from_mmaped_safetensors; if the file is modified or truncated while mapped, behaviour is undefined (crash or silently wrong weights). Exposure requires write access to the model directory.- ✅ A configured model that fails to load now refuses startup (injection classifier and output
moderation), unless the block sets
optional: true(fixed in #35).
Recommended: pin model revisions (commit SHA) and verify SHA-256 in fetch-model.sh and again at
load; keep the model directory read-only to the service user (read-only volume in containers).
(a) Protection
- ✅
OutputDetector(crates/core/src/detectors/output.rs) blocks destructive shell (rm -rf, pipe-to-shell), HTML/JS (<script>, event handlers) and markdown-image exfiltration in replies viablock-dangerous-output; the phase 14 control check shows each artifact blocks at High/Critical. - ✅ Injection rules are scoped to
direction: inputafter phase 13 found they over-blocked 16% of legitimate security answers on replies (README "What this phase fixed"). - ✅ All output surfaces are inspected, streamed (#34) and non-streamed (this PR): every choice,
tool-call arguments, Anthropic
tool_useinput andthinking, and non-200 error bodies (see LLM02). Behaviour note: a tool call whose arguments contain a dangerous artifact (e.g. a destructive shell command) is now refused in non-streamed replies too, as it already was in streams. ⚠️ Documented limit (phase 14 plan): harmful operational prose with no executable artifact is covered only by the opt-in moderation model, which caught 84.0% at threshold 0.8 with 0.0% over-block; the worst case scored 0.467 and evades at both 0.8 and 0.5.- ✅ Moderation
blockreturns HTTP 502 — fixed in this PR. Was: HTTP 200 with an error envelope, so clients that only check the status code treated it as success. Now it uses the same status and protocol-native error envelope as every other output block. (Streams end with the protocol's error event, unchanged.)
(b) Exposure
- ✅ The proxy never renders or executes model output; it forwards JSON/SSE verbatim.
Recommended: apply output mask actions to replies (LLM02).
Re-rated Low (was Medium): every output surface is now inspected on both paths and blocks are unambiguous; what remains is the documented recall limit on artifact-free harmful prose.
(a) Protection
- ✅ Agent firewall (
crates/agent,agentfw): taint tracking, action classification, egress allowlists, subagent authority containment, MCP manifest pinning (drift, shadowing, description poisoning). Benchmark: 73.7% detection (14/19) at 0.0% FPR (0/21) on the hand-authored 40-session corpus; known misses are tainted exfil to a novel host without the judge,destructive-from-taint0/2 andmcp-poisoning1/2 (README "agent layer"). - ✅ Optional local judge tier: 100% / 4% FP on a 50-sample corpus, tighten-only, loopback-only.
⚠️ Everything is opt-in and shadow-first:agentfwdefaults toenforce: false; the proxy'sagent_inspectionis off and, when on, shadow-only unlessenforce: true(crates/proxy/src/config.rs). (It now also covers streamed responses — see A06.) Out of the box the proxy enforces nothing at the tool boundary.⚠️ Documented judge limit: an injection after the first 4 KB of a page is not seen (max_span_bytes: 4096).
(b) Exposure
- ✅
agentfwrefuses non-loopback binds at parse time, requires a 256-bit bearer token compared in constant time, creates the token and audit log0600, caps body size and restricts the judge URL to loopback with exact host matching (crates/agentfw/src/config.rs,token.rs). ⚠️ agentfwfails open on internal errors and on bodies over the cap (by design, documented).
Recommended: document an "enforce" deployment profile; pursue the span-cache fix the judge design already identifies.
- ✅ A "reveal/print your system prompt" signature exists and feeds the risk score; combined with an override phrase it blocks (score 90).
⚠️ On its own the exfiltration request is Medium severity and scored 58 — allowed (below the 85block-high-riskthreshold). There is no output-side check for system-prompt echo, so leakage protection depends on the model.- ✅ Exposure: the proxy holds no system prompt; the
agentfwjudge prompt never includes the action and only a two-token answer is accepted.
Recommended: an opt-in output rule that blocks replies reproducing a configured system prompt (canary token or n-gram overlap).
The firewall has no vector store, embeddings or retrieval pipeline. Retrieved content an application passes in messages is inspected as input under LLM01.
Fact-checking model output is outside the firewall's purpose; no detector claims it. (The accuracy of the project's own claims is addressed under LLM01: the README "+ ML" detection numbers do not describe the proxy as shipped.)
(b) Exposure
- ✅ Request bodies are capped by axum's default 2 MiB limit (3 MiB → 413; a 1.9 MiB prompt was scanned in
~40 ms); JSON nesting is bounded by serde's recursion limit (10,000-deep → 400); headers are bounded
(200 headers → 431, 1 MiB header → connection closed); the
regexcrate is linear-time, so no ReDoS. ⚠️ No timeouts anywhere: the upstream client isreqwest::Client::new()with no timeout (crates/proxy/src/main.rs:18) — a 20 s upstream stall was waited out in full; an idle connection that sent no bytes was still open after 70 s, and a body trickled at 1 byte/s was held for 25 s.⚠️ No connection or concurrency limit (300 concurrent idle connections were accepted) and no rate limiting; detectors run synchronously on the async worker threads.
(a) Protection
⚠️ No per-client quotas, token budgets ormax_tokensceiling, so the firewall does not limit spend or denial-of-wallet against the upstream.
Recommended: set connect/read/total timeouts on the upstream client; add a header-read timeout and
request timeout (tower-http TimeoutLayer), a concurrency limit and an optional per-key rate limit;
optional policy for a maximum max_tokens.
- ✅ The proxy exposes exactly two
POSTroutes (crates/proxy/src/lib.rs:48-53); there are no admin, config or debug endpoints; other methods → 405, other paths → 404,..paths are not normalized into routes. Upstream URLs are fixed by config, so there is no SSRF via request input. ⚠️ No access control on the proxy: anyone who can reach the port can relay requests. With hosted providers the caller still needs a valid key, but in front of a key-less internal model server the proxy grants open access to it.⚠️ Binds0.0.0.0:8080by default (config.rs:139-141,firewall.yaml,deploy/Dockerfile); the Kubernetes sidecar example setsLLM_FW_BIND=0.0.0.0:8080even though the app talks tolocalhost, exposing the proxy on the pod IP to the whole cluster (deploy/k8s-sidecar.yaml).- ✅
agentfwis loopback-only with token auth (see LLM06).
Recommended: default bind to 127.0.0.1 and use it in the sidecar example; optional inbound
client authentication (static key or mTLS) for shared deployments; a NetworkPolicy example.
ZAP on the running proxy: baseline — 0 High, 0 Medium, 0 Low, 1 Informational alert type (66 rules
passed; the root URL is a 404, so the spider has little to crawl). API scan (OpenAPI, active rules
over ~1,100 requests) — 0 High, 0 Medium, 2 Low (X-Content-Type-Options missing,
Cross-Origin-Resource-Policy missing), 3 Informational; 117 rules passed; no crash, no 5xx from the
proxy itself.
- ✅ Safe defaults where it matters:
fail_mode: fail_closed, normalization on, moderation and agent inspection opt-in withflag/shadow first. ⚠️ 0.0.0.0default bind and plain-HTTP listener (A01, A04); the container runs as root (noUSERindeploy/Dockerfile).⚠️ No health/readiness route (/health→ 404), so orchestrators can only probe TCP.⚠️ MissingX-Content-Type-Options: nosniff/Cross-Origin-Resource-Policyon responses (low impact for a JSON API).
Recommended: non-root user in the image; /healthz; add the two headers; document a hardened
deployment profile (loopback/sidecar, TLS in front, enforce modes).
See LLM03 for detail. RustSec clean for the built graph; lockfile committed; SHA-pinned actions; CodeQL + gitleaks + ClamAV + Dependabot; digest-pinned bases; cosign-signed release images with SLSA provenance and SBOM. OpenSSF Scorecard 6.4/10; remaining gaps: Code-Review 0 and Maintained 0 (single maintainer, new repository), Fuzzing 0, CII Best Practices 0, Vulnerabilities 1 (the lockfile noise explained under LLM03), SAST 7 (CodeQL only recently added).
Recommended: clear the quinn-proto lockfile entry; add cargo-fuzz targets for the JSON request
models, normalizer and detectors; apply for the OpenSSF Best Practices badge.
- ✅ Upstream TLS via rustls 0.23.46 with webpki roots (no OpenSSL); default upstreams are
https://. - ✅
agentfwtoken: 256 bits from the OS-seeded RNG, constant-time comparison,0600file. ⚠️ The proxy has no TLS listener option; client API keys reach it in clear unless it runs on loopback / in the same pod or behind a TLS terminator, and the default bind is0.0.0.0.⚠️ No check that upstream bases arehttps://; anLLM_FW_OPENAI_BASE=http://…override silently forwards keys in clear.
Recommended: refuse non-https upstreams unless the host is loopback (or an explicit
allow_insecure_upstream); optional rustls listener, or document TLS termination as required for
non-loopback deployments.
- ✅ Requests are parsed into typed structs and re-serialized before forwarding, so the upstream never
sees bytes the firewall did not parse (no JSON parser differential; duplicate
Content-LengthandCL+TEsmuggling attempts → 400 from hyper). - ✅ No SQL, shell, template or filesystem sinks on the request path; audit records are serialized JSON
(no log injection); CodeQL
security-extended: 0 open alerts. ⚠️ Deserialization errors echo the JSON path and serde message back to the client astext/plain(e.g.messages[0].content: invalid type …) — minor reflection, no HTML context.
The assessment found design-level gaps where traffic passed without the inspection the documentation described, each reproduced against the release build with a mock upstream. All are now closed.
- ✅ Streamed output is inspected on the reassembled text — fixed in #34.
Was: the SSE scanner matched raw upstream bytes (JSON frames included) in a 256-byte tail window and
forwarded each chunk right after its scan, so a secret split across delta frames reached the client;
moderation and agent inspection did not run on streams. Now (
crates/proxy/src/stream.rs): SSE events are parsed incrementally; reply text, tool-call arguments (OpenAIdelta.tool_calls, Anthropicinput_json_delta) and thinking are reassembled per choice / content block and scanned with the same output policy as non-streamed replies; an event is released only once its text isstream_holdback_bytes(default 256) older than the newest text and has been scanned in context, so no part of a match up to that length is ever sent; each channel gets a full scan when it finishes. A block ends the stream with a protocol-native error event and drops the upstream. Agent inspection runs on the reassembled tool calls (held until the verdict when enforcing) and output moderation on the reassembled reply (streams held in full whenaction: block). Regression tests: split AWS key over 3+ OpenAI / Anthropic delta frames, split tool-call arguments, clean byte-for-byte passthrough. Residual: a match longer than the hold-back (e.g. a risk score that only crosses the threshold over the whole reply) is caught at the channel's final scan, after its start may have been released; outputmaskactions are not applied to replies (same as non-streamed, LLM02). - ✅ ML stage wired into the proxy, fail-closed (LLM01; fixed in #35). With the streaming fix (#34) both High items were closed and A06 was re-rated Medium.
- ✅ Full response inspection — fixed in this PR. Non-streamed replies share the stream path's
extraction, so both paths inspect the same surfaces: every choice, tool-call arguments, Anthropic
tool_use/thinkingblocks; non-200 bodies are scanned too (LLM02). - ✅ Full request inspection — fixed in this PR. Anthropic
tool_result/search_result/ textdocumentcontent, tool definitions on both endpoints, and OpenAI content-part arrays (now accepted instead of 422) are scanned as input (LLM01). "Every text-bearing field is inspected" is pinned by per-shape regression tests with benign controls (crates/proxy/tests/scan_coverage.rs). Residual (documented, opt-in refusal): content that is not text — PDF/URL/file document sources, OpenAIfileparts, images — is forwarded uninspected unlessinput.reject_unscannable_documents. A06 is re-rated Low. - ✅ Good design elsewhere: fail-closed default, dual-scan normalization (obfuscation alone is never a block reason), direction-scoped policies, tighten-only judge, shadow-first rollout of new enforcement.
Recommended: extend the per-shape tests whenever the providers add a text-bearing field; consider
turning reject_unscannable_documents on in hardened profiles.
- ✅ The proxy has no authentication mechanism of its own to break; it passes client credentials through and the upstream provider authenticates them. (The lack of inbound access control is rated under A01.)
- ✅
agentfwuses a 256-bit bearer token, strictBearerparsing, constant-time compare and0600storage;agentfw installprints the token by environment variable, never literally.
- ✅ Release images are signed keylessly with Sigstore cosign; the workflow verifies its own signature and
attaches SLSA provenance (
mode=max) and an SBOM (.github/workflows/release.yml).Cargo.lockis committed. ⚠️ ML model files have no pinning or hash verification, and are memory-mapped withunsafe(LLM04).- ✅ Branch protection on
mainrequires 6 passing checks (test, deny, gitleaks, CodeQL rust/actions/ results) and verified signed commits, and blocks force-push and deletion.⚠️ No required reviews (single maintainer). ⚠️ deploy/Dockerfilebuilds without--locked.
Recommended: require the CI, deny, gitleaks and CodeQL checks and signed commits on main;
cargo build --locked in the Dockerfile; model pinning (LLM04).
- ✅ One structured JSON audit record per decision with score, reason labels, OWASP LLM and MITRE ATLAS
tags and latency (
crates/proxy/src/audit.rs); verified to exclude prompt text, secrets and credentials.agentfwkeeps an append-only0600JSONL audit log withreplaytooling. - ✅ Streaming requests now get the same input audit record as non-streaming ones, written before the
stream starts, plus an output
stream_done/stream_error/blockrecord (fixed in #34). - ✅ Rejections and upstream errors are audited — fixed in this PR. Request bodies the proxy
cannot accept (400 malformed JSON, 413, 415, 422) get an input
rejectrecord (status only — never the serde message, which can quote request content); every forwarded request gets its inputallowrecord before forwarding; non-200 upstream replies and transport failures get an outputupstream_errorrecord with the status, orblockwhen the error body was refused. (Unknown routes and wrong methods — 404/405 — are still not audited.) ⚠️ Records carry no client identity (address or key fingerprint), thereq-Ncounter restarts with the process and is not returned to the client, and there is no metrics/alerting hook.
Recommended: add a client identifier (hashed key or address), a UUID request id echoed in a response header, and counters for blocks by rule.
- ✅ Upstream failure → 502 fail-closed (non-streaming and streaming); malformed JSON → 400; schema
errors → 422; no panics observed under ZAP or probes;
unwrap/expecton the request path are limited to a static response builder and the agent mutex. - ✅ Upstream-failure bodies are generic and use the endpoint's own error envelope; the detail, which names the upstream URL, is logged only (LLM02; fixed in this PR).
⚠️ fail_mode: fail_opendoes not change behaviour beyond the error text (both modes return 502), so the option is misleading.- ✅ An enabled model (injection ML or output moderation) that cannot load refuses startup instead of
silently disabling itself, unless
optional: true(fixed in #35). ⚠️ The proxy's agent mutex useslock().expect(…)(handlers.rs:46): after any panic while held, every later inspected request panics (only whenagent_inspection.enabled).⚠️ No timeouts (LLM10).
Recommended: implement or remove fail_open; recover from a poisoned mutex or use parking_lot.
| Control | Where | Runs |
|---|---|---|
cargo fmt, clippy -D warnings, cargo test --all --all-features |
ci.yml (test) |
every PR, push to main |
cargo deny check (RustSec advisories, unmaintained, licenses, bans, sources; all features) |
ci.yml (deny) |
every PR, push to main |
gitleaks over full git history (narrow fixture allowlist in .gitleaks.toml) |
ci.yml (gitleaks) |
every PR, push to main |
CodeQL security-extended (Rust + GitHub Actions) |
codeql.yml |
every PR, push to main, weekly |
ClamAV malware scan (source + fetched crate sources, incl. ml tree) |
malware.yml |
every PR, push to main, weekly |
| OpenSSF Scorecard (SARIF to code scanning) | scorecard.yml |
push to main, weekly |
| Dependabot version updates (cargo, actions, docker) + security updates | dependabot.yml / repo settings |
weekly / continuous |
| Secret scanning + push protection | GitHub settings | every push |
| Private vulnerability reporting | GitHub settings | continuous |
| Signed release image (cosign keyless), SLSA provenance, SBOM | release.yml |
every v* tag |
| OWASP ZAP baseline + API scan | manual, this assessment | 2026-10-18 (not yet in CI) |
OpenSSF Scorecard: 6.4/10 (2026-10-18). Branch protection requires the CI, cargo-deny, gitleaks and CodeQL checks above (merge-blocking) and signed commits, but not reviews.