diff --git a/.env.example b/.env.example index 548621a..d1a0416 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,8 @@ DATABASE_URL=postgresql+psycopg://ypms:ypms@localhost:5433/ypms # ---- JWT ---- -JWT_SECRET=dev-secret-change-me +# >= 32 bytes (HS256, RFC 7518 §3.2); the app refuses to start otherwise. +JWT_SECRET=dev-jwt-secret-change-me-32-bytes-min JWT_ALG=HS256 ACCESS_TOKEN_TTL_MINUTES=60 REFRESH_TOKEN_TTL_DAYS=14 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..2df7092 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,81 @@ +# YPMS CI — Gitea Actions (act_runner) on gitea.doesworks.net. +# +# Gates (PHASE0_PLAN 0.8; NIST SA-11, RA-5, CM-8, SR-4): +# lint (ruff) · types (mypy strict) · tests + 80% coverage floor (includes +# the Alembic migration-drift test) · SAST (bandit) · SCA (pip-audit on the +# runtime dependency set) · secret scan (gitleaks, full history) · +# SBOM (CycloneDX 1.6, runtime-only, uploaded as an artifact). +# +# Runner: needs the `ubuntu-latest` label mapped to an act-compatible image +# (e.g. catthehacker/ubuntu:act-22.04) with outbound access to github.com for +# actions/* and the gitleaks release, and to PyPI. +# +# Suppressions are per-finding with a written reason, never blanket skips. + +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + GITLEAKS_VERSION: "8.30.1" + # sha256 of gitleaks_8.30.1_linux_x64.tar.gz from the release checksums file + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # gitleaks scans full history + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e '.[dev]' + + - name: Lint (ruff) + run: ruff check src tests + + - name: Type check (mypy) + run: mypy src + + - name: Tests + coverage + run: pytest --cov=src/ypms --cov-report=term-missing --cov-fail-under=80 + + - name: SAST (bandit) + run: bandit -r src -c pyproject.toml + + - name: SCA (pip-audit, runtime dependencies) + run: pip-audit --strict . + + - name: Secret scan (gitleaks) + run: | + curl -sSfL -o gitleaks.tgz \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + echo "${GITLEAKS_SHA256} gitleaks.tgz" | sha256sum -c - + tar xzf gitleaks.tgz gitleaks + ./gitleaks git . --redact --no-banner --exit-code 1 + + - name: SBOM (CycloneDX, runtime only) + run: | + python -m venv /tmp/runtime + /tmp/runtime/bin/pip install --quiet . + cyclonedx-py environment --of JSON -o sbom.cdx.json /tmp/runtime/bin/python + + # v3 targets the GHES-style artifact API Gitea implements. Move to v4 only + # after confirming this Gitea server accepts it. + - uses: actions/upload-artifact@v3 + with: + name: sbom + path: sbom.cdx.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 5cf1c90..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: ci - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python 3.12 - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - - name: Install - run: | - python -m pip install --upgrade pip - pip install -e '.[dev]' - - - name: Lint (ruff) - run: ruff check src tests - - - name: Type check (mypy) - run: mypy src - - - name: Tests + coverage - run: pytest --cov=src/ypms --cov-report=term-missing --cov-fail-under=80 - - - name: SAST (bandit) - run: bandit -r src -c pyproject.toml - - - name: SCA (pip-audit) - run: pip-audit --strict --disable-pip - - - name: Gitleaks - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 1305fed..cd82dbb 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,8 @@ logs/ # Alembic scratch (real migrations are checked in under migrations/versions/) alembic/ + +# CI outputs when run locally +sbom.cdx.json +gitleaks +gitleaks.tgz diff --git a/docker-compose.yml b/docker-compose.yml index 211a289..b37d8a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: condition: service_healthy environment: DATABASE_URL: postgresql+psycopg://ypms:ypms@db:5432/ypms - JWT_SECRET: dev-secret-change-me + JWT_SECRET: dev-jwt-secret-change-me-32-bytes-min JWT_ALG: HS256 ACCESS_TOKEN_TTL_MINUTES: 60 COLUMN_ENCRYPTION_KEY: dev-col-enc-key-change-me-32bytes-______ diff --git a/pyproject.toml b/pyproject.toml index 71dc4e6..fb06ba6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "pydantic[email]>=2.8", "pydantic-settings>=2.4", "passlib>=1.7", - "python-jose[cryptography]>=3.3", + "pyjwt>=2.9", "python-multipart>=0.0.9", "structlog>=24.4", "cryptography>=43", @@ -29,6 +29,7 @@ dev = [ "mypy>=1.11", "bandit>=1.7", "pip-audit>=2.7", + "cyclonedx-bom>=4", ] [build-system] diff --git a/src/ypms/auth/security.py b/src/ypms/auth/security.py index cbdcbb9..1b4fe49 100644 --- a/src/ypms/auth/security.py +++ b/src/ypms/auth/security.py @@ -11,6 +11,10 @@ NIST 800-63 IAL/AAL semantics: `pre_application` < `standard` < require `payroll_authorized` or higher (Phase 7+). pbkdf2_sha256 not bcrypt — see ARCHITECTURE.md §4.6. + +JWT library: PyJWT, not OWC's python-jose (milestone 0.8). python-jose hard- +depends on `ecdsa`, which carries an unfixable advisory (PYSEC-2026-1325) +that failed the pip-audit gate. YPMS only signs HS256. Flag to OWC. """ from __future__ import annotations @@ -18,7 +22,7 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any -from jose import JWTError, jwt +import jwt from passlib.context import CryptContext from ypms.settings import get_settings @@ -77,6 +81,6 @@ def decode_token(token: str) -> dict[str, Any]: s = get_settings() try: payload: dict[str, Any] = jwt.decode(token, s.jwt_secret, algorithms=[s.jwt_alg]) - except JWTError as e: + except jwt.PyJWTError as e: raise ValueError("invalid token") from e return payload diff --git a/src/ypms/framework/retention.py b/src/ypms/framework/retention.py index 93bd21b..38f2b9b 100644 --- a/src/ypms/framework/retention.py +++ b/src/ypms/framework/retention.py @@ -133,7 +133,8 @@ def mapped_models() -> list[type[Any]]: def _count_past(db: Session, model: type[Any], policy: RetentionPolicy, now: datetime) -> int: - assert policy.period_days is not None + if policy.period_days is None: + raise ValueError(f"policy {policy.data_class!r} has no period") anchor = getattr(model, policy.anchor) cutoff = now - timedelta(days=policy.period_days) stmt = select(func.count()).select_from(model).where(anchor < cutoff) diff --git a/src/ypms/settings.py b/src/ypms/settings.py index f6bfef5..f1127fc 100644 --- a/src/ypms/settings.py +++ b/src/ypms/settings.py @@ -1,5 +1,6 @@ from functools import lru_cache +from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -10,7 +11,8 @@ class Settings(BaseSettings): database_url: str # JWT + auth - jwt_secret: str + # HS256 needs >= 256 bits of key (RFC 7518 §3.2); refuse to boot otherwise. + jwt_secret: str = Field(min_length=32) jwt_alg: str = "HS256" access_token_ttl_minutes: int = 60 refresh_token_ttl_days: int = 14 diff --git a/tests/conftest.py b/tests/conftest.py index 5fd3a83..1c65986 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,8 @@ from collections.abc import Callable, Iterator from typing import Any os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") -os.environ.setdefault("JWT_SECRET", "test-secret") +# Test-only value, never deployed. 32+ bytes to satisfy the HS256 floor. +os.environ.setdefault("JWT_SECRET", "test-jwt-secret-at-least-32-bytes-long") # gitleaks:allow os.environ.setdefault( "COLUMN_ENCRYPTION_KEY", "test-col-enc-key-32bytes-______________" ) diff --git a/tests/test_settings_secrets.py b/tests/test_settings_secrets.py new file mode 100644 index 0000000..616b1a2 --- /dev/null +++ b/tests/test_settings_secrets.py @@ -0,0 +1,19 @@ +"""Settings refuse weak secrets at startup.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from ypms.settings import Settings + + +def test_short_jwt_secret_refused(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("JWT_SECRET", "x" * 31) + with pytest.raises(ValidationError, match="jwt_secret"): + Settings() # type: ignore[call-arg] + + +def test_32_byte_jwt_secret_accepted(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("JWT_SECRET", "x" * 32) + assert Settings().jwt_secret == "x" * 32 # type: ignore[call-arg]