3 Commits

Author SHA1 Message Date
3932aa56f7 [NX-202 Issue] Add pip-audit CI enforcement for Python dependency security
All checks were successful
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m41s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Python Dependency Security / pip-audit (block high/critical) (push) Successful in 50s
This commit integrates pip-audit to enforce vulnerability checks in CI. Dependencies with unresolved HIGH/CRITICAL vulnerabilities will block builds unless explicitly allowlisted. The process is documented, with a strict policy to ensure exceptions are trackable and time-limited.
2026-02-15 10:44:33 +01:00
9657bd7a36 Merge branch 'main' of https://git.nesterovic.cc/nessi/NexaPG into development
All checks were successful
Migration Safety / Alembic upgrade/downgrade safety (push) Successful in 20s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
2026-02-15 10:33:56 +01:00
574e2eb9a5 Ensure valid Docker Hub namespace in release workflow
All checks were successful
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m44s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Added validation to normalize input, reject invalid namespaces, and check for proper formatting in the Docker Hub namespace. This prevents configuration mistakes and ensures compliance with naming requirements.
2026-02-15 10:32:44 +01:00
6 changed files with 346 additions and 2 deletions

View File

@@ -27,6 +27,20 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Dependency security gate (pip-audit)
run: |
python -m pip install --upgrade pip
pip install pip-audit
pip-audit -r backend/requirements.txt --format json --aliases --output pip-audit-backend.json || true
python backend/scripts/pip_audit_gate.py \
--report pip-audit-backend.json \
--allowlist ops/security/pip-audit-allowlist.json
- name: Resolve version/tag - name: Resolve version/tag
id: ver id: ver
shell: bash shell: bash
@@ -51,10 +65,28 @@ jobs:
if [ -z "$NS" ]; then if [ -z "$NS" ]; then
NS="${{ secrets.DOCKERHUB_USERNAME }}" NS="${{ secrets.DOCKERHUB_USERNAME }}"
fi fi
if [ -z "$NS" ]; then
# Normalize accidental input like spaces or uppercase.
NS="$(echo "$NS" | tr '[:upper:]' '[:lower:]' | xargs)"
# Reject clearly invalid placeholders/config mistakes early.
if [ -z "$NS" ] || [ "$NS" = "-" ]; then
echo "Missing Docker Hub namespace. Set repo var DOCKERHUB_NAMESPACE or secret DOCKERHUB_USERNAME." echo "Missing Docker Hub namespace. Set repo var DOCKERHUB_NAMESPACE or secret DOCKERHUB_USERNAME."
exit 1 exit 1
fi fi
# Namespace must be a single Docker Hub account/org name, not a path/url.
if [[ "$NS" == *"/"* ]] || [[ "$NS" == *":"* ]]; then
echo "Invalid Docker Hub namespace '$NS'. Use only the account/org name (e.g. 'nesterovicit')."
exit 1
fi
if ! [[ "$NS" =~ ^[a-z0-9]+([._-][a-z0-9]+)*$ ]]; then
echo "Invalid Docker Hub namespace '$NS'. Allowed: lowercase letters, digits, ., _, -"
exit 1
fi
echo "Using Docker Hub namespace: $NS"
echo "value=$NS" >> "$GITHUB_OUTPUT" echo "value=$NS" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx - name: Set up Docker Buildx

View File

@@ -0,0 +1,53 @@
name: Python Dependency Security
on:
push:
branches: ["main", "master", "development"]
paths:
- "backend/**"
- ".github/workflows/python-dependency-security.yml"
- "ops/security/pip-audit-allowlist.json"
- "docs/security/dependency-exceptions.md"
pull_request:
paths:
- "backend/**"
- ".github/workflows/python-dependency-security.yml"
- "ops/security/pip-audit-allowlist.json"
- "docs/security/dependency-exceptions.md"
workflow_dispatch:
jobs:
pip-audit:
name: pip-audit (block high/critical)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install pip-audit
run: |
python -m pip install --upgrade pip
pip install pip-audit
- name: Run pip-audit (JSON report)
run: |
pip-audit -r backend/requirements.txt --format json --aliases --output pip-audit-backend.json || true
- name: Enforce vulnerability policy
run: |
python backend/scripts/pip_audit_gate.py \
--report pip-audit-backend.json \
--allowlist ops/security/pip-audit-allowlist.json
- name: Upload pip-audit report
uses: actions/upload-artifact@v3
with:
name: pip-audit-security-report
path: pip-audit-backend.json

View File

@@ -21,6 +21,7 @@ It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC,
- [`pg_stat_statements` Requirement](#pg_stat_statements-requirement) - [`pg_stat_statements` Requirement](#pg_stat_statements-requirement)
- [Reverse Proxy / SSL Guidance](#reverse-proxy--ssl-guidance) - [Reverse Proxy / SSL Guidance](#reverse-proxy--ssl-guidance)
- [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test) - [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test)
- [Dependency Exception Flow](#dependency-exception-flow)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
- [Security Notes](#security-notes) - [Security Notes](#security-notes)
@@ -206,7 +207,7 @@ Note: Migrations run automatically when the backend container starts (`entrypoin
| Variable | Description | | Variable | Description |
|---|---| |---|---|
| `FRONTEND_PORT` | Host port mapped to frontend container port `80` | | `FRONTEND_PORT` | Host port mapped to frontend container port `8080` |
## Core Functional Areas ## Core Functional Areas
@@ -387,6 +388,16 @@ PG_DSN_CANDIDATES='postgresql://postgres:postgres@postgres:5432/compatdb?sslmode
python backend/scripts/pg_compat_smoke.py python backend/scripts/pg_compat_smoke.py
``` ```
## Dependency Exception Flow
Python dependency vulnerabilities are enforced by CI via `pip-audit`.
- CI blocks unresolved `HIGH` and `CRITICAL` findings.
- Missing severity metadata is treated conservatively as `HIGH`.
- Temporary exceptions must be declared in `ops/security/pip-audit-allowlist.json`.
- Full process and required metadata are documented in:
- `docs/security/dependency-exceptions.md`
## Troubleshooting ## Troubleshooting
### Backend container keeps restarting during `make migrate` ### Backend container keeps restarting during `make migrate`

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Gate pip-audit results with an auditable allowlist policy.
Policy:
- Block unresolved HIGH/CRITICAL vulnerabilities.
- If severity is missing, treat as HIGH by default.
- Allow temporary exceptions via allowlist with expiry metadata.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import sys
from pathlib import Path
SEVERITY_ORDER = {"unknown": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
BLOCKING_SEVERITIES = {"high", "critical"}
def _parse_date(s: str) -> dt.date:
return dt.date.fromisoformat(s)
def _normalize_severity(value: object) -> str:
"""Normalize various pip-audit/osv-style severity payloads."""
if isinstance(value, str):
v = value.strip().lower()
if v in SEVERITY_ORDER:
return v
try:
# CVSS numeric string fallback
score = float(v)
if score >= 9.0:
return "critical"
if score >= 7.0:
return "high"
if score >= 4.0:
return "medium"
return "low"
except ValueError:
return "unknown"
if isinstance(value, (int, float)):
score = float(value)
if score >= 9.0:
return "critical"
if score >= 7.0:
return "high"
if score >= 4.0:
return "medium"
return "low"
if isinstance(value, list):
# OSV sometimes returns a list of dicts. Pick the max-known severity.
best = "unknown"
for item in value:
if isinstance(item, dict):
sev = _normalize_severity(item.get("severity"))
if SEVERITY_ORDER.get(sev, 0) > SEVERITY_ORDER.get(best, 0):
best = sev
return best
if isinstance(value, dict):
return _normalize_severity(value.get("severity"))
return "unknown"
def _load_allowlist(path: Path) -> tuple[list[dict], list[str]]:
if not path.exists():
return [], []
data = json.loads(path.read_text(encoding="utf-8"))
entries = data.get("entries", [])
today = dt.date.today()
active: list[dict] = []
errors: list[str] = []
required = {"id", "reason", "approved_by", "issue", "expires_on"}
for idx, entry in enumerate(entries, start=1):
missing = required - set(entry.keys())
if missing:
errors.append(f"allowlist entry #{idx} missing keys: {', '.join(sorted(missing))}")
continue
try:
expires = _parse_date(str(entry["expires_on"]))
except ValueError:
errors.append(f"allowlist entry #{idx} has invalid expires_on: {entry['expires_on']}")
continue
if expires < today:
errors.append(
f"allowlist entry #{idx} ({entry['id']}) expired on {entry['expires_on']}"
)
continue
active.append(entry)
return active, errors
def _iter_findings(report: object):
# pip-audit JSON can be list[dep] or dict with dependencies.
deps = report if isinstance(report, list) else report.get("dependencies", [])
for dep in deps:
package = dep.get("name", "unknown")
version = dep.get("version", "unknown")
for vuln in dep.get("vulns", []):
vuln_id = vuln.get("id", "unknown")
aliases = vuln.get("aliases", []) or []
severity = _normalize_severity(vuln.get("severity"))
if severity == "unknown":
severity = "high" # conservative default for policy safety
yield {
"package": package,
"version": version,
"id": vuln_id,
"aliases": aliases,
"severity": severity,
"fix_versions": vuln.get("fix_versions", []),
}
def _is_allowlisted(finding: dict, allowlist: list[dict]) -> bool:
ids = {finding["id"], *finding["aliases"]}
pkg = finding["package"]
for entry in allowlist:
entry_pkg = entry.get("package")
if entry["id"] in ids and (not entry_pkg or entry_pkg == pkg):
return True
return False
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--report", required=True, help="Path to pip-audit JSON report")
parser.add_argument("--allowlist", required=True, help="Path to allowlist JSON")
args = parser.parse_args()
report_path = Path(args.report)
allowlist_path = Path(args.allowlist)
if not report_path.exists():
print(f"[pip-audit-gate] Missing report: {report_path}")
return 1
report = json.loads(report_path.read_text(encoding="utf-8"))
allowlist, allowlist_errors = _load_allowlist(allowlist_path)
if allowlist_errors:
print("[pip-audit-gate] Allowlist validation failed:")
for err in allowlist_errors:
print(f" - {err}")
return 1
unresolved_blocking: list[dict] = []
summary = {"critical": 0, "high": 0, "medium": 0, "low": 0, "unknown": 0}
ignored = 0
for finding in _iter_findings(report):
sev = finding["severity"]
summary[sev] = summary.get(sev, 0) + 1
if _is_allowlisted(finding, allowlist):
ignored += 1
continue
if sev in BLOCKING_SEVERITIES:
unresolved_blocking.append(finding)
print("[pip-audit-gate] Summary:")
print(
f" CRITICAL={summary['critical']} HIGH={summary['high']} "
f"MEDIUM={summary['medium']} LOW={summary['low']} ALLOWLISTED={ignored}"
)
if unresolved_blocking:
print("[pip-audit-gate] Blocking vulnerabilities found:")
for f in unresolved_blocking:
aliases = ", ".join(f["aliases"]) if f["aliases"] else "-"
fixes = ", ".join(f["fix_versions"]) if f["fix_versions"] else "-"
print(
f" - {f['severity'].upper()} {f['package']}=={f['version']} "
f"id={f['id']} aliases=[{aliases}] fixes=[{fixes}]"
)
return 1
print("[pip-audit-gate] No unresolved HIGH/CRITICAL vulnerabilities.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,53 @@
# Dependency Security Exception Flow (pip-audit)
This document defines the auditable exception process for Python dependency vulnerabilities.
## Policy
- CI blocks unresolved `HIGH` and `CRITICAL` dependency vulnerabilities.
- If a vulnerability does not provide severity metadata, it is treated as `HIGH` by policy.
- Temporary exceptions are allowed only through `ops/security/pip-audit-allowlist.json`.
## Allowlist Location
- File: `ops/security/pip-audit-allowlist.json`
- Format:
```json
{
"entries": [
{
"id": "CVE-2026-12345",
"package": "example-package",
"reason": "Upstream fix not released yet",
"approved_by": "security-owner",
"issue": "NX-202",
"expires_on": "2026-12-31"
}
]
}
```
## Required Fields
- `id`: Vulnerability ID (`CVE-*`, `GHSA-*`, or advisory ID)
- `reason`: Why exception is necessary
- `approved_by`: Approver identity
- `issue`: Tracking issue/ticket
- `expires_on`: Expiry date in `YYYY-MM-DD`
Optional:
- `package`: Restrict exception to one dependency package
## Rules
- Expired allowlist entries fail CI.
- Missing required fields fail CI.
- Exceptions must be time-limited and linked to a tracking issue.
- Removing an exception is required once an upstream fix is available.
## Auditability
- Every exception change is tracked in Git history and code review.
- CI logs include blocked vulnerabilities and allowlisted findings counts.

View File

@@ -0,0 +1,3 @@
{
"entries": []
}