Phase 0 milestone 0.8: CI pipeline on Gitea Actions

Workflow moved to .gitea/workflows/ci.yml (Gitea is the CI host). Every
gate now passes locally, step-for-step; green on main is pending an
act_runner on gitea.doesworks.net (runs #1-#5 are queued "Waiting").

Gate fixes found by actually running them:
- pip-audit: `--disable-pip` without `-r` was a usage error, so the old
  step could never pass. Now `pip-audit --strict .` audits the runtime
  dependency set from pyproject.
- pip-audit then flagged ecdsa (PYSEC-2026-1325, no fix), a hard dep of
  python-jose. Swapped to PyJWT (HS256 only; 3-line change in
  auth/security.py). Flag to OWC.
- PyJWT warned the test JWT secret was 11 bytes. JWT_SECRET now requires
  >= 32 bytes (RFC 7518 s3.2) at settings load; dev/compose/test defaults
  updated. Test-only value carries an inline gitleaks:allow.
- bandit B101: assert in retention._count_past replaced with a raise.
- gitleaks: gitleaks-action needs a GitHub token/license; replaced with
  the pinned 8.30.1 binary, sha256-verified, scanning full history.

Added:
- SBOM: CycloneDX 1.6 from a clean runtime-only venv (39 components),
  uploaded as a CI artifact. YPMS-POAM-042.
- cyclonedx-bom in dev extras; CI outputs gitignored.

158 tests passing, 95.83% coverage; ruff, mypy, bandit, pip-audit,
gitleaks clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris TonjesandClaude Opus 5.5 committed 2026-10-01 10:57:09 -04:00
1 parent 20a7fdf262
commit 5f2d885fdc
11 files changed
+123 -55

No files matched your search

+2 -1
View File
@@ -8,7 +8,8 @@
DATABASE_URL=postgresql+psycopg://ypms:ypms@localhost:5433/ypms DATABASE_URL=postgresql+psycopg://ypms:ypms@localhost:5433/ypms
# ---- JWT ---- # ---- 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 JWT_ALG=HS256
ACCESS_TOKEN_TTL_MINUTES=60 ACCESS_TOKEN_TTL_MINUTES=60
REFRESH_TOKEN_TTL_DAYS=14 REFRESH_TOKEN_TTL_DAYS=14
+81
View File
@@ -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
-47
View File
@@ -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 }}
+5
View File
@@ -60,3 +60,8 @@ logs/
# Alembic scratch (real migrations are checked in under migrations/versions/) # Alembic scratch (real migrations are checked in under migrations/versions/)
alembic/ alembic/
# CI outputs when run locally
sbom.cdx.json
gitleaks
gitleaks.tgz
+1 -1
View File
@@ -24,7 +24,7 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql+psycopg://ypms:ypms@db:5432/ypms 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 JWT_ALG: HS256
ACCESS_TOKEN_TTL_MINUTES: 60 ACCESS_TOKEN_TTL_MINUTES: 60
COLUMN_ENCRYPTION_KEY: dev-col-enc-key-change-me-32bytes-______ COLUMN_ENCRYPTION_KEY: dev-col-enc-key-change-me-32bytes-______
+2 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"pydantic[email]>=2.8", "pydantic[email]>=2.8",
"pydantic-settings>=2.4", "pydantic-settings>=2.4",
"passlib>=1.7", "passlib>=1.7",
"python-jose[cryptography]>=3.3", "pyjwt>=2.9",
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"structlog>=24.4", "structlog>=24.4",
"cryptography>=43", "cryptography>=43",
@@ -29,6 +29,7 @@ dev = [
"mypy>=1.11", "mypy>=1.11",
"bandit>=1.7", "bandit>=1.7",
"pip-audit>=2.7", "pip-audit>=2.7",
"cyclonedx-bom>=4",
] ]
[build-system] [build-system]
+6 -2
View File
@@ -11,6 +11,10 @@ NIST 800-63 IAL/AAL semantics: `pre_application` < `standard` <
require `payroll_authorized` or higher (Phase 7+). require `payroll_authorized` or higher (Phase 7+).
pbkdf2_sha256 not bcrypt — see ARCHITECTURE.md §4.6. 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 from __future__ import annotations
@@ -18,7 +22,7 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from jose import JWTError, jwt import jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from ypms.settings import get_settings from ypms.settings import get_settings
@@ -77,6 +81,6 @@ def decode_token(token: str) -> dict[str, Any]:
s = get_settings() s = get_settings()
try: try:
payload: dict[str, Any] = jwt.decode(token, s.jwt_secret, algorithms=[s.jwt_alg]) 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 raise ValueError("invalid token") from e
return payload return payload
+2 -1
View File
@@ -133,7 +133,8 @@ def mapped_models() -> list[type[Any]]:
def _count_past(db: Session, model: type[Any], policy: RetentionPolicy, now: datetime) -> int: 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) anchor = getattr(model, policy.anchor)
cutoff = now - timedelta(days=policy.period_days) cutoff = now - timedelta(days=policy.period_days)
stmt = select(func.count()).select_from(model).where(anchor < cutoff) stmt = select(func.count()).select_from(model).where(anchor < cutoff)
+3 -1
View File
@@ -1,5 +1,6 @@
from functools import lru_cache from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -10,7 +11,8 @@ class Settings(BaseSettings):
database_url: str database_url: str
# JWT + auth # 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" jwt_alg: str = "HS256"
access_token_ttl_minutes: int = 60 access_token_ttl_minutes: int = 60
refresh_token_ttl_days: int = 14 refresh_token_ttl_days: int = 14
+2 -1
View File
@@ -16,7 +16,8 @@ from collections.abc import Callable, Iterator
from typing import Any from typing import Any
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") 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( os.environ.setdefault(
"COLUMN_ENCRYPTION_KEY", "test-col-enc-key-32bytes-______________" "COLUMN_ENCRYPTION_KEY", "test-col-enc-key-32bytes-______________"
) )
+19
View File
@@ -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]