Controls, and their edges.
Every claim below was read out of the source before it was written down, and the last section is the part most pages leave out: what is not covered. The full threat model, including a STRIDE table with a residual-risk column for each row, lives in SECURITY.md at the root of the repository — public, like the rest of it.
One service owns auth.
digikey issues and verifies everything. It is eighteen Python modules, not a framework plugin, and there is no static shared-secret fallback left in the stack.
- RS256, with a kidAccess tokens are asymmetric RS256 JWTs signed with a rotating RSA key and stamped with a kid header. Verifiers fetch the public half from the JWKS endpoint and never hold signing material, so a compromised consumer cannot mint tokens.
- Scoped API keysKeys are bcrypt-hashed at rest; only a short lookup prefix is stored in the clear. Each key carries a tenant slug and an explicit scope list, and scope matching supports component wildcards (digisearch:*) so a key can be issued narrow and stay narrow.
- Scope checked per routeProtected routes declare the scope they need and the shared service middleware enforces it. When auth is not configured the middleware fails closed — a protected route returns 503 rather than serving unauthenticated traffic.
- Revocation, when you wire itRevoking a key marks it revoked and pushes its live token ids to a Redis jti blocklist that the auth middleware checks on every request; a Redis outage returns 503 rather than failing open. With no Redis URL configured the blocklist is a no-op and already-issued tokens stay valid until they expire — keep TTLs short if you run it that way. One disagreement to flag before you find it yourself: SECURITY.md's revocation section predates the blocklist and still calls revocation a roadmap item. This description is the one that matches the code.
- Brute force costs somethingKey issuance and token mint sit behind a per-IP token bucket — 10 requests a minute, burst 20 — returning 429 with a Retry-After. Liveness and JWKS routes are exempt so probes stay up under load. The bucket is in-process only.
A request you can follow.
The reason to run your own infrastructure is to be able to answer what happened. These four are the mechanics of that answer.
- digibase.httpOne correlation middleware, installed by all six FastAPI services. It reads X-Request-ID from the request or generates a uuid4, publishes it on a ContextVar, injects it onto every log record, forwards it on outbound service-to-service calls, and echoes it on the response header. One id, one run, end to end.
- audit_log(), per serviceWorkflow events append to a JSONL audit log — timestamp, event type, agent id, payload, with optional key prefix, tenant and jti. The writer is audit_log() in digigraph.audit, and in the digiclaw and digiquant copies of the same module; digibase.audit is not the writer, it supplies the redact_mapping() every writer passes its payload through. The file lives on your host.
- Bounded outbound HTTPService-to-service calls are constructed through the shared client helpers, which apply a connect-5 / read-30 / write-10 / pool-5 second timeout envelope. A bare httpx client has no read timeout and will hang forever against a stalled upstream, so it is banned in production paths.
- No wildcard CORSEvery service installs CORS from one helper that reads an explicit origin allowlist from the environment. There is no * default: unset means the allowlist is empty and no cross-origin script can call the service.
What CI refuses to merge.
Secrets scanning and dependency auditing are gates rather than dashboards — when they run they fail the job instead of filing a note for later. Each row says what triggers it, because a gate that does not fire is not a gate.
- gitleaksScans the diff on every code pull request, and the full history on every push to develop and main — the push job is literally named "Scan (full history)" and passes no --log-opts, so it is a wider scan than the pull-request one, not a narrower. Any finding fails the job. The scanner is the OSS CLI at a pinned version, verified against a recorded SHA-256 of the release tarball before it runs. Allowlist entries in .gitleaks.toml require a written justification. Markdown-only pull requests skip this scan.
- pip-auditSeven Python components — digibase, digigraph, digiquant, digisearch, digismith, digikey and digiclaw — have their locked dependency closure exported and audited against the OSV database weekly, and whenever a change to a dependency manifest lands. HIGH and CRITICAL findings block the merge; MEDIUM and LOW are warn-only annotations. Accepting a CVE requires an entry with a rationale and a re-evaluation trigger. Three boundaries on that. The workspace has eleven members, so digifetch, digillm, digiskills and digivault are outside the matrix and their dependencies are not audited. A pull request that adds Python code without touching a manifest does not trigger the audit at all. And the scope is Python only — there is no Node or JavaScript dependency audit in CI, so the frontend and digichat dependency trees are unaudited.
- Loopback by defaultEvery service binds 127.0.0.1 in docker-compose.yml. Opening a port or binding 0.0.0.0 requires a matching change to SECURITY.md and is scored against the security rubric.
- Debug surfaces offDebug and thread endpoints are behind environment flags that default to 0, so /v1/debug/*, /test_llm and /threads/* are not reachable unless someone deliberately turns them on.
What we have not closed.
A selection from the residual-risk column of the threat model — the entries a reader deciding whether to deploy this would want first, not the whole table. SECURITY.md carries every row, each next to the mitigation it sits behind. If any of this is disqualifying for your deployment, better to learn it here than after an integration.
- Not built for the open internetThe design target is a single host or a private network. Putting these services on a public endpoint without a hardened gateway in front is outside the threat model — reach the stack over a VPN or a tunnel, not a public port.
- Redaction matches names, not valuesThe audit redactor walks a payload and replaces any key whose name contains password, api_key, token or secret. It does not inspect values, so it is not a PII scrubber and it will not catch a secret stored under an unexpected key name. Keeping prompts and document bodies out of audit payloads is a discipline enforced by review, not by the function.
- The live-trading gate is not a runtime interlockBroker adapters for Interactive Brokers, Alpaca and QuantConnect exist only as stubs whose connect and submit_order methods raise NotImplementedError. What guards them is a source-tree fact plus process: a local pre-push hook demands a human co-sign trailer on commits touching live-trading paths, and the security rubric scores it. There is no circuit breaker in the running system, and a hook that runs on a developer's machine can be bypassed — after which the change still has to clear review and branch protection.
- Key-scope isolation, not storage isolationOn a shared deployment, tenant separation is enforced at the digikey key-scope layer. Storage-layer isolation and per-tenant resource quotas are not implemented; multi-tenant operation is a roadmap item, not a shipped guarantee.
- Retrieved documents are not a trust boundary yetTool boundaries are typed and the MCP tool set is an allowlist, which limits what an agent can do. There is no content-level sanitiser or trust-tier tagging on documents pulled back from retrieval, so prompt injection carried in indexed content is not systematically defended. There is also no runtime egress allowlist on production service traffic.
- The audit log is local and unsignedAudit events are per-host JSONL. There is no append-only remote sink and no signed hash chain, so the trail is evidence of what the system recorded, not tamper-proof evidence that the record was never altered.
- Rate limiting does not span instancesThe token bucket is per-process, with no shared store, so it does not coordinate across replicas. There are no per-key or per-tenant quotas on orchestration or retrieval paths, and no request body-size cap at the ASGI layer — that is delegated to an upstream gateway.
- JWKS is cached for 300 secondsVerifiers cache the JWKS document for five minutes, so a signing-key rotation takes up to that long to propagate to every verifier.
- Dependency auditing stops at PythonThere is no Node or JavaScript dependency audit anywhere in CI, so the frontend and digichat dependency trees are not scanned for CVEs at all — it is written down as a follow-up, not shipped. On the Python side, MEDIUM and LOW findings are warn-only, and a pull request that adds code without touching a dependency manifest does not trigger the audit.
- Not every Action is pinned to a SHAThe secret scanner is the OSS CLI at a pinned version, verified against a recorded SHA-256 before it runs. The GitHub Actions around it are weaker: some are pinned to a commit SHA, most only to a major-version tag, which a compromised upstream release can move under us. Pinning is audited when a workflow file changes rather than enforced by a check.
- The insider controls are process, not enforcementThe hooks that block protected-path edits and unsigned live-trading pushes run in the developer's own environment, so a determined insider with write access can bypass the harness. What is left is pull-request review and GitHub branch protection — configuration audited out of band, not a property of the repository you can read. The same applies to the secret-scanner allowlist: a wrongly scoped entry would mask a real leak, and only review catches that.
- The public endpoints still fingerprintLiveness and JWKS are deliberately minimal and secret-free, but their response shape still leaks stack and version information. That is accepted rather than mitigated, on the basis that the contract is public by design — and there is no WAF or bot-management layer in front of any of it.
Reporting a vulnerability.
Please do not open a public issue. Email the address published in SECURITY.md with [digithings Security] in the subject, and include reproduction steps, the affected components, and any impact you know of.
The commitment in that document is an acknowledgement within 72 hours and a coordinated-disclosure timeline within seven days. We will agree an embargo where it makes sense, and credit you in the release notes if you want it.
SECURITY.md — threat model, defaults, disclosure contact
For how the same posture is enforced on the way in — the review gates, the test suite, the rubrics — see the quality page.