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:
1 parent
20a7fdf262
commit
5f2d885fdc
11 files changed
+123
-55
No files matched your search
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 }}
|
|
||||||
@@ -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
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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-______________"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
Reference in new issue
Block a user