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
# ---- 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
+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/
# CI outputs when run locally
sbom.cdx.json
gitleaks
gitleaks.tgz
+1 -1
View File
@@ -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-______
+2 -1
View File
@@ -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]
+6 -2
View File
@@ -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
+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:
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)
+3 -1
View File
@@ -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
+2 -1
View File
@@ -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-______________"
)
+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]