Security review before the first release¶
Review of the trust boundaries of Lorebase at commit 4bd2d26, with the fixes made on top of it.
Each fixed finding has its own commit with a test that fails without the fix. The findings left
open by the first pass (F6, F7, F12 to F23) were fixed in a second pass, described below.
The threat model covers the same boundaries and the ones added since (agents over MCP, webhooks, inference providers, the release pipeline) by STRIDE category, with the mitigation of each threat and the risk that remains.
Scope and method¶
Packages keeper, store, scribe, inference and herald, the deployment files under
deploy/, the environment and example configuration, and the GitHub workflows. The security
sections of design.md and
store-schema-notes.md give the intended model.
Boundaries examined:
| Boundary | What was checked |
|---|---|
| Unauthenticated to authenticated | auth modes and their required settings, API key parsing and HMAC, OIDC JWKS validation, bind address of auth.mode: none, body limits, problem details |
| Narrowed grant to Domain-wide | RBAC matrix, Kind, Operation and branch scoped grants, ReadGuard, response cache variants, idempotency keys per principal |
| Domain to installation | Domain id handling in the authorizer, API key scopes, installation administrators, Source connections, schema inference sources |
| Agent to human-reviewed write | Operation review policies, Proposal approvals and self-approval, merge permission on the target branch |
| External data to model prompt | inference chat client, structured output validation, local span extraction |
| Keeper to Postgres role | row-level security policies, revision 0005 security definer functions, maintenance jobs, role separation |
| Browser to Keeper (added with web sign-in, below) | session cookie, sign-in state, CSRF, return paths, token storage, refresh and sign-out |
Tools: make security (pip-audit on the lock file, pnpm audit --audit-level=high),
uvx bandit -r packages -q -ll, pnpm audit in apps/web, and a grep of every logging call
near tokens, keys, DSNs and secrets.
Findings¶
| ID | Severity | Area | Finding | Status |
|---|---|---|---|---|
| F1 | Critical | scribe | A Source could resolve any secret reference, including the Lorebase database DSN | Fixed |
| F2 | High | scribe | Source errors echoed the resolved value of a secret reference | Fixed |
| F3 | High | keeper | auth.mode: none could be served on a non-loopback bind address |
Fixed |
| F4 | Medium | store | Webhook targets in shared address space and other non-global ranges were accepted | Fixed |
| F5 | Medium | scribe | A webhook target could stall delivery for every Domain | Fixed |
| F6 | Medium | deploy | The compose stack connects as the Postgres superuser, which ignores row-level security | Fixed |
| F7 | Medium | deploy | Compose falls back to change-me for the LiteLLM master key and the Grafana password |
Fixed |
| F8 | Low | keeper | A Domain id of * in a path was authorized against grants in any Domain |
Fixed |
| F9 | Low | store | Index drop jobs accepted any index name | Fixed |
| F10 | Low | inference | A per-task endpoint inherited the main API key | Fixed |
| F11 | Low | CI | Checkouts kept the job token in .git/config |
Fixed |
| F12 | Low | store | Security definer functions accept NULL limits and do not check the transaction scope | Fixed |
| F13 | Low | store | Revision 0005 does not verify that the table schema is not writable by the application role |
Fixed |
| F14 | Low | store | Bundle import has no size limits and trusts unhashed metadata | Fixed |
| F15 | Low | keeper | Schema inference sources are installation-wide | Fixed |
| F16 | Low | keeper | The optional API key IP allowlist of design 13.2 is not implemented | Fixed |
| F17 | Low | keeper | OIDC issuer and JWKS URLs may use plain HTTP | Fixed |
| F18 | Low | scribe | CSV over HTTP checks the host name only; local CSV reads have a check-then-read race | Fixed |
| F19 | Low | deploy | Helm NetworkPolicy is disabled by default; /api/v1/docs is served by default |
Fixed |
| F20 | Low | keeper | Requests with invalid credentials are not rate limited | Fixed |
| F21 | Info | inference | Provider error text (up to 300 characters) is kept in exception messages; clients honour proxy variables | Fixed |
| F22 | Info | store | rls_scope does not refuse commas in Domain ids; sql_literal assumes standard_conforming_strings |
Fixed |
| F23 | Info | keeper | Server-Sent Event streams are authorized once, at connection | Fixed |
Fixed¶
F1. Source connections are limited to an allowlist (packages/scribe/src/lorebase_scribe/sources/connectors.py).
Sources are defined in the schema by callers holding schema.write. Scribe resolved whatever
secret reference a Source named, so a modeler of one Domain could define a Postgres Source on the
reference holding the Lorebase DSN and copy any table of the installation into their Domain; the
policies rely on a transaction setting that such a query can change. Scribe now resolves only the
references in scribe.sources.allowed_connections (empty by default).
Test: packages/scribe/tests/test_source_connections.py.
F2. Source errors never contain secret values (same file). A CSV Source whose reference held
another secret failed the allowed-roots check with the value in the message, which is stored in
the run record and returned by GET /domains/{d}/sources/{s}/runs. Messages now name the setting
or the reference only. Test: test_source_connections.py.
F3. Anonymous mode is refused off loopback (packages/keeper/src/lorebase_keeper/settings.py).
auth.mode: none accepts every request as an installation administrator. Keeper now refuses to
start with that mode and a keeper.host that is not loopback unless keeper.auth.allow_insecure
is true. The compose stack sets the flag through KEEPER_ALLOW_INSECURE, since it publishes the
port on 127.0.0.1. Test: packages/keeper/tests/test_settings.py.
The pepper (at least 32 characters) is required with api_key and api_key+oidc, and
oidc.issuer with oidc.audience with oidc and api_key+oidc (auth/config.py,
_mode_requirements); the Helm chart also refuses to render an API key mode without a pepper.
F4. Webhook targets must be global unicast (packages/store/src/lorebase_store/webhooks/delivery.py).
The check missed 100.64.0.0/10 (which holds an instance metadata address on some clouds), IETF
protocol assignments and IPv6 site-local addresses. Only addresses Python classifies as global,
and not multicast, reserved or site-local, are accepted. Test: packages/store/tests/test_webhooks.py.
F5. Webhook attempts are bounded and bodies are not read (packages/scribe/src/lorebase_scribe/webhooks.py).
client.post read the whole response with per-read timeouts; deliveries run in sequence, so one
target streaming or trickling a body blocked delivery for all Domains. Each attempt now runs under
asyncio.timeout and closes the streamed response unread. Test: packages/scribe/tests/test_webhook_sender.py.
F8. * is an ordinary Domain id (packages/keeper/src/lorebase_keeper/auth/rbac.py). The
authorizer selected its any-Domain grant lookup by comparing the requested Domain id with *, so
/domains/*/... was authorized with the caller's grants anywhere. The routes found no such Domain,
so no data crossed. The lookup is now selected by None. Test: packages/keeper/tests/auth/test_rbac.py.
F9. Index drop jobs drop managed indexes only (packages/store/src/lorebase_store/indexes.py).
Jobs are queued by the application role and run by the maintenance role; a drop job now runs only
for a per-Attribute index of its Domain. Test: packages/store/tests/test_indexes.py
(test_a_drop_job_only_drops_managed_indexes_of_its_domain).
F10. Task endpoints use their own key (packages/inference/src/lorebase_inference/settings.py).
A task override with another base_url and no api_key sent the main key to that host. It now
gets no key unless it sets one. Test: packages/inference/tests/test_settings_providers.py.
F11. CI checkouts do not persist credentials (.github/workflows/*.yaml). Every
actions/checkout sets persist-credentials: false; no job fetches or pushes afterwards.
Fixed in the second pass¶
Each fix has its own commit with a test that fails without it (F21 to F23 are one commit each).
F6. Keeper and Scribe connect as a role subject to RLS on compose
(deploy/compose/docker-compose.yaml, deploy/compose/postgres/app-role.sh). A one-shot
db-roles service creates or updates LOREBASE_APP_USER (default lorebase_app, password from
the required LOREBASE_APP_PASSWORD) on every start: LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB
NOCREATEROLE NOREPLICATION, row rights on the tables and sequences of public (default
privileges cover later tables), CONNECT, TEMPORARY on the database, EXECUTE on the read
functions of an earlier migration; CREATE on public is revoked from PUBLIC. It is
idempotent, so existing volumes are converted. Keeper and Scribe use that role; migrate and
the index maintenance DSN keep the owner, and migrate sets database.application_role. A
one-shot service was chosen over a docker-entrypoint-initdb.d script because init scripts run
only on an empty volume. Roles are documented in deployment.md.
Test: tests/tooling/test_compose_stack.py; the web e2e suite passed on the stack (11 tests),
where pg_stat_activity showed Keeper as lorebase_app with rolsuper and rolbypassrls
false, and Scribe ran its index and webhook jobs under the role. The Helm chart has the same
split since the hardening from the threat model.
F7. No placeholder secrets for the LiteLLM and Grafana profiles
(deploy/compose/docker-compose.yaml, deploy/compose/require-secrets.sh, .env.example).
The change-me defaults are gone and .env.example leaves both values empty with a generation
command. ${VAR:?} fails for every profile, because compose interpolates every service, so
litellm and grafana depend on one-shot guards (litellm-secrets, grafana-secrets) that
exit 1 and name the variable when it is empty, change-me or shorter than 16 characters.
Test: tests/tooling/test_compose_stack.py.
F12, F13. Security definer arguments, scope and search_path
(packages/store/migrations/versions/0008_definer_argument_checks.py). Revision 0008
replaces the four functions (same signatures, owner and grants) with checks at entry: every
count between 1 and its ceiling (store.reads.definer_ceilings, NULL refused, SQLSTATE 22023),
the Domain given (22023) and in the transaction scope or with the bypass on (42501), and
search_path equal to the path fixed by the migration (55000). The upgrade refuses to run while
database.application_role may create objects in the schema of the tables or of pg_trgm.
Revision 0010 (overlay branches) creates the four _overlay variants with the same checks,
run before the overlay's rows are resolved, and the same refusal.
Test: packages/store/tests/test_definer_checks.py (run as a non-owner role, both sets).
F14. Bundle import is bounded (packages/store/src/lorebase_store/bundle.py). Per-file and
total size caps counted on the bytes read, an entry count cap and a bounded line length
(store.bundle.*); tarballs accept only regular files and the schemas directory and refuse
links, devices, absolute and .. paths; directory mode opens every file with O_NOFOLLOW at
each level. Bundles are documented as trusted input in operations.md.
Test: packages/store/tests/test_bundle_limits.py.
F15. Schema inference sources per Domain
(packages/keeper/src/lorebase_keeper/services/schema_inference.py).
keeper.schema_inference.domain_sources maps a Domain to its own source references (an empty
list disables Postgres inference for it); Domains not listed use allowed_sources. The refusal
does not name other Domains' sources. Test: packages/keeper/tests/test_schema_inference.py.
F16. API key network allowlist (packages/keeper/src/lorebase_keeper/client_address.py,
auth/principals.py, revision 0009). Keys may carry allowed_networks (CIDR list, capped by
keeper.auth.api_keys.max_allowed_networks), set through the admin API, the client and
lorebase apikey create --allow-network; a key used from another address gets the 401 of an invalid
key. The client address is the socket peer, or, when the peer is in keeper.trusted_proxies,
the first X-Forwarded-For hop from the right that is not a trusted proxy; uvicorn no longer
rewrites the peer from proxy headers. Tests: packages/keeper/tests/test_client_address.py,
packages/keeper/tests/auth/test_api_key_authentication.py.
F17. OIDC over HTTPS only (packages/keeper/src/lorebase_keeper/auth/config.py, oidc.py).
oidc.issuer, oidc.jwks_uri and a discovered jwks_uri must use https unless the host is
loopback or keeper.auth.oidc.allow_insecure is set.
Test: packages/keeper/tests/auth/test_oidc.py.
F18. CSV Source reads (packages/scribe/src/lorebase_scribe/sources/connectors.py). URL
hosts go through the webhook target check (global unicast only, unless
scribe.sources.csv.allow_private_targets) and the download connects to the checked addresses;
timeout_s bounds the whole download and the body is capped by max_bytes. Local files are
resolved and checked against the resolved roots, then opened from the root one component at a
time with O_NOFOLLOW, must be regular files and are read from that descriptor up to
max_bytes. Test: packages/scribe/tests/test_connectors.py.
F19. Helm network policy on by default (deploy/helm/lorebase/values.yaml,
templates/networkpolicy.yaml). Ingress closed except Keeper, Herald, web and Ollama from Scribe;
egress per workload: DNS, Postgres (in-cluster pods or networkPolicy.egress.database.to), the
OTLP collector when telemetry is on, the model endpoint for Scribe, webhook ranges, Herald and
the test pod to Keeper only. No default rule opens every address; the production profile lists
placeholder ranges and turns keeper.openapi.docs_enabled off, as kubernetes.md
recommends. Test: tests/tooling/test_helm_network_policy.py.
F20. Failed authentications are rate limited
(packages/keeper/src/lorebase_keeper/ratelimit/enforce.py, limiter.py). A bucket class of
its own, keeper.limits.rate.auth_failures (30 per minute, burst 30), with one bucket per
client address (IPv6 by /64) and one per API key prefix. A 401 to a request that carried a
credential takes a token from both; while either is empty the request gets 429 with
Retry-After before the prefix lookup, the HMAC or the token validation, stays out of the audit
log and is logged at DEBUG. Successes take nothing. Trade-off, documented in
operations.md: whoever knows a key prefix (it is not secret) can keep that key
throttled, and clients behind one address share its bucket. Operators who cannot accept that set
auth_failures.per_key_prefix_enabled: false: only the per-address bucket (always on) is kept.
Tests:
packages/keeper/tests/auth/test_auth_failure_limits.py,
packages/keeper/tests/ratelimit/test_rate_limit_buckets.py.
F21. Inference client details (packages/inference/src/lorebase_inference/transport.py).
401 and 403 errors keep the status and a fixed message only; other excerpts are scrubbed of
the configured key, Bearer values, sk--style keys and long base64 or hex runs. httpx clients
of inference and Herald ignore proxy variables unless trust_env is set
(inference.llm.trust_env, inference.embeddings.trust_env, herald.keeper.trust_env); with it,
NO_PROXY is honoured as usual. Tests: packages/inference/tests/test_transport.py,
packages/herald/tests/test_safety.py.
F22. Store literals (packages/store/src/lorebase_store/auth/scope.py, db.py). rls_scope
and scope_to_domain share one check that refuses empty ids and ids with a comma; every store
engine sets standard_conforming_strings: on. Tests: packages/store/tests/auth/test_rls_scope.py,
packages/store/tests/test_settings.py.
F23. Event streams are authorized again (packages/keeper/src/lorebase_keeper/agents/feed.py,
routes/events.py). Every agents.feed.reauthorize_interval_s (default 60 s) an open stream
authenticates its request again (with its client address, for key allowlists) and requires
data.read on the branch; it closes when either fails. The check uses the usual caches, so a
grant changed through another Keeper is seen within the interval plus
keeper.auth.authz.grants_cache_ttl_s. Test:
packages/keeper/tests/integration/test_read_authorization.py.
Remaining limits: the per-prefix bucket of F20 can be used to throttle a known key
(per_key_prefix_enabled: false removes it); bundle headers of pax and GNU long
names are bounded by the archive size only (F14); the O_NOFOLLOW walk of CSV and bundle files
is POSIX only (F14, F18); schema inference sources are bound to Domains but not to schemas or
tables (F15).
Web sign-in: a new boundary¶
Web sign-in (keeper.auth.sessions, migration 0013) adds a credential the browser sends by
itself: the session cookie. Code: packages/keeper/src/lorebase_keeper/auth/sessions.py,
auth/provider.py, auth/keyring.py, routes/sessions.py; storage in
packages/store/src/lorebase_store/auth/sessions.py. Setup and the full list of checks are in
deployment.md. Threats considered and what answers them:
| Threat | Mitigation |
|---|---|
| Token theft by script in the page (XSS) | The browser gets no token: the session id is in an HttpOnly cookie; provider tokens stay in Keeper |
| Session id theft from the database or backups | Only its SHA-256 is stored; refresh and ID tokens are AES-256-GCM encrypted under a key derived (HKDF) from encryption_key, which is a secret reference, never stored |
| Cookie injection from a sibling host, or over http | __Host- prefix with Secure, Path=/ and no Domain (unless cookie_secure: false, meant for loopback development) |
| Cross-site request forgery | SameSite=Lax; unsafe methods also need the CSRF header (HMAC of the session id, compared in constant time), a trusted Origin when present and no cross-site Sec-Fetch-Site; requests with Authorization or an API key ignore the cookie and need no token |
| Login CSRF, code injection, replayed ID token | state and nonce (256 bits each) and the PKCE verifier live in an encrypted, 10-minute, HttpOnly cookie bound to the browser that started; the callback checks the state before redeeming the code, the provider checks the verifier, Keeper checks the ID token's signature, issuer, audience, azp, expiry and nonce |
| Session fixation | A new random id at every sign-in; the session the browser presented is deleted |
| Open redirect | return_to must be a local path (no scheme, authority, backslash or control character, at most 2,048 characters) and is checked again when the state cookie is opened; redirects go to app_url's origin only; provider error codes are mapped to a fixed list |
| Long-lived or orphaned sessions | Idle and absolute timeouts checked on every request; refresh at the provider when the access token expires, and a refused refresh, a changed subject or an unreadable token blob ends the session; sign-out deletes the row and offers RP-initiated logout; rows go with their principal |
| Redirect URI manipulation | The configured redirect_uri is sent verbatim to the authorization and token endpoints (exact match at the provider); it is never derived from request headers |
| Provider metadata or endpoints over plain http | Same rule as F17: https unless loopback or oidc.allow_insecure; the metadata's issuer must equal oidc.issuer; providers that do not list S256 PKCE are refused |
The web_sessions table is installation-wide like api_keys: it has no domain_id, hence no
row-level security policy, and the application role reaches it through the owner's default
privileges. A session resolves to the principal a bearer token of the same subject gets, so
grants, Domain isolation and row-level security apply unchanged; auth.mode: none refuses a
sessions configuration. Sign-in and sign-out routes (/auth/login, /auth/callback,
/auth/logout) are not authenticated by the limits dependency, so a stale cookie never blocks
them; they take from a bucket per client address instead (the class of the method, reads or
writes), before any provider request or session lookup. The limits dependency names routes
without the API prefix whatever form the router reports, so exempt_paths and these routes
match. The CSRF check runs before the session is looked up, so a forged request neither
extends nor refreshes a session, and a bad session cookie on other routes counts as a failed
authentication for auth_failures. Refreshed ID tokens are checked like the first one
(signature, issuer, audience, azp, expiry); the subject must not change and a nonce, if
present, must be the sign-in's, which is kept in the encrypted token blob.
The authorization code reaches Keeper in the query string of /auth/callback. Keeper's access
log records the route template, not the query, but reverse proxies and load balancers usually
log full URLs: such a log holds codes that are single use, short-lived and useless without the
PKCE verifier, which only the browser's encrypted sign-in cookie carries. Keep proxy logs
protected anyway, or drop the query of that path from them.
Tests: packages/keeper/tests/auth/test_sessions.py (state, nonce, PKCE, audience, tampered
and expired state cookie, CSRF cases, Origin and Sec-Fetch-Site, idle and absolute expiry,
rotation, refresh, refused refresh, logout revocation, open redirects, cookie flags, settings),
packages/store/tests/auth/test_sessions_integration.py and
packages/keeper/tests/integration/test_sessions_postgres.py (as a role without
BYPASSRLS), and the web app's src/api/session.test.ts and src/api/fetcher.test.ts.
Remaining limits: the CSRF token is readable by any origin that keeper.cors allows with
credentials, so list only the app's origins there; sessions survive a provider outage until the
outage ends or a timeout applies; one sign-in at a time per browser (a second tab's sign-in
replaces the first one's state cookie); the per-session refresh is serialized by a lease, not a
lock, so a refresh that fails mid-way waits refresh_lease_s before the next attempt.
Hardening from the threat model¶
The threat model listed residual risks by priority. Each fix below has its own commit and tests.
Audit log append-only for the application role
(packages/store/migrations/versions/0015_append_only_audit_log.py,
deploy/compose/postgres/app-role.sh). Revision 0015 revokes UPDATE, DELETE, TRUNCATE,
REFERENCES and TRIGGER on audit_log from PUBLIC, from database.application_role and from
every other role holding them except the owner; the compose db-roles script, which re-grants row
rights on every start, takes them back on audit_log. No code path updates, deletes or prunes
audit entries, so no maintenance-role or definer function was needed. Test:
packages/store/tests/auth/test_audit_append_only_integration.py (the application role appends
and reads, and gets SQLSTATE 42501 on UPDATE, DELETE and TRUNCATE; the downgrade gives the
rights back).
Document URL intake checks and pins addresses
(packages/keeper/src/lorebase_keeper/services/document_urls.py). After the scheme and the
keeper.documents.url_hosts allowlist, the host is resolved once and refused unless every
address is global unicast, with the check_target of webhook targets and Sources; the GET then
connects to the checked addresses only (the Host header and the TLS server name keep the host
name), trying the next one on a connection failure, never a new lookup. Redirects, proxies from
the environment and bodies above max_bytes stay refused. A resolution failure answers 502, a
private address 422. keeper.documents.url_allow_private_targets (false) turns the check off.
Test: packages/keeper/tests/test_document_urls.py (loopback, RFC 1918, link-local, shared
address space, IPv6 unique local and IPv4-mapped loopback refused before any request; the
request goes to the checked address with the original Host and SNI).
PDF and DOCX parsed in a limited child process
(packages/scribe/src/lorebase_scribe/extraction/sandbox.py, settings
scribe.extraction.parser). The extraction job hands the bytes to
python -I -B -m lorebase_scribe.extraction.sandbox over standard input, with an empty
environment. Before reading them the child sets RLIMIT_CPU (cpu_s, 60), RLIMIT_AS
(memory_mb, 1024, where the system enforces it) and no core files, and replaces the socket
constructors and name resolution with functions that refuse. The parent kills it after
timeout_s (60 s), reads a bounded answer, refuses documents over max_input_bytes (50 MiB)
without starting it, and turns a limit, a signal, a parser exception or a garbled answer into a
ParseError, a permanent job error. Markdown and text are still decoded in the worker. Test:
packages/scribe/tests/test_parser_sandbox.py with stand-in parsers
(packages/scribe/tests/sandbox_targets.py) that sleep, spin, allocate, return a huge text,
open sockets and kill themselves; real PDF and DOCX parse as before.
Webhook signing keys encrypted at rest (packages/store/src/lorebase_store/webhooks/keys.py,
packages/store/migrations/versions/0016_sealed_webhook_keys.py, webhooks.encryption_key
and previous_encryption_keys). The keyring of web sessions moved to
packages/store/src/lorebase_store/keyring.py with a namespace (Keeper's derived keys are
unchanged), so Keeper, which stores keys, and Scribe, which signs with them, share it. With a
key set, the signing key is sealed with AES-256-GCM in webhooks.sealed_key and secret_hash
is null; revision 0016 makes secret_hash nullable with a check that exactly one column is
set. Rows stored before keep working: each delivery pass seals up to batch of them, and keys
sealed under a previous key are sealed again under the current one. A key that does not open
fails the attempt with an error naming the setting. The downgrade refuses while sealed rows
exist. Tests: packages/store/tests/test_webhook_keys.py (sealing, rotation, tampering,
settings, unchanged session key derivation) and
packages/store/tests/test_webhook_keys_integration.py (a webhook created before the key is
sealed by the worker and still verifies at the receiver; rotation; an unknown key).
Security headers from Keeper and the web image
(packages/keeper/src/lorebase_keeper/middleware.py, settings.py,
deploy/docker/web/40-lorebase-config.sh, deploy/docker/web/default.conf,
deploy/helm/lorebase/templates/web-configmap.yaml). Keeper's outermost middleware adds
X-Content-Type-Options: nosniff, X-Frame-Options: DENY with Content-Security-Policy:
frame-ancestors 'none', Referrer-Policy: no-referrer and, in hsts.enabled: auto, HSTS when
the request scheme is https or a peer in keeper.trusted_proxies forwarded https; a header a
route set is kept (keeper.security_headers). The web image writes an nginx include at start
with a Content Security Policy built from the runtime Keeper URL (connect-src), extra sources
(LOREBASE_WEB_CSP_CONNECT_SRC) or a whole replacement (LOREBASE_WEB_CSP), included in every
location so add_header inheritance cannot drop it; values with quotes, $, braces or newlines
stop the container. Tests: packages/keeper/tests/test_security_headers.py,
tests/tooling/test_web_security_headers.py (the entrypoint script and the rendered chart).
Dependency audit in CI (.github/workflows/audit.yaml, scripts/audit_check.py,
.github/audit-allowlist.yaml, Makefile targets security-python and security-web). On
demand and before every release (on every pull request, on main and weekly until 2026-10-23), pip-audit (pinned version) checks the exported locked
runtime dependencies and pnpm audit --prod the web app's. The check fails on any advisory not
in the allowlist (matched by package and id or alias) and on expired entries; accepted ones are
printed as warnings. Entries carry a reason, a review date and an expiry. The allowlist starts
empty: both audits found nothing. Dependabot version updates stay off. Test:
tests/tooling/test_audit_check.py.
Base images pinned by digest (deploy/docker/backend.Dockerfile,
deploy/docker/web.Dockerfile, the compose proxy image). Each FROM uses an ARG ..._IMAGE
default of the form tag@sha256:<index digest>, the multi-arch index so amd64 and arm64 builds
get the same content; release describes the bump. Test:
tests/tooling/test_dockerfile_pins.py fails on an external FROM without a 64-hex digest.
Shared rate limits across Keepers (packages/keeper/src/lorebase_keeper/ratelimit/shared.py,
packages/store/src/lorebase_store/ratelimit.py, revision 0022). With
keeper.limits.rate.backend: postgres the token buckets live in the UNLOGGED rate_limit_buckets
table, keyed by the SHA-256 of the bucket name (client addresses and principal ids are not
stored). One upsert refills a bucket on the database clock and leases up to lease_tokens whole
tokens (at most a quarter of the bucket) to the Keeper, which serves requests from memory until
the lease is used or expires (lease_ttl_s); tokens leave the shared bucket only through leases,
so all Keepers together never exceed a limit. Failed-authentication buckets lease one token per
failure, are read before each authentication from a reading at most check_cache_s old, and fail
closed (429) when the database does not answer within timeout_s; other buckets follow
on_error (local by default: the in-process buckets meanwhile). The Helm chart sets the backend
when replicaCount > 1 or the HPA is on. Tests:
packages/store/tests/test_ratelimit_buckets_integration.py (concurrent leases never exceed the
bucket, refill, prune, names not stored), packages/keeper/tests/ratelimit/test_shared_store.py
(local, allow, deny, fail closed on a slow database),
packages/keeper/tests/integration/test_limits_postgres.py (two applications on one database
share a bucket and the failed-authentication buckets), tests/tooling/test_helm_db_roles.py.
Read auditing (packages/keeper/src/lorebase_keeper/auth/read_audit.py, auth/audit.py,
revision 0022). With keeper.auth.audit.reads.enabled the audit middleware hands every
successful read of an authenticated caller (GET, HEAD and the read-only POST routes of
keeper.limits.rate.routes) to a bounded queue; a background task writes batches in one
transaction to audit_read_log. The read stream has hash chains of its own, per Domain and
installation-wide, rather than sharing those of audit_log: reads are sampled and dropped under
pressure, so their volume and gaps must not touch the chains of actions, whose appends (inside
commits too) would otherwise wait on read traffic for the chain lock; each stream is verified,
checkpointed and kept for its own retention. Filters (principal_types, kinds) and
sample_rate apply except to reads that showed or masked a sensitive value: the masking and
vault disclosure code notes those in a context variable of the request, and cached bodies carry
the notes of their first rendering. Records hold the route template, never query strings, bodies
or values. A full queue or a failed batch drops records, counted in
lorebase.audit.reads.dropped by reason. The table is append-only for the application role like
audit_log (compose db-roles and the chart's db-roles.py included). Tests:
packages/keeper/tests/auth/test_read_audit.py,
packages/keeper/tests/integration/test_sensitivity_api.py (masked and cleared reads recorded at
sample_rate: 0, cached answers included, no value in the records),
packages/store/tests/auth/test_audit_streams_integration.py,
packages/store/tests/auth/test_audit_append_only_integration.py.
Audit chain checkpoints outside the database
(packages/store/src/lorebase_store/auth/checkpoints.py,
packages/scribe/src/lorebase_scribe/checkpoints.py, packages/keeper/src/lorebase_keeper/__main__.py).
An audit_checkpoint job, queued every scribe.audit_checkpoints.interval_s by the scheduler
leader, reads the newest entry of every chain of each stream and writes a JSON checkpoint signed
with Ed25519 over its RFC 8785 form. Sinks: a file that is never replaced (written to a temporary
name, synced and hard-linked into place, refused outside the directory); an HTTP PUT or POST;
or an S3-compatible PUT signed with Signature Version 4. HTTP targets pass the checks of webhook
targets (check_target: allowed schemes, every address global unicast unless
allow_private_targets, connection pinned to the checked address with the host name kept for
TLS, no redirects, no proxy from the environment, response body never read). The signing key is
a secret reference, refused at start unless it is an Ed25519 PEM. audit verify --checkpoints
PATH --checkpoint-key PUB verifies each checkpoint's signature (a file that does not verify is
reported and fails the run) and reports checkpointed entries that are missing or whose hash
changed, which a table owner cutting a chain's tail or recomputing a chain cannot hide without
the signing key. Tests: packages/store/tests/auth/test_audit_streams_integration.py (truncation
and rewrite detected, forged and altered documents refused),
packages/scribe/tests/test_checkpoint_sinks.py (the published Signature Version 4 example,
file sink never replaces or escapes, pinned HTTP, private targets, schemes and redirects refused,
settings), packages/scribe/tests/integration/test_audit_checkpoints.py,
packages/keeper/tests/test_main.py.
Helm chart: the services no longer connect as the table owner
(deploy/helm/lorebase/templates/_helpers.tpl, templates/migrate.yaml,
deploy/helm/lorebase/files/db-roles.py). The chart ran the migration Job, Keeper and Scribe with
one DSN, so the services connected as the owner of the tables: row-level security held only
through FORCE, the revoke of revision 0015 did not apply to them and they could run DDL. As on
compose, the chart now takes two DSNs, db-maintenance-dsn (the owner: the migration and
database.maintenance_dsn of Scribe and Keeper) and db-dsn (the application role: database.dsn
of Keeper and Scribe). A db-roles init container of the migration Job, running as the owner,
creates the application role (or waits for the one CloudNativePG manages from
postgresql.cluster.roles), refuses it with SUPERUSER or BYPASSRLS or when it is the owner,
and applies the grants of app-role.sh, audit_log revoke included; the migration runs with
database.application_role set. The chart refuses to render with one user for both DSNs, a missing
maintenance DSN or an unsafe managed role unless database.allowSharedRole: true (development).
Test: tests/tooling/test_helm_db_roles.py, the CI negative cases of the helm job.
Residual risks closed¶
The residual risks the threat model listed after the hardening above were fixed rather than documented, each with tests; every fix was reviewed again on its own and the findings of those reviews were fixed in follow-up commits with regression tests:
- Vault key rotation (
packages/store/src/lorebase_store/vault/rotation.py,keys.py):vault.rotatejob andlorebase admin vault rotate|status(vault.manage, installation administrators only);vault.token_keyrotates throughtoken_key_idandprevious_token_keys. Tests:packages/store/tests/test_vault.py. - Erasure gaps (
vault/store.py,keeper/routes/vault.py): matching forms for case variants, lineage of key changes followed under the Domain's vault lock, moved tokens, tombstones withvault.erased_valuesfor Source syncs, extraction and imports,pii.importfor feeding vault Attributes, keyed digest of the key in the audit entry. Tests:test_vault.py,packages/keeper/tests/integration/test_sensitivity_api.py,packages/keeper/tests/integration/writes/test_pii_import_postgres.py. - Value oracles (
keeper/reading/sensitivity.py,kindhealth/,core/validate/data_rules.py,services/operations.py,lorebase_store/digests.py): keyed parameter digests always, hidden duplicate groups, generic and collapsed issue codes, nested references refused, references resolved consistently (diff stream, Operation edits, Conflicts, markdown from structured data), step errors redacted. - Branches forked before a tag (
materialize/stale.py,services/schema.py): tag rebase and thestore.sensitivity_refreshjob by the effective tags. - Operation analysis (
core/expr/sensitivity.py): field reads resolved by the inferred Kind. - Bundles (
vault/transfer.py,bundle.py): opt-in plaintext vault export, audited at start and end, restored under the target's keys with lineage and tombstones. - Webhook keys and the read audit: sealed signing keys by default;
on_overflow: rejectfor a read stream that serves as proof.
What stays is inherent and listed in the threat model: values outside the vault stay in content-addressed history; the services hold the vault keys and fingerprints are deterministic; the fallback digest secret lives in the database; checkpoints cover the chains up to the latest one published.
Kind-scoped invisibility¶
An independent review of the change making Kinds invisible to callers whose grants are narrowed to other Kinds (design 13.3) looked for reads that still name, key, title or count an invisible Kind, answers that differ from those for unknown names, gaps in the schema projection, cache mixing and widening through the new Domain and branch reads. Findings and what was done:
- High: Proposal Conflicts about a reference to an invisible Kind (
PurchaseOrder.supplierfor a reader of orders) sat in a visible Kind's partition and showed Supplier keys, the hidden Attribute id and messages. Fixed: such Conflicts and automatic resolutions are left out in SQL (PartitionFilter.exclude_attributes), Record-level value maps lose the Attributes left out, and messages naming them are replaced (routes/proposals.py). Tests:packages/keeper/tests/auth/test_proposal_read_authorization.py,packages/store/tests/test_merging.py. - Medium: Trait implementations mapping a dropped Attribute kept its id in the projected
schema, which then failed validation and quoted it in previews. Fixed: such implementations
are left out, and a Trait implemented by nothing left in goes too. Test:
packages/core/tests/expr/test_visibility.py. - Medium: provenance origins, Revision messages and Proposal titles in Record history,
lineage and explanations named Operations and Sources of invisible Kinds, and changes of a
visible Record touching only a hidden reference were listed. Fixed: origins and texts naming
an id or a name left out are left out, and such changes are not listed (also in context
packs). Test:
packages/keeper/tests/integration/test_invisible_kinds.py. - Low, accepted: agent trace refs are filtered by the granted Kinds, not by the projection (a Kind keyed by a reference to an invisible Kind is shown in traces); Derivation previews refuse a reused hidden id, which confirms an id the caller already knows (ids carry 80 random bits); Revisions, branch heads and ahead/behind counts move on changes of any Kind (threat model, residual risk 8).
- No issue found in the projection cache (entries hold their schema and compare by identity),
the
ETagvariants,unchanged_head, search and typo corrections, trait query fields, Source previews, and callers whose grants are not narrowed to Kinds or withinvisible_kinds: false.
Other observations¶
- Prompt injection. The extraction model gets no tools, its output is validated against a
JSON schema with bounded repair attempts, and local span extraction takes evidence text from
the source by checked offsets. No extraction prompt builder exists yet in
packages/inference; when it is added, document text must go in the user message as delimited data, never in the system prompt, and outputs must stay Proposals that need review. - Webhook signing key. The stored value is the HMAC key itself; with
webhooks.encryption_keyset it is stored encrypted (see the hardening section above). - Herald forwards only the caller's credential in HTTP mode, never logs it, maps Keeper problems to short messages, and caps tool output except the context pack, which Keeper bounds by its token budget.
Tool results¶
make security: pip-audit found no known vulnerabilities;pnpm audit --audit-level=highfound none.pnpm auditinapps/web(all severities): no known vulnerabilities.uvx bandit -r packages -q -ll: 42 medium, 0 high. 39 are B608 (SQL built as strings) instore/reads.py,store/traverse.py,store/gc.py,store/indexes.py,store/jobs.py,scribe/sources/connectors.py,core/expr/checker.pyand tests: each interpolates constants, whitelisted keywords, checked identifiers or quoted literals, or (Postgres Sources) SQL written by the schema's modelers and run read-only; the checker hit is an error message. B506 incore/model/yaml_schema.pyis aSafeLoadersubclass. B104 hits are in tests. No real hit.- Logging: no call at INFO or above emits a token, key, pepper, DSN or password. Rejected API keys log the public prefix, rejected tokens the PyJWT error, schema inference the secret reference; the redaction filter also scrubs every resolved secret value from log records.