4.1 KiB
Security
The operator-facing summary. The reasoning behind each control is in THREAT_MODEL.md; the supply-chain rules are in MODEL_SUPPLY_CHAIN_POLICY.md.
Trust boundaries
| Boundary | Rule |
|---|---|
| Operator ↔ control plane | Every admin route requires the operator API key |
| Application ↔ control plane | A service credential scoped to specific capabilities |
| Control plane ↔ compute node | The node authenticates outbound; the control plane never dials a node |
| Control plane ↔ upstream model source | Acquisition only, into quarantine, verified before use |
An operator credential and a capability credential are different things and cannot substitute for
each other — capability_clients_are_not_operators is asserted as a platform invariant.
Model code is never executed
trust_remote_code is false and production refuses to start if it is anything else. A model
repository cannot ship Python that ModelForge will run. Artifacts are verified per file by SHA-256
against the recorded upstream identity before leaving quarantine, and an artifact that fails stays
there.
Secrets
| Secret | Stored as | Notes |
|---|---|---|
| Operator API key | configuration only | never persisted by the platform |
| Service credential | SHA-256 hash plus a short prefix | cannot be read back; rotate if lost |
| Node credential | SHA-256 hash | single-use enrolment, atomically claimed |
| Backup encryption key | configuration only | store it outside this deployment |
| Hugging Face token | configuration only | acquisition only; never passed to a runtime |
ModelForge never generates its own credentials. A platform that mints its own admin secret has no way to tell you it did. Generation procedures are in CONFIGURATION.md.
Revocation is permanent, expiry is enforced, a disabled client cannot serve with a valid secret, and rotation leaves exactly one usable secret.
Network exposure
By default only the API is published. PostgreSQL, Redis and the console bind to loopback, and a test fails if any Compose projection publishes a datastore more widely — including the disaster-recovery projection, which was found doing exactly that during the v1 gate.
Container hardening
No privileged containers. No container mounts the Docker socket. No host network or PID namespace.
The API, Node Agent and console drop all capabilities and set no-new-privileges; the Node Agent and
console run read-only root filesystems. The console serves a static build from an unprivileged
nginx — never a development server.
Console security headers
Content-Security-Policy with default-src 'none', script-src 'self' and no unsafe-inline or
unsafe-eval; inline style is permitted only as an attribute via style-src-attr, which covers the
dynamic width bars the console uses while still blocking an injected <style> element. connect-src
is derived at build time from the same API base URL compiled into the bundle, so the policy cannot
drift from the origin the bundle calls.
Also: X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy and Permissions-Policy — on the entry
document and on hashed assets.
Errors
Every error response is exactly code, message and correlation_id. A 500 body is exactly
Internal Server Error: no DSN, no driver name, no SQL, no path, no traceback.
Backups
AES-256-GCM from a reviewed library, a fresh nonce per chunk, associated data binding each chunk to its key id and index, and fail-closed decryption that removes both the partial file and the destination. No encryption key appears in any manifest or audit event.
Supply chain
Dependencies are locked; no floating specifier is permitted and a test enforces it. Python
dependencies are audited inside the built images rather than against a manifest, and the
frontend with npm audit. Each release publishes a CycloneDX SBOM bound to the image digests and
the source commit.
Reporting a vulnerability
Report privately to the repository owner. Do not open a public issue.