Open, transparent, IPSIE-aligned identity-trust scoring for SaaS vendors. Name a SaaS tool and the agent researches the live web — vendor docs, trust centers, the OpenID Foundation — then scores six IPSIE-aligned identity controls with citations and drafts a governance verdict your sourcing, finance, third-party risk, security architecture, and IT engineering teams can all trust. It exists to replace the slow, committee-driven (RAPID) review with something fast and evidence-backed.
The six controls map to the control areas of the OpenID Foundation's IPSIE (Interoperability Profile for Secure Identity in the Enterprise) — the emerging standard for how SaaS must interoperate with the enterprise identity fabric — each anchored to a concrete open standard:
| Control | Standard(s) | IPSIE area |
|---|---|---|
| Single Sign-On | SAML 2.0 / OIDC | Federated authentication |
| User Lifecycle | SCIM 2.0 (provision + deprovision) | Lifecycle management |
| Entitlements | SCIM groups / RBAC | Authorization & entitlements |
| Risk Signal Sharing | CAEP / Shared Signals Framework | Continuous access evaluation |
| Logout | RP-initiated / Single Logout | Session management |
| Token Revocation | OAuth 2.0 revocation / CAE | Credential & token management |
The trust score is a transparent mean of the six control weights (Supported 100 · Partial 55 · Unverified 25 · Not found 8) — auditable, not a black box. The full scoring spec is public in METHODOLOGY.md.
Alignment, not certification: Snout maps a vendor's posture to IPSIE-aligned control areas from public evidence; it does not assert IPSIE conformance on a vendor's behalf.
Snout owns one job and does it transparently: open, evidence-cited vetting of a SaaS vendor's IPSIE-aligned identity controls, for the buy/keep decision.
- ✅ It is a discovery and assessment tool — it sees and reports identity-control posture with citations, and produces a governance verdict a human signs off on.
- ❌ It is not an inline enforcer. Snout does not block sign-ins, sit in the browser request path, or terminate sessions. It complements your ITDR / SSPM and your IdP — it reports the posture gap; enforcement is handed off to those systems.
This boundary is deliberate: an open-source tool that reports and vets transparently is a better fit for the buy/keep decision than one that tries to enforce inline.
┌──────────────────────────────┐
Slack /snout ───────► │ │
Teams @mention ─────► │ server (Express) │ ──► Anthropic Messages API
ServiceNow ─┐ │ │ + web_search tool
Okta ───────┼──HMAC──►│ /api/assess → agent.ts │ ──► vendor docs · OIDF · trust pages
NetSuite ───┘ catalog │ /api/assessments → store.ts │
│ /webhooks /slack /teams │ ──► store (JSON file → Postgres)
└──────────────┬───────────────┘
│ REST /api
┌──────────────▼───────────────┐
│ web (React + Vite) │
│ Command Center · Assessments │
│ Detail · Integrations │
└───────────────────────────────┘
server/— Express + TypeScript. Runs the agent, persists assessments, and hosts the chat + catalog webhooks. By default the agent calls the Anthropic Messages API with the server-sideweb_searchtool, so the API key never reaches the browser. The LLM call sits behind a small provider abstraction (server/src/llm/), so it can also target any Anthropic-compatible endpoint or an OpenAI-compatible one — see Configuration.web/— React + Vite, the Obsidian Command console. Talks only toserverover REST.
Every entry point (UI, Slack, Teams, catalog webhook) funnels through the same
assessApp() and the same store, so a /snout Notion in Slack and a click in the
dashboard produce one identical record.
Prerequisites: Node 20.19+.
git clone <your-fork-url> snout && cd snout
npm install # root tooling (concurrently)
npm run install:all # installs server + web deps
cp server/.env.example server/.env # add ANTHROPIC_API_KEY
cp web/.env.example web/.env
npm run dev # server :8787 + web :5173 (proxied)Open http://localhost:5173, type an app name in the form / the sidebar terminal / the top search, and the agent goes to work (~20–40s).
No key yet? The dashboard is honest about it — the status badge shows Setup needed with a
banner naming the env var to set, and you can click Load sample data (or open ?demo=1) to
explore the whole UI with illustrative data offline. See Demo.
cp server/.env.example server/.env # add ANTHROPIC_API_KEY
docker compose up --build # web on :8080, server on :8787| Method | Path | Purpose |
|---|---|---|
GET |
/health |
liveness + active provider/model + assessReady (is a provider key configured?) |
GET |
/api/assessments |
list, newest first |
GET |
/api/assessments/:id |
one assessment |
POST |
/api/assess |
{ name, vendor?, url?, context? } → runs agent, returns the record |
DELETE |
/api/assessments/:id |
remove |
GET |
/api/catalog |
list discovered apps (enriched with any linked assessment) |
POST |
/api/catalog |
bulk-ingest discovered apps { apps: [...] } (used by the extension's Sync) |
POST |
/api/catalog/:domain/assess |
assess a discovered app and link the result |
DELETE |
/api/catalog/:domain |
remove a discovered app |
GET |
/api/catalog/export |
discovered apps + posture findings/risk score (SIEM/BI export) |
GET |
/api/kb |
all KB vendors (repo files + overrides) — powers the verification queue |
GET |
/api/kb/:key |
merged knowledge-base facts (repo file + overrides) for a vendor |
POST |
/api/kb/:key/:control |
human verify/override one control fact (source: human) |
GET |
/api/alerts |
monitoring alerts (breach/CVE feed + control regressions) |
DELETE |
/api/alerts/:id |
dismiss an alert |
GET |
/api/audit |
audit log of mutating calls (admin only) |
POST |
/webhooks/catalog/:source |
servicenow | okta | netsuite — HMAC signed |
POST |
/webhooks/idp/:source |
okta | entra | google sign-in/audit logs → discovered (HMAC) |
POST |
/webhooks/email |
forwarded signup/account email metadata → discovered (HMAC) |
POST |
/webhooks/breach |
SaaS breach/CVE feed → monitoring alerts (HMAC) |
POST |
/slack/snout |
/snout <app> slash command |
POST |
/teams/snout |
Teams outgoing webhook / bot |
/api/* can be protected with a bearer token by setting API_TOKEN. Alternatively, enable
OIDC login (OIDC_ISSUER + client id/secret + OIDC_REDIRECT_URI + SESSION_SECRET) so
users sign in via your IdP and a signed session cookie authorizes the dashboard; bearer tokens
keep working for API/CI clients. See /auth/login, /auth/callback, /auth/logout, /auth/me.
Inbound catalog — point ServiceNow Flow, an Okta Workflows job, or a NetSuite
saved search at /webhooks/catalog/:source, signing the body with SNOUT_WEBHOOK_SECRET
(x-snout-signature: <hex hmac-sha256>). Each record is normalized and queued for
assessment automatically.
Discovery sensors (IdP logs & email) — inventory apps + auth methods without the
browser extension by forwarding your own telemetry to HMAC-signed endpoints (same
SNOUT_WEBHOOK_SECRET / x-snout-signature scheme):
- IdP sign-in logs →
POST /webhooks/idp/:sourcewhere:sourceisokta|entra|google. Point an Okta System Log export, a Microsoft GraphauditLogs/signInsjob, or a Google Workspace Reports pull at it (or route them via your SIEM). Snout maps each event to a discovered app — auth method (SSO/OAuth), IdP, OAuth client + scopes — deduped by domain. Events without a resolvable app domain are skipped and counted (add adomainfield in your forwarder to capture those). - Signup / account emails →
POST /webhooks/emailwith{ messages: [{ from, subject, date }] }. Snout records the sender's domain as a discovered app when the mail looks like a signup/account notice; personal mailboxes and newsletters are ignored.
Pull-pollers (zero-touch) — instead of pushing, Snout can pull sign-in logs on a schedule.
Set IDP_POLL_INTERVAL_MINUTES plus OKTA_LOG_URL + OKTA_API_TOKEN (Okta System Log),
ENTRA_TENANT_ID/ENTRA_CLIENT_ID/ENTRA_CLIENT_SECRET (Microsoft Graph), and/or
GOOGLE_SA_CLIENT_EMAIL/GOOGLE_SA_PRIVATE_KEY/GOOGLE_ADMIN_SUBJECT (Google Admin SDK Reports,
read-only, via a locally-signed service-account JWT). Off by default; outbound calls go only to
your IdP's hosts, and credentials live in env and are never logged.
All sensors (extension, IdP logs, email) merge by domain into one discovered record with a
capped per-app history (events), surfaced in the dashboard's Discovered view. Discovery is
push-only — Snout stores no IdP/mailbox credentials and makes no outbound calls to ingest. These
routes require ENABLE_CATALOG (on by default).
Slack — create a slash command /snout, set its Request URL to /slack/snout,
and add SLACK_SIGNING_SECRET. The command acks instantly and posts the verdict back
to the channel when the agent finishes (via Slack's response_url).
Teams — add an Outgoing Webhook, paste its security token into TEAMS_SECURITY_TOKEN,
and mention @Snout assess <app>. Cached apps return instantly; new ones kick off
in the background. Because Teams outgoing webhooks are synchronous (~5s budget), a true
async reply needs a Bot Framework bot that stores the conversation reference and posts a
proactive message — routes/teams.ts marks exactly where to slot that in.
Browser extension (shadow SaaS & auth discovery) — extension/ is a Manifest V3
Chrome extension that watches how you authenticate as you browse and catalogs shadow
SaaS and shadow auth (local passwords, consumer/social IdP logins, OAuth consent
grants) — all local-first. Each discovered app has a one-click Assess in Snout
button that calls /api/assess. Load it via chrome://extensions → Load unpacked. See
extension/README.md.
See server/.env.example and web/.env.example. Each webhook route returns 501 until
its secret is set, so you can enable integrations one at a time.
The assessment agent runs through a provider abstraction (server/src/llm/). Anthropic is
the default and needs zero config change for existing setups (ANTHROPIC_API_KEY alone
behaves exactly as before).
LLM_PROVIDER=anthropic(default) — Anthropic Messages API with nativeweb_search. SetANTHROPIC_BASE_URLto run through a proxy, an API gateway, or an internal Anthropic-compatible endpoint (e.g. LiteLLM in Anthropic mode).ANTHROPIC_API_KEYandANTHROPIC_MODELare unchanged.LLM_PROVIDER=openai— any OpenAI-compatible/v1/chat/completionsendpoint, set viaLLM_BASE_URL(server root, no path — Snout appends/v1/chat/completions),LLM_API_KEY, andLLM_MODEL. Covers OpenAI, LiteLLM, OpenRouter, and local servers like vLLM and Ollama (e.g.LLM_BASE_URL=http://localhost:11434).
Misconfiguration fails closed at startup (e.g. LLM_PROVIDER=openai without LLM_BASE_URL).
Web search & grounding. Only the Anthropic path has live web search. With an OpenAI-compatible provider, assessments run with reduced grounding: Snout instructs the model to cite nothing and prefer
unknown, and then deterministically drops citations and downgrades any unprovensupported/partialverdict tounknown(capping the recommendation atHold). Each assessment records itsgroundingmode (web_search|reduced). For grounded assessments off Anthropic, front an Anthropic-compatible gateway, or implement the optionalsearch()seam on a provider to plug in an external search step.
ANTHROPIC_BASE_URL / LLM_BASE_URL are operator-trusted config — they may point at
internal hosts (that's the point of supporting gateways), so they are not subject to the
SSRF private-host block that applies to untrusted user/citation URLs. See SECURITY.md.
Default is JSON files under DATA_DIR (assessments, discovered, kb, alerts, audit) —
zero setup, single-tenant, fine for small teams. Set DATABASE_URL to switch to the built-in
Postgres store (server/src/store.pg.ts): a table per collection with JSONB data and
tenant-scoped keys, schema auto-created on first use (server/db/schema.sql is the reference).
The Postgres store also provides per-tenant data isolation: every row carries a tenant
column and every query is scoped WHERE tenant = $1, so one tenant can never read or write
another's data. The request tenant is resolved by withTenant (via AsyncLocalStorage) from the
bearer client's x-tenant header or an OIDC user's session claim. The JSON store ignores tenant
(single-tenant).
Two ways to explore Snout with sample data and no API key:
-
In the running app — click Load sample data on the empty Command Center (or open the dashboard with
?demo=1, e.g.http://localhost:5173/?demo=1, a handy share link). The dashboard fills with illustrative assessments + discovered apps; Run assessment synthesizes locally. A Sample data banner and Exit demo make it clear nothing is real. -
As a standalone file — build a self-contained, offline demo (no server at all):
cd web && npm run build:demo # produces web/dist/index.html — open it in a browser
A prebuilt
snout-demo.htmlis also included at the repo root for convenience.
The demo data is illustrative only; see DISCLAIMER.md.
This app runs an LLM over untrusted web content and exposes a compute-heavy endpoint, so it ships hardened by default. Highlights:
- Prompt injection (OWASP LLM01): untrusted input is fenced and labelled as data; the system prompt refuses embedded instructions; and the model's JSON is run through a strict schema that coerces verdicts, clamps lengths, and drops unsafe citation URLs. Least-privilege tooling (read-only
web_search). - Auth (API2):
/api/*requires a bearer token; the server fails closed in production (won't start withoutAPI_TOKENunlessALLOW_ANON=true). - Resource abuse (API4/API6): per-client rate limits, a stricter
/assessbucket, a concurrency cap, body-size limits, and request timeouts. - SSRF (API7): all URLs (input + citations) are allowlisted to public http(s); private/loopback/metadata hosts blocked.
- Misconfiguration (API8): helmet + CSP + strict CORS; leak-free error handler; request ids.
- Output handling (LLM05): Slack/Teams text is escaped and broadcast-mentions stripped; citation links are re-validated client-side.
Full threat model, control mapping, and a deployment hardening checklist are in SECURITY.md. Verdicts are evidence-backed research, not sign-off — a human approves.
Snout keeps an open, verified knowledge base of IPSIE-control support per vendor under
kb/ — one JSON file per vendor, community-contributable via PR (see
kb/README.md). This is what makes assessments compound:
- The agent reads human-verified KB facts first (injected as trusted, structured priors — never as free-text instructions) and researches only the gaps, returning a per-control confidence. Verified facts are authoritative and reused across every future assessment.
- Each control carries provenance (
kb-verified·agent·kb-proposed). Reviewers verify or override a control from the dashboard orPOST /api/kb/:key/:control; agent findings are stored as unverified proposals so the KB grows over time. - Accuracy is measured, not asserted, and bias-resistant: the eval reports held-out
(never-in-KB) accuracy and a baseline comparison — naive floor vs KB-only vs (with
--live) no-KB-model vs KB-augmented-model, plus the resulting KB lift — alongside per-control accuracy, precision/recall, confusion, and calibration. - The KB compounds:
npm run seed:kbbatch-runs the agent over a vendor list to generate unverified proposals at scale; the dashboard Knowledge view is a verification queue (prioritized most-unverified-first, with a verified-% bar and per-vendor bulk-verify) where a human promotes proposals to verified (and flags facts older than 180 days as stale).npm run kb:statsreports coverage and the human-verified ratio.
cd server
npm run kb:validate # validate every kb/<domain>.json against the schema
npm run kb:stats # coverage + human-verified ratio + fully-verified vendors
npm run eval # KB-only accuracy vs the benchmark → writes kb/EVAL.md
npm run eval -- --baseline # add the naive/KB-only baseline comparison + held-out split
npm run eval -- --live --baseline # also run the model with/without the KB → KB lift (uses your provider)
npm run seed:kb # batch-generate KB proposals from scripts/seed-vendors.json (live; needs a key)Current measured numbers live in kb/EVAL.md; the label-independence discipline is in server/eval/README.md. The scoring itself stays the transparent mean documented in METHODOLOGY.md.
Two off-by-default passes deepen single-assessment trustworthiness (each adds latency/cost on the web-search path):
VERIFY_FINDINGS=true— an adversarial refutation pass: a second LLM call tries to refute each verdict, and Snout deterministically demotes anything it can't defend tounknown(capping the recommendation). Human-verified KB facts are never demoted.CHECK_CITATIONS=true— fetches each cited page (SSRF-guarded:safeUrl()public-only +redirect: manual+ timeout + size cap) and drops citations that don't actually mention the control/standard. Unfetchable pages are kept (no false drops).
- Auth-posture findings — every discovered app is scored for identity risk (no corporate
SSO, local-password login, consumer IdP, broad/long-lived OAuth scopes) with a risk score and
badges in the Discovered view;
GET /api/catalog/exportemits the same as a flat feed for SIEM/BI. These are findings, not inline enforcement (see Scope). - Continuous monitoring — forward a SaaS breach/CVE feed to
POST /webhooks/breach, and re-assessments raise an alert on any control regression (e.g.ssodrops fromsupported). Alerts surface in the dashboard and atGET /api/alerts. SetREASSESS_INTERVAL_HOURSto run re-assessments on a schedule (stale apps re-checked automatically, firing change alerts). - OAuth scope risk is tiered — write/admin scopes flag high, broad read access
medium, long-lived (offline) tokens low. Multi-sensor discovery now also dedupes on
the registrable domain (eTLD+1), so
saml.acme.comandapp.acme.comcollapse toacme.com. - Roles & audit — the admin
API_TOKENcan do everything; an optional read-onlyAPI_VIEWER_TOKENcanGETbut not mutate. Every mutating call is written to an audit log (GET /api/audit, admin only) tagged with role and tenant. Per-tenant data isolation is enforced by the Postgres store (DATABASE_URL): every query is scoped to the request's tenant (see Persistence). - OIDC dashboard login — optional IdP sign-in (Authorization Code + PKCE) with a signed, httpOnly session cookie; role/tenant derived from claims. Bearer auth still works alongside it.
npm test # server + web suites (Vitest)
npm run lint # ESLint across server, web, and the extensionServer tests cover scoring · HMAC · discovery · KB/eval (incl. baseline + readiness) · posture · RBAC · Postgres tenant isolation · OIDC · pollers. Web tests (Vitest + Testing Library) smoke-test the dashboard shell, the readiness/setup states, demo mode, and the error boundary. A top-level React error boundary shows a friendly fallback instead of a white screen if any view throws. CI runs lint + both suites + both builds on every PR (Node 20.19).
- Spend signal join (NetSuite/Productiv) to flag shadow-IT you pay for but haven't assessed
- Settings: default reviewers, score thresholds, secret rotation
- Bot Framework Teams bot for true async replies
Versioning follows SemVer; changes are recorded in CHANGELOG.md.
To cut a release: bump extension/manifest.json to match, add a ## [x.y.z] changelog
entry, then tag and push:
git tag -a v1.0.0 -m "Snout v1.0.0"
git push --follow-tagsThe Release workflow verifies the tag matches the manifest version, builds a
Web-Store-ready extension zip (manifest at the zip root), and publishes a GitHub Release
with that zip attached and the changelog section as notes — one click to distribute.
Snout's assessments are automated, AI-generated, and may be wrong — they are a research aid, not professional advice or sign-off, and a human must review every decision. It names third-party vendors for identification only and is not affiliated with them. The browser extension's monitoring is the deployer's legal responsibility. Please read DISCLAIMER.md before relying on any output.
Contributions are welcome — see CONTRIBUTING.md and our Code of Conduct. Report security issues privately per SECURITY.md, not via public issues.
MIT — see LICENSE.