Snout runs an LLM that ingests untrusted user input and untrusted web-search results, and exposes a paid, compute-heavy endpoint. This document records the threat model and the controls implemented against it. Controls are mapped to the OWASP Top 10 for LLM Applications (2025) and the OWASP API Security Top 10 (2023).
| Asset | Threat | Primary actor |
|---|---|---|
| The agent's reasoning | Prompt injection — a malicious vendor page, search result, app name, or context tries to override instructions, fake "supported" verdicts, or inject links | Anyone who controls a web page the agent might read |
| Rendered/chat output | Link & mention injection, XSS via citation URLs | Same |
/api/assess |
Cost abuse / DoS via floods of expensive LLM calls | Unauthenticated internet |
| The API generally | Anonymous access, data exfiltration, enumeration | Unauthenticated internet |
| Webhooks | Forged catalog/chat events triggering assessments | Anyone who finds the URL |
| Server-side fetches | SSRF to internal services / cloud metadata | Via attacker-supplied URLs |
| Secrets | Anthropic key / webhook secrets leaking | Logs, error responses, client |
The model can't separate instructions from data, so we don't rely on it to.
- Segregate untrusted content. All user fields are sanitized and wrapped in an explicit
<<UNTRUSTED_INPUT>>fence; the system prompt states that those fields and all web-search results are data, never instructions (agent.ts). - Constrain behavior. A high-priority security preamble tells the model to refuse embedded instructions, never change output format, never reveal the prompt, and mark
unknownrather than trust an unverified claim. - Deterministic output validation (
security/schema.ts) is the load-bearing control: the model's JSON is parsed through a strict zod schema that coerces verdicts to a known enum, clamps every string and array length, and drops any citation whose URL isn't a safe public http(s) link. A successful injection still can't emit arbitrary or oversized content. - Least-privilege tooling. The only tool is read-only
web_search, capped at 6 uses. The agent cannot act on any internal system; its output is data that a human approves. - Telemetry. Injection-like input is logged (not blocked, to avoid false positives on real app names).
- React escapes all rendered text; the only
dangerouslySetInnerHTMLis a static CSS string. - Citation links are re-validated client-side and rendered
rel="noopener noreferrer nofollow". - Text sent to Slack/Teams runs through
forChat()— escapes& < >, strips@channel/@here/@everyone— preventing link and broadcast-mention injection.
/api/*requiresAuthorization: Bearer <API_TOKEN>when a token is set; comparison is constant-time.- In production the server refuses to start without
API_TOKENunlessALLOW_ANON=trueis explicitly set (for deployments behind an authenticating gateway). No anonymous access by default. - Roles (EPIC-ENTERPRISE): the admin
API_TOKENcan mutate; an optionalAPI_VIEWER_TOKENis read-only — awriteGuardrejects any non-GET from a viewer (403), and the audit log is admin-only. Every mutating call is recorded to a tamper-evident-by-append audit log (who/role/tenant/path/outcome). - OIDC login (optional): when fully configured (
OIDC_ISSUER+ client id/secret +OIDC_REDIRECT_URI+SESSION_SECRET), users authenticate via your IdP using Authorization Code + PKCE withstateandnoncechecks. A short-lived signed (jose HS256) cookie carries the PKCE/state/nonce across the redirect; on success a signed, httpOnly,sameSite=lax,secure-in-prod session cookie (8 h) authorizes API calls alongside bearer tokens. Only ID-token claims are used for identity — no IdP access/refresh tokens are stored. Role is derived from a configurable claim (OIDC_ADMIN_VALUE); IdP/validation errors are never echoed to the browser. A partial OIDC config fails closed at startup, andSESSION_SECRETmust be ≥32 chars in production. - Per-tenant data isolation: the audit log's
TENANT_IDis one input; real isolation is enforced by the Postgres store (setDATABASE_URL). Every table has a leadingtenantcolumn and every query is scopedWHERE tenant = $1, so one tenant cannot read or write another's rows. The request tenant comes from the authenticated bearer'sx-tenant(operator-trusted) or, for OIDC users, the session's own tenant claim (whichx-tenantcannot override). The default JSON store is single-tenant;server/db/schema.sqldocuments the schema and an optional row-level-security backstop.
- Per-client rate limits (keyed by token hash or proxy-aware IP): a general
/apibucket and a stricter/assessbucket, plus a separate webhook bucket. - A concurrency semaphore caps simultaneous in-flight agent runs (
429when exhausted). - Request bodies capped (
BODY_LIMIT, default 64 kB); agent calls have a 90 s timeout and a boundedmax_tokens.
- Every untrusted URL — the user-supplied one and every agent citation — passes
safeUrl(): http(s) only, no embedded credentials, and private / loopback / link-local / cloud-metadata (169.254.169.254) hosts blocked. The server does not fetch user URLs today; this hardens the citation surface and pre-empts addingweb_fetch. - The LLM endpoint base URL (
ANTHROPIC_BASE_URL/LLM_BASE_URL) is operator-trusted configuration, sourced only from the server's environment and never from request data. It legitimately needs to reach internal gateways (LiteLLM, vLLM, Ollama on127.0.0.1/10.x/[::1]), so it intentionally does not go through thesafeUrl()private-host block. It still gets a lighter check viasafeBaseUrl()— http(s) only and no embedded credentials — validated at startup (fail closed). This keeps the boundary clean:safeUrl()stays strict for untrusted input/citation URLs; only the trusted base URL is exempt from the host block.
- The LLM call sits behind a provider abstraction, but
validateAgentOutput()always runs on every provider's output — the security schema can't be bypassed by switching providers. Prompt fencing, input sanitization, and injection telemetry stay inagent.tsand apply to all providers. - Only the Anthropic path has live
web_search. With a provider that lacks it, assessments run with reduced grounding: a deterministic post-validation guard drops all citations and downgrades any unprovensupported/partialverdict tounknown(recommendation capped atHold), because a non-search model cannot have retrieved evidence and the schema does not verify citation provenance. The grounding mode is recorded on each assessment. - Provider error responses are never returned to the client or used as the thrown message (a third-party gateway could echo auth headers/keys); the upstream detail is logged server-side only. API keys, bearer tokens, and base URLs are never logged or exposed via
/healthor/api/config.
- Every discovery/catalog webhook (
/webhooks/catalog/:source,/webhooks/idp/:source,/webhooks/email) is HMAC-SHA256 verified againstSNOUT_WEBHOOK_SECRETover the raw body with a constant-time compare; routes fail closed (501) when the secret is unset and reject a bad/absentx-snout-signature(401). They share the rate-limited webhook bucket and the discovered routes are gated byENABLE_CATALOG. - Discovery is push-only: Snout ingests logs/emails your own pipeline forwards. It stores no IdP or mailbox credentials and makes no outbound calls to ingest — so there is no new SSRF surface and no third-party secret to leak.
- All ingested fields are length-clamped (
sanitizeField) and the app key is validated against a strict domain regex; events without a resolvable domain are skipped (and counted), not stored. Per-app history is capped so a chatty sensor can't grow the store unbounded. - IdP pull-pollers (
OKTA_*/ENTRA_*/GOOGLE_*, off by default) are the other outbound path: they call only your configured IdP (OKTA_LOG_URLissafeBaseUrl()-validated at startup; Entra uses fixed Microsoft hosts; Google uses fixedoauth2.googleapis.com/admin.googleapis.comhosts). The Okta SSWS token, Entra client secret, and Google service-account key live in env, are sent only to those hosts, and are never logged. Google auth uses a short-lived service-account JWT (RS256, signed locally viajose) exchanged for a read-only Reports scope under domain-wide delegation. Pulled records run through the same adapters +sanitizeUpsertas the push path.
- Refutation pass (
VERIFY_FINDINGS) is an internal LLM call over the model's own findings — no new external surface; output is parsed defensively and only ever demotes verdicts deterministically (never upgrades), and never touches human-verified KB facts. - Citation grounding (
CHECK_CITATIONS) is the one place Snout fetches an untrusted URL. It is SSRF-guarded: the URL passessafeUrl()(public http(s) only, private/loopback/metadata blocked) before the fetch, the fetch usesredirect: "manual"so a 3xx can't bounce it to a private host, and it is bounded by a timeout and a response-size cap. It only drops citations; an unfetchable page is kept (no false drops). Both passes are off unless explicitly enabled.
- The knowledge base (
kb/repo files + Store overrides) is trusted by provenance: repo files land via reviewed PRs and overrides come from the authenticatedPOST /api/kb/:key/:control. Even so, KB content is treated as data, not instructions: facts are injected into the agent structurally (verdict + standards + a sanitized one-line summary), every KB citation URL is re-checked withsafeUrl(), and KB text can never alter the model's instructions. - Only human-verified facts (
source: "human") are injected as trusted priors;seed/agentfacts are candidates surfaced for review, never auto-trusted.validateAgentOutput()still runs on the model output and remains unbypassable; the deterministic transparent-mean score is computed server-side from the merged result. - Loading is defensive: a malformed or schema-invalid KB file is skipped (not fatal), all fields are length-clamped, and per-app proposal writes never overwrite a human-verified fact.
helmetsecurity headers on the API;x-powered-bydisabled; strict CORS allowlist (WEB_ORIGIN).- A Content-Security-Policy plus
X-Frame-Options,nosniff,Referrer-Policy, andPermissions-Policyon the web tier (web/nginx.conf). - Central error handler returns generic errors with a request id — never stack traces or internals. Every response carries
x-request-id.
- Secrets live only in env / a secrets manager;
.envis gitignored; secrets are never logged or returned. - Lockfiles are committed; CI runs
npm audit; Dependabot keeps dependencies current.
-
NODE_ENV=productionand a strong, rotatedAPI_TOKEN(or a real auth gateway +ALLOW_ANON=true) - TLS terminated in front; HSTS enabled at the edge
-
TRUST_PROXYset to your proxy/ingress so rate-limit keys use real client IPs -
WEB_ORIGINrestricted to your actual front-end origin(s) - Webhook secrets set; catalog API tokens (Okta SSWS, ServiceNow, NetSuite) scoped read-only
- Tune
RATE_LIMIT_MAX,ASSESS_RATE_MAX,MAX_CONCURRENT_ASSESSMENTSto budget/quotas - Swap the JSON store for Postgres; back up
DATA_DIR/DB - Forward
x-request-idand the[audit]log lines to your SIEM
- Prompt injection cannot be fully eliminated — it's a property of LLMs. Verdicts are evidence-backed research, not sign-off; a human approves, and the auditable score + citations exist precisely so a reviewer can catch a manipulated result.
- The LLM base URL is trusted config and is exempt from the SSRF host block, so anyone who can already set the server's environment (insider / leaked deploy creds) could point it at an internal host or exfiltrate the provider key. This is an accepted trade-off for supporting internal gateways; it is not reachable from the API, since the base URL is never derived from request data.
- The default JSON store is single-node, last-write-wins, and single-tenant. Set
DATABASE_URLto use the Postgres store for durability and per-tenant isolation. - Teams outgoing webhooks are synchronous (~5 s); long assessments return "started" rather than a final card (see README).
Please open a private security advisory or email the maintainers rather than filing a public issue. We aim to acknowledge within 3 business days.