Do not put undisclosed vulnerability details into a public issue. Use GitHub private vulnerability reporting (Security Advisories) for this repository so the report stays private until a fix is coordinated. Include affected version, reproduction, and impact (affected routes or jobs behavior where relevant).
There are no supported releases yet (0.0.1-SNAPSHOT); the current scope is the main branch.
- Acknowledge within 2 working days, fix privately, disclose after a fix is available.
- Solo owner: G1DO triages; no second reviewer available, so required
gatecheck green plus self-review of the diff satisfies review (checks never weakened).
- No secrets are stored in the repository. Postgres comes from Testcontainers in tests and Boot Dev Services on local run.
- Never committed and never pasted into Notion, issues, PRs, comments, or logs; environment-specific config stays out of the repo.
- Enable GitHub secret scanning + push protection where available, with gitleaks as defense-in-depth in pre-commit and CI (full history via
fetch-depth: 0+task check:secrets). - Threat model lives in
docs/security/threat-model.md(required: Auth/sensitive fires). No SBOM is maintained for this slice; add one only when its risk (public contract, durable data, release chain) actually requires it.
- Update monthly, critical within 7 days.
- Dependabot alerts + version updates on; dependency-review blocks new vulns at PR time; local
task check:audit(OSV.dev) fails on HIGH/CRITICAL.
- HTTP entry:
TenantAuthFilterrequiresX-Tenant-Idon the submit slice and looks it up intenants; missing or unknown tenants get401, and a lookup failure fails closed.SubmitServicere-validates the tenant inside the submit path. - Tenant isolation is composite
FOREIGN KEY(tenant_id, xxx_id)edges plusUNIQUE(tenant_id, id)targets plus DB-enforced RLS:RLS FORCEon the ten tenant-scoped tables (documents, document_versions, jobs, attempts, artifacts, outbox, processed_events, audit_events, cap_grants, job_checkpoints;tenantsexcluded as the auth lookup target) with onetenant_isolationpolicy per table (tenant_id = current_setting('app.tenant', true)), runtime roleanvil_appisNOSUPERUSER NOBYPASSRLS, views aresecurity_invoker=true. Every transaction touching tenant rows setsSET LOCAL app.tenantviaTenantGuard.setTenantwith a read-back probe; missing context reads zero rows (fail closed). Seesrc/main/resources/db/migration/V1__durable_v1_schema.sql,src/main/resources/db/migration/V2__tenant_rls.sql,src/main/java/com/g1do/anvil/tenant/TenantGuard.java,docs/architecture/data-model.md, anddocs/decisions/0005-db-enforced-tenant-isolation.md. - Job ownership is fenced on
(id, tenant_id, owner, fencing_token, state)with the database clock as the only lease clock; stale workers affect zero rows and terminal rows are immutable at the database. - Content paths are
tenants/{tenant}/{sha}with tenant names rejected when they escape their directory (/,\,..,%-encoded traversal, reserved names); every read re-verifiessha256(bytes)and never serves a torn tail. Authenticated reads go throughContentStore.getForCaller(caller, resource, sha)(caller must equal resource plus anartifactsrow for(caller, sha)under RLS, else caller-scoped404with no oracle). Seesrc/main/java/com/g1do/anvil/cas/ContentStore.java. - Read surfaces are fail-closed per tenant: direct-ID fetches (
ReadService) return empty for cross-tenant IDs (same as missing, no oracle); minimal search isGET /api/documents?query=&limit=&cursorwith tenant solely fromX-Tenant-Id(WHERE tenant_id=:t AND title ILIKE :prefix% ORDER BY created_at,id, RLS plus pre-filter plus per-row re-check, cursor replay across tenants returns 0 rows); audit event reads areGET /audit/events?job_id=&limit=&cursor(aliases/api/audit/events,/api/v1/audit/events) inAuditReadService/AuditEventsControllerreturning the caller-tenant-only chainORDER BY seq(WHERE tenant_id=? AND job_id=?, RLS underneath, per-row tenant+job re-check, tenant solely fromX-Tenant-Idwithtenant/tenant_idparams ignored, cross-tenantjob_idsame empty200 []as missing, tenant- and job-scoped event-UUID cursor with foreign/missing resolving to 0 rows, default limit20/max100, JDBC only, no filesystem access,401on missing/unknown tenant viaTenantAuthFilteron/audit/*); deterministic replay isGET /audit/replay?job_id=(aliases/api/audit/replay,/api/v1/audit/replay) inReplayService/ReplayControllerreturning caller-tenant-only{input_sha,output_sha,match}read-only (WHERE tenant_id=? AND job_id=?+ RLS + per-row re-check,limit/cursorignored, malformed400, missing/cross-tenant generic404with no bytes and no oracle, non-terminal own-tenantmatch:false); presigned CAS URLs (CasPresignedService, TTL60s, DB clock, HMAC-boundtenant/sha/job/cap/epoch/expiry) deny403on swapped tenant, tampering, expiry, or revoked caps (403 NEEDS_APPROVAL,cap_rejected++). Seesrc/main/java/com/g1do/anvil/read/,src/main/java/com/g1do/anvil/audit/AuditReadService.java,src/main/java/com/g1do/anvil/audit/AuditEventsController.java,src/main/java/com/g1do/anvil/replay/ReplayService.java,src/main/java/com/g1do/anvil/replay/ReplayController.java,src/main/java/com/g1do/anvil/cas/CasPresignedService.java, anddocs/security/threat-model.md. - Untrusted execution is jailed per tenant (
ExecJail,JailEgress): each run forks a child with its working directory set to<anvil.exec.root>/<tenant>and a cleared environment (env -iequivalent: onlyPATH=/usr/bin:/bin,ANVIL_TENANT=<tenant>, plus explicit allowlist entries;ANVIL_TENANT/PATHcannot be overridden, loader/debug keysLD_*/DYLD_*/PYTHON*/PERL*/RUBY*/NODE_*/IFS/BASH_ENV/ENV/ZDOTDIRdeny). Shell interpreters deny, absolute/../%operands deny, workspace executables deny, symlink-traversing paths deny, and stdout/stderr cap at 1 MiB per stream (truncated + flag). File operands must resolve viaresolveInWorkspace(absolute/../%/null-byte/symlink deny withSecurityException); authenticated reads go throughreadWorkspaceFile(caller, resource, rel)(caller != resourcedenies caller-scoped without oracle). Egress from the untrusted path is default-deny (JailEgress.checkAlloweddenies every URL with no allowlist in v1, without network); blocked targets (localhost/metadata/link-local/loopback/private incl. expanded/mapped forms, non-canonical numerics, non-http(s), userinfo) always deny even when allowlisted; the jail HTTP client never follows redirects (followRedirects(NEVER)). Denials carry a generic reason only. Residual: no mount/user/network namespace in v1 (kernel still permits raw../siblingor/tmpfrom arbitrary native code once it runs; interpreter-cstrings, workspace binaries,/proc, and symlink TOCTOU bypass the argv allowlist —runis trusted-caller-only), no DNS-rebind re-check, non-canonical IP encodings deny without decoding, no nftables DROP. Seesrc/main/java/com/g1do/anvil/exec/anddocs/security/threat-model.md. - Input validation limits live in
SubmitService(title,mime_type,Idempotency-Key, requiredbody); CBOR canonicalization covers{title, mime_type, body}only.