The procedures and policies behind this overview live under
docs/compliance/ — quick jump-off:
npm run dr:drill helper automates phases A–D.GET /cp/retention.Email contact@meterbox.ai with details and reproduction steps. Please do
not open public issues for security reports. We aim to acknowledge within 2
business days. Our disclosure program — scope, safe-harbor terms, and our
commitments — is published at
/.well-known/security.txt. Good-faith research
within scope is authorized and protected by safe harbor. Paid researcher
channel + reward tiers live in
docs/compliance/bug-bounty-program.md;
the annual external pentest program is documented at
docs/compliance/penetration-testing.md.
Dependencies are monitored continuously: Dependabot opens update PRs
(.github/dependabot.yml) and the Security scan CI workflow
(.github/workflows/security-scan.yml) runs on every PR, every push to
main, and weekly on a schedule. It runs npm audit (backend + frontend),
a Trivy filesystem scan (vulns + secrets + misconfig) whose results are
uploaded to the GitHub Security tab, and emits a CycloneDX SBOM artifact.
CI hard-fails on a Critical production-dependency advisory
(scripts/ci/npm-audit-gate.js, which honors the time-boxed allowlist below,
plus a Trivy critical gate with ignore-unfixed), so a known, patchable
critical cannot be merged. Lower severities are reported non-blocking and
remediated on the SLA below.
Remediation SLA (from disclosure / Dependabot alert; severity taken from the advisory's CVSS rating as a starting point):
| Severity | Target | Notes |
|---|---|---|
| Critical | 24 hours | Patch, hotfix, or compensating control + ticket. CI blocks merges meanwhile. |
| High | 1 week | |
| Medium | 1 month | |
| Low | 3 months | |
| Informational | best-effort | Tracked, no committed deadline. |
When a fix requires a breaking change we can't land immediately (or no fix
exists), the advisory gets a time-boxed, justified exception in
.github/npm-audit-allowlist.json — the gate ignores it until the until
date, then fails again. Each entry records the compensating control and the
remediation plan. CI logs every suppression on each run, so exceptions are
never silent.
Active exceptions: none. (The prior request/form-data criticals were
resolved by upgrading @kubernetes/client-node to 1.x.)
Per-data-class retention. The live policy is queryable at GET /cp/retention
(owner/admin) and surfaced in the SPA's Settings → Security tab — both read
from src/control-plane/services/retention-policy.js, the single source of
truth that the audit sweeper + session layer also enforce.
| Data class | Store | Retention | Configurable via |
|---|---|---|---|
Audit log (cp_audit_events) |
control-plane Postgres | 365 days, then pruned | AUDIT_RETENTION_DAYS |
| Outbound webhook delivery log | data-plane (per env) | 30 days (terminal rows), then purged | — |
| Session — idle timeout | Redis | 30 min sliding | SESSION_IDLE_TTL_SECONDS |
| Session — absolute cap | Redis | 12 h from login | SESSION_ABSOLUTE_TTL_SECONDS |
| Usage ledger | data-plane PVC | lifetime of the account (statements need full history) | — |
| Credit grants | data-plane (per env) | lifetime; expired flipped to expired, never deleted |
— |
| Reclaim backups (tarballs) | control-plane PV | operator-managed — no auto-prune; snapshot off-host | BACKUP_PATH |
A background sweeper prunes cp_audit_events older than the window (append-only
otherwise — prune is the only deletion path, and only by age). For off-host
SIEM shipping, GET /cp/audit/export?since=&until= streams the org's events as
NDJSON. Audit-log tamper-evidence (WORM) + signed off-host archival remain on
the compliance roadmap.
/cp/* call is recorded in an
append-only log, SHA-256 hash-chained per org. GET /cp/audit/verify walks
the chain on demand and returns either valid:true with hashed_count, or
valid:false with the first broken_at event id + reason. A chain break
is itself an alertable signal.severity: high once the
SECURITY_ALERT_HIGH_THRESHOLD is crossed. Visible in Settings → Security
→ Security alerts (owner / admin).PUT /cp/security-alerts/notifications or the Settings card;
POST /cp/security-alerts/notifications/test fires a synthetic payload
through both channels for wiring confirmation.GET /cp/access-review (JSON or ?format=csv)
returns per-team-member role, MFA posture, last_login_at, last_action_at,
and an active / idle / stale classification derived from the audit
log. Surfaced as a Download CSV link on the Team page. SOC 2 CC6.2 / CC6.3
evidence for quarterly access reviews.This maps the technical controls implemented in this codebase to NIST SP 800-53 Rev. 5 control IDs, as a starting point for a System Security Plan (SSP). It is a self-assessment of what the software enforces — not an ATO or a 3PAO assessment. Organizational and operational controls (policies, training, incident response, contingency planning, personnel, physical) and several deployment-time technical controls (see Operator responsibilities) are required for an actual authorization and are out of scope of the code.
Defaults below are configurable; the FedRAMP-Moderate-aligned values are noted where they differ from the shipped default.
| Control | Implementation | Where |
|---|---|---|
| AC-2 Account Management | Org+owner created at signup; invite flow (email + role); role update/remove; org deletion purges users/memberships + revokes sessions; SSO authenticates existing users only (no self-provision) | routes/auth.js, services/{users,memberships,organizations,invites} |
| AC-3 Access Enforcement | Session/JWT/admin-key auth populates req.auth; owner/admin/member gates on /cp/*; admin gate on /v1/* |
middleware/{authenticate,require-admin}.js |
| AC-6 Least Privilege | Three-tier roles; invite/member-management/org-delete restricted to owner/admin | routes/auth.js |
| AC-7 Unsuccessful Logon Attempts | Throttled login (5/min per IP, 10/hr per email) and MFA verify (30/5min per IP, 10/5min per user) | services/rate-limiter.js, routes/auth.js |
| AC-11 Session Lock (idle) | Sliding inactivity timeout, renewed on each authenticated request — default 30 min (SESSION_IDLE_TTL_SECONDS; FedRAMP Moderate: 900) |
services/sessions.js |
| AC-12 Session Termination | Absolute max-lifetime cap from login regardless of activity — default 12 h (SESSION_ABSOLUTE_TTL_SECONDS); explicit logout + Clear-Site-Data; revoke-all-sessions on password reset |
services/sessions.js, routes/auth.js |
| Control | Implementation | Where |
|---|---|---|
| AU-2 / AU-3 / AU-12 Event logging, content, generation | Append-only cp_audit_events records every mutating /cp/* action and security-relevant failures (login failure, bad-password/invalid-code MFA, rate-limited); each row carries actor id, denormalized actor email (survives user deletion), IP, user-agent, outcome, timestamp, and structured meta |
services/audit.js |
| AU-9 Protection of audit information | Append-only (no update/delete API); exposed read-only to owner/admin via GET /cp/audit |
services/audit.js, routes/auth.js |
| Control | Implementation | Where |
|---|---|---|
| IA-2(1)(2) MFA | TOTP (RFC 6238, authenticator-app compatible) + one-time bcrypt-hashed backup codes; login returns a challenge instead of a session; disable requires both the account password and a fresh second factor | services/mfa.js, routes/auth.js |
| IA-5 Authenticator management | Password policy 12–72 chars + letter + number (NIST 800-63B; no forced composition); always-on local weak-password screen; bcrypt password hashing (cost configurable); one-time email/phone verification codes, invite tokens, and reset tokens all bcrypt-hashed | schemas/index.js, services/{weak-passwords,users,verification,invites,password-reset} |
| IA-5(1) Compromised-password check | Optional HIBP breach screening via k-anonymity (only a 5-char SHA-1 prefix leaves the process; fail-open); off by default for air-gapped installs, or point at a self-hosted mirror | services/pwned-passwords.js |
| IA-8 Federated identity | SSO via OIDC (Google + enterprise IdP); SAML intentionally out of scope | services/oidc.js |
| Control | Implementation | Where |
|---|---|---|
| SC-5 Denial-of-service protection | Per-IP / per-identifier fixed-window rate limiting on every unauthenticated and sensitive endpoint (signup, login, MFA, password-reset, verification, SSO start, CSP report) | services/rate-limiter.js |
| SC-7 Boundary protection | Single-tenant isolation — one pod per (org, env) with its own PVC; COOP/CORP same-origin; X-Frame-Options: DENY. Egress SSRF guard on every tenant-configured outbound URL (webhooks, reverse-ETL sinks, security-alert webhooks): host allow-list rejects loopback/private/link-local/metadata addresses, and redirects are refused so a 3xx can't relay past the check (NetworkPolicy is the complementary perimeter) |
middleware/security-headers.js, lib/ssrf-guard.js, deploy chart |
| SC-8 Transmission confidentiality | HSTS (1 y + includeSubDomains) enforces HTTPS in browsers; Secure cookie flag in production |
middleware/security-headers.js, routes/auth.js |
| SC-18 Mobile code | CSP — default-src 'none' enforced on the JSON API, restrictive policy on /docs; violations reported to /cp/csp-report |
middleware/security-headers.js, routes/csp-report.js |
| SC-23 Session authenticity | 256-bit server-side session tokens in httpOnly + SameSite=Lax cookies; CSRF double-submit on state-changing /cp/* and on session-cookie-authenticated /v1/* mutations (Bearer/admin-key callers exempt; one-time-token endpoints exempted) |
services/sessions.js, control-plane/csrf.js, middleware/require-csrf-for-session.js |
| SC-28 Protection of information at rest | Passwords, MFA secrets/backup codes, verification + reset tokens hashed (bcrypt) or held only in-cluster; customer billing/metering data never leaves the cluster (no managed datastores) | services/*, deploy chart |
| Control | Implementation | Where |
|---|---|---|
| SI-4 System monitoring | CSP violation collector (csp.violation log), structured per-request access log, append-only audit log |
routes/csp-report.js, middleware/access-log.js, services/audit.js |
| SI-10 Information input validation | Zod schema validation on every request body/query/params; structured 400s with field paths | middleware/validate.js, schemas/ |
| Control | Implementation | Where |
|---|---|---|
| CM-6 Configuration settings | Hardened HTTP header baseline applied to every response across all roles; X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Permitted-Cross-Domain-Policies: none, Cache-Control: no-store on sensitive surfaces; X-Powered-By removed |
middleware/security-headers.js |
| CM-7 Least functionality | Permissions-Policy disables unused browser features (camera, mic, geolocation, payment, sensors, Topics) |
middleware/security-headers.js |
| SR-3 / CM-7 Supply chain | Container images published to a private GHCR registry; pods pull via imagePullSecrets |
deploy chart, CI |
MeterBox staff never access tenant data in the course of running the platform. The architecture makes this structural, not just policy:
tests/integration/cross-tenant-isolation.integration.test.js).postgres.rbac.enabled, default on). The
application connects as meterbox_app — a NOSUPERUSER role scoped to the
app database with DML + CREATE on public and nothing cluster-wide. The
meterbox superuser is used only for the one-time bootstrap (break-glass).
A separate meterbox_readonly (SELECT-only) role exists for ops / SIEM /
auditor queries — separation of duties. Default-open PUBLIC grants are
revoked (CIS PostgreSQL). A compromised app pod therefore cannot manage
roles, reach other databases, or escalate.A Business Associate Agreement is available on the on-prem / enterprise tier. Until then the platform is not covered for PHI. Once a BAA is signed, the following data-store scope applies:
| Data store | Contents | PHI-eligible | Notes |
|---|---|---|---|
| Control-plane Postgres | orgs, users, memberships, audit log | ✅ in scope | encrypt the PVC (SC-28); RBAC on by default |
| Data-plane PVC (per org × env) | customer registry, ledger, coupons, credit grants | ✅ in scope | per-tenant isolation; encrypt the PVC |
| Redis | sessions, rate-limit counters, verification flags | 🟡 conditional | no PHI by design; treat as in scope if metadata could carry it |
Reclaim backups (BACKUP_PATH PV) |
gzipped /data archive per reclaim |
✅ in scope | encrypt at rest (roadmap: AEAD + KMS) |
Metering request_id + metadata |
tenant-supplied per-event fields | ⚠️ avoid | enable zero-retention metering (/v1/settings/privacy) so these are never persisted |
| Stripe (external, opt-in) | payment data | ❌ out of scope here | covered by the subprocessor's own PCI posture |
To keep PHI out of MeterBox's persisted surface entirely, tenants on PHI
workloads should enable zero-retention metering so only token aggregates +
cost are stored — no request_id, no metadata.
The codebase cannot enforce these; they must be provided by the deployment:
security.networkPolicies.enabled, default on) with default-deny + narrow allow rules and cross-tenant DP↔DP isolation; the operator must run a CNI that enforces them (Calico, Cilium, …) — k3d/flannel may not. For air-gapped installs, leave PWNED_PASSWORDS_CHECK off or use an in-cluster mirror.AUDIT_RETENTION_DAYS, default 365; pruned by the audit-retention sweeper) and exportable as NDJSON via GET /cp/audit/export for off-host SIEM ingestion (see Data retention). Operator still owns SIEM ingestion + WORM/tamper-evidence archival.SESSION_COOKIE_SECRET, the CP_JWT_PRIVATE_KEY / CP_JWT_PUBLIC_KEY keypair, JWT_SECRET, and registry credentials.This document covers the technical control surface only; a full FedRAMP/SOC 2 authorization additionally requires the organizational controls and a formal assessment. See the compliance & trust overview for framework status.