Skip to content

Commit b502132

Browse files
committed
release 3.0.0: test-DB migrations, ruff baseline, typed electronAPI, /health enrichments, multi-worker rate-limit backend, pydantic 3.13 pins, CSRF invariant comment, README + CHANGELOG
Addresses the 3.0 follow-up items on top of the phase 1-6 hardening pass: - backend/tests now run Alembic `upgrade head` against the in-memory DB, so future migrations (like the Phase 4 CHECKs and the Phase 6 index) are exercised every pytest run instead of bypassed via create_all. - ruff baseline added via backend/pyproject.toml; CI's new lint step is clean and pytest still green (182 passed, 8 skipped). - frontend/src/types/electron.d.ts types window.electronAPI; LicenseContext drops every `any` cast and TS strict mode stays green. - /health returns git_sha + started_at alongside version so oncall can map an incident to a specific deploy. - rate_limit.py honours RATELIMIT_STORAGE_URI and logs a loud warning when run with WORKERS>1 without a shared backend. - requirements.txt / requirements-test.txt: pydantic >=2.10, sqlalchemy >=2.0.32 etc — Python 3.13 contributors no longer need a Rust toolchain. - auth.py top-of-module comment documents the CSRF-safety invariant (auth state lives in the Authorization header, never a cookie). - VERSION bumped to 3.0.0 across the three SSOT constants files, both package.json, both package-lock.json. README banner + "What's new" block added; CHANGELOG 3.0.0 entry with upgrade notes.
1 parent c3beec6 commit b502132

35 files changed

Lines changed: 457 additions & 109 deletions

CHANGELOG.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,140 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
---
88

9+
## [3.0.0] — 2026-04-22
10+
11+
Major hardening release. Lifts the codebase rating from ~7.8 to ~9.0 by
12+
closing the long-tail of security, correctness, and scaling issues that
13+
would have caused incidents at SaaS scale. Contains breaking changes — see
14+
**Upgrade notes** below before deploying.
15+
16+
### Added
17+
- **Filesystem-backed evidence uploads** — Manual uploads now write to
18+
`EVIDENCE_STORAGE_PATH` (default `./storage`). The DB stores a path only,
19+
not the bytes. New endpoint `GET /api/v1/evidence/items/{id}/download`
20+
streams the file back with a path-traversal guard.
21+
- **HTTPS-ready nginx config** — `nginx.conf` ships HTTPS on port 443
22+
(SSL certs via mounted `./ssl/`), HTTP → HTTPS redirect, HSTS, a locked-
23+
down Content-Security-Policy, `Permissions-Policy`, and 404 responses for
24+
`/docs`, `/redoc`, and `/openapi.json`. `nginx.dev.conf` +
25+
`docker-compose.dev.yml` keep HTTP-only local dev frictionless.
26+
- **SSOT for cross-repo constants** — `backend/app/core/constants.py`,
27+
`frontend/src/constants.ts`, and `electron/licensing/tier-constants.js`
28+
all carry `VERSION`, `VALID_LICENSE_TIERS`, `VALID_COMPLIANCE_LEVELS`,
29+
`MACHINE_LIMITS`, and `FEATURE_GATES` with cross-pointer headers.
30+
- **Enriched `/health`** — Now returns `git_sha` and `started_at` alongside
31+
the version string, so oncall can map an incident to a specific deploy.
32+
- **`GET /api/v1/machines` pagination** — `?limit=` (default 50, max 200)
33+
and `?offset=` query params.
34+
- **CHECK constraints** — `users.license_tier` locked to
35+
`{free, pro, enterprise}` and `machines.compliance_level` locked to
36+
`{NULL, compliant, at_risk, critical}` at the DB level.
37+
- **Rate limits on auth + credential endpoints** — `/forgot-password`
38+
(3/min), `/reset-password` (5/min), and every AWS-credential endpoint
39+
now carry slowapi limits.
40+
- **Domain-separated credential encryption key** — Fernet key derived from
41+
`SECRET_KEY` via HKDF-SHA256 with label
42+
`complianceguard:credential-encryption:v1`. Legacy SHA-256 derivation
43+
retained as a read-only fallback so pre-3.0 rows still decrypt.
44+
- **Multi-worker rate-limit backend** — `RATELIMIT_STORAGE_URI` env var is
45+
honoured (e.g. `redis://host:6379/0`). Starting under `WORKERS>1` without
46+
a shared backend logs a WARNING.
47+
- **Ruff lint step in CI** — New `pyproject.toml` with the CI rule set;
48+
`backend-tests` now runs `ruff check app` before pytest.
49+
- **Pip caching + release-job `build` dependency** — CI's release job was
50+
able to ship even when the frontend build broke; it now waits on `build`.
51+
- **Alembic migrations `7a1c4f9b2d08` and `8b2e7c1d5a19`** — data-model
52+
hardening (CHECKs, `Machine.updated_at`, nullable
53+
`ComplianceFramework.company_id`) and index on
54+
`evidence_collections.user_id`.
55+
56+
### Changed — breaking
57+
- **Hardcoded docker-compose fallbacks removed.** `SECRET_KEY` and
58+
`DB_PASSWORD` no longer have silent defaults. An unset value aborts the
59+
stack at boot with a readable error. Existing deployments relying on the
60+
published demo values will fail fast — set real values in `.env` before
61+
upgrading.
62+
- **Manual evidence storage format changed.** Uploads are now written to
63+
the filesystem; `data.content_base64` on `EvidenceItem` is gone. Any
64+
automation that reached into that column must switch to the new
65+
`GET /api/v1/evidence/items/{id}/download` endpoint. (If you have
66+
existing base64 rows, write a one-time script to extract and rewrite
67+
them — see `backend/app/api/evidence.py` for the new shape.)
68+
- **`GET /api/v1/machines` is paginated by default.** Old callers that
69+
assumed an unbounded list now receive at most 50 machines. Pass
70+
`?limit=200&offset=…` if you need more.
71+
- **Electron `cloudConnect` sends `application/x-www-form-urlencoded`.**
72+
Fixes silent 422 on every sign-in in ≤2.9.0. No action required — this
73+
was simply broken before.
74+
- **`fleet_stats` and `get_machines` no longer load the full machines
75+
table into Python.** Same API shape; only the query plan changed.
76+
77+
### Fixed
78+
- **Grace-period lockout.** `electron/licensing/license-crypto.js` now
79+
returns `valid: true` during the 7-day renewal grace window. Paid
80+
desktop users were previously kicked off on the day of expiry.
81+
- **`datetime.utcnow()` deprecation.** Replaced with
82+
`datetime.now(timezone.utc)` in all production code.
83+
- **Module-load side effects on tests.** `run_migrations()` now runs from
84+
the FastAPI lifespan handler, so importing `app.main` in tests does not
85+
hit the DB.
86+
- **Unbounded in-memory eval cache.** `ComplianceService.evaluations` is
87+
now a `collections.OrderedDict` capped at 100 entries (FIFO).
88+
- **Mutating a Pydantic v2 model post-construction.** `Settings`
89+
environment overrides are now applied via `@model_validator(mode="after")`
90+
instead of a module-level `setattr` loop.
91+
- **`SECRET_KEY` captured at module import.** JWT helpers now resolve the
92+
key lazily, so pytest env-var overrides are honoured.
93+
- **`pydantic.v1.ConfigDict` import** in `config.py` removed; three
94+
`class Config:` blocks migrated to `ConfigDict(...)`.
95+
- **Cloud-sync plaintext fallback.** `secure-storage.js` fallback path now
96+
AES-256-GCM-encrypts values with a machine-derived key and logs a loud
97+
warning, instead of storing plaintext. `decryptString` throws a
98+
descriptive error on failure instead of returning an empty string.
99+
- **Windows evidence collection sequential loop.** Ten `exec` calls now
100+
run concurrently via `Promise.allSettled`, each with a 30s default
101+
timeout. Fixed a pre-existing `log` variable shadow in `collectEventLogs`.
102+
- **Web-mode license activation.** `LicenseContext.activateLicense` now
103+
calls `POST /api/auth/activate-license` instead of returning
104+
"Requires desktop app".
105+
- **Release CI gap.** `release` job now `needs: [..., build]`.
106+
107+
### Security
108+
- **CSRF invariant documented.** `backend/app/api/auth.py` now carries a
109+
top-of-file comment spelling out that auth state rides in the
110+
`Authorization` header only — the API is CSRF-safe as long as no
111+
cookie-based auth path is ever introduced without explicit CSRF
112+
protection.
113+
114+
### Upgrade notes
115+
1. Set `SECRET_KEY` and `DB_PASSWORD` in your `.env` (generate with
116+
`openssl rand -hex 32` for the former). The stack will refuse to start
117+
otherwise.
118+
2. Provide TLS certificates at `./ssl/cert.pem` and `./ssl/key.pem` for the
119+
production `nginx` service, or run the dev stack via
120+
`docker-compose -f docker-compose.yml -f docker-compose.dev.yml up`.
121+
3. If you run `uvicorn` with `WORKERS>1`, set
122+
`RATELIMIT_STORAGE_URI=redis://…` — otherwise rate limits silently
123+
multiply by worker count.
124+
4. Expose `GIT_SHA` (usually via CI) in your deploy env to get meaningful
125+
`git_sha` values in `/health`.
126+
5. Run Alembic: `alembic upgrade head` applies `7a1c4f9b2d08` and
127+
`8b2e7c1d5a19`. Existing rows whose `license_tier` is outside
128+
`{free, pro, enterprise}` — or `compliance_level` outside
129+
`{compliant, at_risk, critical}` — will fail the new CHECK. Fix the
130+
data first.
131+
132+
### Developer experience
133+
- Backend test DB now runs Alembic migrations (via `create_test_database`
134+
→ `alembic upgrade head`) instead of `Base.metadata.create_all`. New
135+
migrations are exercised by every pytest run.
136+
- `requirements.txt` and `requirements-test.txt` ranges relaxed so Python
137+
3.13 contributors don't need a Rust toolchain.
138+
- `frontend/src/types/electron.d.ts` replaces the `(window as any)` casts
139+
in `LicenseContext` with a typed surface.
140+
141+
---
142+
9143
## [2.9.0] — 2026-04-17
10144

11145
### Added

README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
</p>
44

55
<p align="center">
6-
<a href="#quick-start"><img src="https://img.shields.io/badge/version-2.9.0-2563EB" alt="Version"></a>
6+
<a href="#quick-start"><img src="https://img.shields.io/badge/version-3.0.0-2563EB" alt="Version"></a>
77
<img src="https://img.shields.io/badge/license-BSL%201.1-orange" alt="License">
88
<a href="#soc-2-controls"><img src="https://img.shields.io/badge/SOC%202-29%20controls-10B981" alt="Controls"></a>
99
<img src="https://img.shields.io/badge/tests-311%20passing-10B981?logo=vitest&logoColor=white" alt="Tests">
@@ -13,6 +13,42 @@
1313

1414
---
1515

16+
### What's new in 3.0.0 — security & data hardening
17+
18+
`3.0.0` is a breaking release focused on tightening the things that would
19+
have eventually caused outages or incidents at scale. Highlights:
20+
21+
- **Deploys fail loudly on missing secrets.** `docker-compose.yml` no longer
22+
falls back to the published demo SECRET_KEY or DB_PASSWORD; an unset value
23+
aborts the stack at boot with a readable error.
24+
- **Filesystem-backed evidence uploads.** Manual uploads are stored under
25+
`EVIDENCE_STORAGE_PATH` (default `./storage`) and referenced from the DB
26+
by path — no more 100 MB base64 blobs in a JSON column. A new
27+
`GET /api/v1/evidence/items/{id}/download` endpoint serves them back with
28+
a path-traversal guard.
29+
- **Check constraints on enum columns.** `users.license_tier` and
30+
`machines.compliance_level` are now CHECK-constrained at the DB layer, so
31+
a bad value can no longer sneak in via a direct SQL write.
32+
- **Hardened nginx.** HTTPS block active, CSP / HSTS / Permissions-Policy
33+
headers added, deprecated `X-XSS-Protection` removed, `/docs` and
34+
`/openapi.json` return 404 publicly. A separate `nginx.dev.conf` and
35+
`docker-compose.dev.yml` keep HTTP-only local dev frictionless.
36+
- **Rate limits extended.** `/forgot-password`, `/reset-password`, and every
37+
AWS-credential endpoint now carry slowapi limits. Set
38+
`RATELIMIT_STORAGE_URI=redis://…` when running with multiple workers —
39+
the app logs a warning if you don't.
40+
- **Fleet stats is now a single query.** `/api/v1/machines/fleet-stats`
41+
aggregates in SQL instead of loading every row into Python, and
42+
`GET /api/v1/machines` is paginated (`?limit=…&offset=…`).
43+
- **Grace-period lockout fix.** Paid desktop users are no longer kicked off
44+
during the 7-day renewal grace window.
45+
- **Credential encryption key is domain-separated** from the JWT signing key
46+
via HKDF-SHA256 (with transparent fallback for legacy-encrypted rows).
47+
48+
See [`CHANGELOG.md`](CHANGELOG.md) for the full list plus upgrade notes.
49+
50+
---
51+
1652
Compliance tools like Vanta, Drata, and Sprinto scan your cloud infrastructure. That's useful — but they can't see what's happening **on the machines themselves**. Password policies, firewall rules, event logs, running services, local user accounts — that evidence lives on the endpoint, not in AWS.
1753

1854
ComplianceGuard lives on the endpoint too. It collects evidence directly from Windows, scores it against 29 SOC 2 Type II controls, and tells you exactly where the gaps are. Run it as a desktop app or deploy the web version with Docker — everything stays under your control.

backend/app/api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@
1717
# - controls.py: Control assessment endpoints
1818
# - evidence.py: Evidence collection APIs
1919
# - reports.py: Compliance reporting
20-
# - admin.py: Administrative functions
20+
# - admin.py: Administrative functions

backend/app/api/auth.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
"""
22
Authentication API endpoints for ComplianceGuard.
33

4-
This module provides JWT-based authentication endpoints including login and registration.
4+
This module provides JWT-based authentication endpoints including login and
5+
registration.
6+
7+
CSRF invariant — DO NOT SET AUTH COOKIES
8+
----------------------------------------
9+
Authentication state is carried exclusively via the ``Authorization: Bearer``
10+
header. No endpoint in this module sets an auth cookie, and the frontend is
11+
expected to store tokens in ``localStorage``, not cookies. That's what makes
12+
the API CSRF-safe without an explicit CSRF token: a cross-site forgery
13+
attempt cannot attach the bearer header because JS on an attacker-controlled
14+
origin has no access to our origin's ``localStorage``.
15+
16+
If you ever add a cookie-based auth path here (session cookie, persistent
17+
login, OAuth proxy, etc.), you MUST also add CSRF protection — SameSite=Lax
18+
alone is not sufficient for state-changing endpoints.
519
"""
620

721
from datetime import timedelta, datetime, timezone

backend/app/api/compliance.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
from datetime import datetime, timezone
1515
import uuid
1616

17-
from app.core.soc2_controls import SOC2Framework, SOC2Control, ControlCategory, create_soc2_framework
18-
from app.services.compliance_service import ComplianceService, ComplianceEvaluation, ComplianceStatus, create_compliance_service
17+
from app.core.soc2_controls import SOC2Control, create_soc2_framework
18+
from app.services.compliance_service import ComplianceStatus, create_compliance_service
1919
from app.core.database import get_db
2020
from app.api.deps import get_current_user, require_pro
2121
from app.models.user import User

backend/app/api/evidence.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,29 @@
44
REST API endpoints for managing and collecting compliance evidence.
55
"""
66

7-
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
8-
from fastapi.responses import FileResponse
9-
from sqlalchemy.orm import Session
10-
from typing import Dict, Any, Optional, List
11-
from pydantic import BaseModel, ConfigDict
12-
from datetime import datetime, timezone
137
import logging
148
import os
159
import re
1610
import uuid
11+
from datetime import datetime, timezone
12+
from typing import Any, Dict, List, Optional
1713

18-
logger = logging.getLogger(__name__)
14+
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
15+
from fastapi.responses import FileResponse
16+
from pydantic import BaseModel, ConfigDict
17+
from sqlalchemy.orm import Session
1918

2019
from app.api.deps import get_current_user
21-
from app.core.database import get_db
2220
from app.core.config import settings
2321
from app.core.credential_crypto import decrypt_credential
24-
from app.models.user import User
25-
from app.models.evidence import EvidenceCollection, EvidenceItem
22+
from app.core.database import get_db
2623
from app.models.aws_credential import AwsCredential
24+
from app.models.evidence import EvidenceCollection, EvidenceItem
25+
from app.models.user import User
2726
from app.services.evidence_collector import EvidenceCollectionService
2827

28+
logger = logging.getLogger(__name__)
29+
2930
router = APIRouter(prefix="/evidence", tags=["evidence"])
3031

3132
# Pre-compute max bytes once so it isn't recalculated per request

backend/app/api/machines.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ async def sync_machine(
9191
if limit is not None:
9292
current_count = (
9393
db.query(Machine)
94-
.filter(Machine.user_id == current_user.id, Machine.is_active == True)
94+
.filter(Machine.user_id == current_user.id, Machine.is_active.is_(True))
9595
.count()
9696
)
9797
if current_count >= limit:
@@ -155,7 +155,7 @@ async def get_fleet_stats(
155155
).label("never_synced"),
156156
func.avg(Machine.last_score).label("avg_score"),
157157
)
158-
.filter(Machine.user_id == current_user.id, Machine.is_active == True) # noqa: E712
158+
.filter(Machine.user_id == current_user.id, Machine.is_active.is_(True))
159159
.one()
160160
)
161161

@@ -188,7 +188,7 @@ async def get_machines(
188188
"""
189189
machines = (
190190
db.query(Machine)
191-
.filter(Machine.user_id == current_user.id, Machine.is_active == True) # noqa: E712
191+
.filter(Machine.user_id == current_user.id, Machine.is_active.is_(True))
192192
.order_by(Machine.last_sync_at.desc().nullslast(), Machine.id.desc())
193193
.offset(offset)
194194
.limit(limit)

backend/app/core/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,4 +364,4 @@ def validate_production_settings() -> List[str]:
364364
if production_warnings:
365365
import warnings
366366
for warning in production_warnings:
367-
warnings.warn(warning, UserWarning)
367+
warnings.warn(warning, UserWarning)

backend/app/core/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
- frontend/package.json
2121
"""
2222

23-
VERSION = "2.9.0"
23+
VERSION = "3.0.0"
2424

2525
VALID_LICENSE_TIERS = ("free", "pro", "enterprise")
2626
VALID_COMPLIANCE_LEVELS = ("compliant", "at_risk", "critical")

backend/app/core/database.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from sqlalchemy import create_engine
99
from sqlalchemy.orm import declarative_base, sessionmaker
1010
from sqlalchemy.pool import StaticPool
11-
from typing import Generator, Optional
11+
from typing import Generator
1212

1313
from app.core.config import get_database_url, settings
1414

@@ -98,9 +98,38 @@ def get_session_factory():
9898

9999

100100
def create_test_database():
101-
"""Create a test database with all tables. Returns the test engine."""
101+
"""
102+
Create a test database with the real schema by running Alembic migrations.
103+
104+
We deliberately avoid ``Base.metadata.create_all`` so CHECK constraints,
105+
server defaults, and new indexes ship the same way tests see them —
106+
migrations. If migrations ever break, the test suite fails loudly
107+
instead of silently drifting.
108+
109+
Falls back to ``create_all`` only when ``alembic.ini`` is missing (ad-hoc
110+
scripts running the module outside the repo tree).
111+
"""
112+
import os
113+
102114
test_engine = create_database_engine(testing=True)
103-
Base.metadata.create_all(bind=test_engine)
115+
116+
alembic_ini = os.path.join(
117+
os.path.dirname(__file__), "..", "..", "alembic.ini"
118+
)
119+
if not os.path.exists(alembic_ini):
120+
Base.metadata.create_all(bind=test_engine)
121+
return test_engine
122+
123+
from alembic import command as alembic_command
124+
from alembic.config import Config as AlembicConfig
125+
126+
alembic_cfg = AlembicConfig(alembic_ini)
127+
alembic_cfg.set_main_option("sqlalchemy.url", str(test_engine.url))
128+
# Pin the same engine's connection so the migrations operate on the
129+
# in-memory DB pytest is using, not a fresh file-backed one.
130+
with test_engine.connect() as connection:
131+
alembic_cfg.attributes["connection"] = connection
132+
alembic_command.upgrade(alembic_cfg, "head")
104133
return test_engine
105134

106135

0 commit comments

Comments
 (0)