diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6f2edcd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + # Python dependencies (requirements.txt): weekly check for updates and + # security fixes; PRs must pass CI before merging. + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + + # Keep the GitHub Actions used by CI/release workflows current. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f82cd7a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_call: + schedule: + # Weekly security audit, Monday 06:17 UTC + - cron: "17 6 * * 1" + +permissions: + contents: read + +jobs: + test: + name: Smoke test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12", "3.14"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run smoke test + run: python scripts/smoke_test.py + + security-audit: + name: Dependency CVE audit (pip-audit) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install pip-audit + run: pip install pip-audit + + - name: Audit pinned requirements for known CVEs + run: pip-audit -r requirements.txt + + alert-on-scheduled-failure: + name: Open issue if weekly audit fails + runs-on: ubuntu-latest + needs: [test, security-audit] + if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure') + permissions: + issues: write + steps: + - name: Create alert issue unless one is already open + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + title="Scheduled security/CI check failed" + existing=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title \"$title\"" --json number --jq 'length') + if [ "$existing" -gt 0 ]; then + echo "An alert issue is already open; not creating a duplicate." + exit 0 + fi + { + echo "The weekly scheduled run of CI failed — most likely a new CVE affects a pinned dependency, or the code no longer passes the smoke test." + echo "" + echo "Failed run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo "" + echo "Until this is resolved, release gating will block new releases. Review the run logs, decide on the fix (usually a dependency bump in requirements.txt), and close this issue once CI is green again." + } > issue-body.md + gh issue create --repo "$GITHUB_REPOSITORY" \ + --title "$title" \ + --assignee "${{ github.repository_owner }}" \ + --body-file issue-body.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed1aa26..5fe8bb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,20 +10,44 @@ on: workflow_dispatch: inputs: tag: - description: "Tag to create the release under (e.g. v1.0.0)" + description: "Tag to create the release under (e.g. v1.0.2)" required: true - default: "v1.0.0" + default: "v1.0.2" + skip_checks: + description: "OWNER OVERRIDE: publish the release even though checks failed (use only after reviewing the failure)" + type: boolean + required: false + default: false permissions: contents: write + issues: write jobs: - release: + # Every release candidate must pass the same checks as CI: + # smoke test on all supported Python versions + dependency CVE audit. + checks: if: >- startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/release/v') || github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/ci.yml + permissions: + contents: read + issues: write + + release: runs-on: ubuntu-latest + needs: [checks] + # Publish only when checks passed, or when a human explicitly set the + # skip_checks override on a manual run after reviewing the failure. + if: >- + always() && + (startsWith(github.ref, 'refs/tags/v') || + startsWith(github.ref, 'refs/heads/release/v') || + github.event_name == 'workflow_dispatch') && + (needs.checks.result == 'success' || + (github.event_name == 'workflow_dispatch' && inputs.skip_checks)) steps: - uses: actions/checkout@v4 @@ -57,8 +81,53 @@ jobs: exit 1 fi + if [ "${{ needs.checks.result }}" != "success" ]; then + echo "::warning::Release published with skip_checks override; checks result was: ${{ needs.checks.result }}" + fi + gh release create "$tag" \ --repo "$GITHUB_REPOSITORY" \ --title "OCSP Server Test Suite $tag" \ --notes-file "$notes_file" \ "${extra_args[@]}" + + # If checks failed, no release is created. Alert the owner with an issue + # so a human can fix the problem or consciously decide to release anyway. + alert-release-blocked: + runs-on: ubuntu-latest + needs: [checks] + if: >- + always() && needs.checks.result == 'failure' && + !(github.event_name == 'workflow_dispatch' && inputs.skip_checks) + steps: + - name: Open issue describing the blocked release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + case "$GITHUB_REF" in + refs/tags/*) tag="${GITHUB_REF#refs/tags/}" ;; + refs/heads/release/*) tag="${GITHUB_REF#refs/heads/release/}" ;; + *) tag="${{ inputs.tag }}" ;; + esac + title="Release $tag blocked: pre-release checks failed" + existing=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title \"$title\"" --json number --jq 'length') + if [ "$existing" -gt 0 ]; then + echo "An alert issue for $tag is already open; not creating a duplicate." + exit 0 + fi + { + echo "A release of **$tag** was requested, but the pre-release checks (smoke test and dependency CVE audit) failed, so **no release was published**." + echo "" + echo "Failed run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo "" + echo "Your options:" + echo "1. **Fix the problem** (code fix or dependency bump), push it, and trigger the release again — it will publish automatically once checks pass." + echo "2. **Decide to release anyway**: go to Actions -> Create Release -> Run workflow, enter the tag, and set skip_checks. This is logged as an explicit owner override." + echo "" + echo "Close this issue once resolved." + } > issue-body.md + gh issue create --repo "$GITHUB_REPOSITORY" \ + --title "$title" \ + --assignee "${{ github.repository_owner }}" \ + --body-file issue-body.md diff --git a/.github/workflows/security-autopatch.yml b/.github/workflows/security-autopatch.yml new file mode 100644 index 0000000..6e1b40c --- /dev/null +++ b/.github/workflows/security-autopatch.yml @@ -0,0 +1,160 @@ +name: Security Autopatch + +# Daily watch for published CVE fixes in the project's dependencies. +# When a fix exists outside the allowed version range in requirements.txt, +# this workflow raises the range, re-runs the full check suite, and — only +# if everything passes — commits the bump and publishes a patch release. +# If checks fail, or a vulnerability has no fix, no release happens: +# an issue is opened and assigned to the owner to decide. + +on: + schedule: + - cron: "43 10 * * *" + workflow_dispatch: + +permissions: + contents: write + issues: write + +concurrency: + group: security-autopatch + cancel-in-progress: false + +jobs: + autopatch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tooling + run: pip install pip-audit packaging + + - name: Audit dependencies and raise vulnerable ranges + id: patch + run: | + python scripts/security_autopatch.py audit + { + echo "changed=$(python -c "import json; print(str(json.load(open('autopatch-summary.json'))['changed']).lower())")" + echo "unfixable=$(python -c "import json; print(str(bool(json.load(open('autopatch-summary.json'))['unfixable'])).lower())")" + } >> "$GITHUB_OUTPUT" + + - name: Verify patched requirements (install, re-audit, smoke test) + id: verify + if: steps.patch.outputs.changed == 'true' + continue-on-error: true + run: | + pip install -r requirements.txt + pip-audit -r requirements.txt + python scripts/smoke_test.py + + - name: Commit bump and publish patch release + id: publish + if: steps.patch.outputs.changed == 'true' && steps.verify.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + latest=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName) + base="${latest#v}" + IFS=. read -r major minor patch <<< "$base" + next="v${major}.${minor}.$((patch + 1))" + echo "next=$next" >> "$GITHUB_OUTPUT" + notes=".github/release-notes/${next}.md" + + python scripts/security_autopatch.py notes --version "$next" --output "$notes" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add requirements.txt "$notes" + git commit -m "fix(security): raise dependency ranges for published CVE fixes ($next)" + git push origin "HEAD:${GITHUB_REF_NAME}" + + gh release create "$next" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$(git rev-parse HEAD)" \ + --title "OCSP Server Test Suite $next" \ + --notes-file "$notes" + + - name: Notify owner of the automated release + if: steps.publish.outcome == 'success' && steps.publish.outputs.next != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + next="${{ steps.publish.outputs.next }}" + { + echo "The security-autopatch workflow found published CVE fixes for a dependency, raised the version range in requirements.txt, ran the full check suite (install, re-audit, smoke test on the updated dependencies), and published **$next** automatically." + echo "" + echo "Release: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$next" + echo "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo "" + echo "No action is required; this issue is informational. Close it once you've seen it. If the release should not have happened, delete it from the releases page and revert the commit." + } > issue-body.md + gh issue create --repo "$GITHUB_REPOSITORY" \ + --title "Automated security release $next published" \ + --assignee "${{ github.repository_owner }}" \ + --body-file issue-body.md + + - name: Alert owner that autopatch is blocked + if: steps.patch.outputs.changed == 'true' && steps.verify.outcome == 'failure' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + title="Security autopatch blocked: fix available but checks failed" + existing=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title \"$title\"" --json number --jq 'length') + if [ "$existing" -gt 0 ]; then + echo "An alert issue is already open; not creating a duplicate." + exit 0 + fi + { + echo "A dependency has a published CVE fix outside the allowed range in requirements.txt, but after raising the range the check suite (install, re-audit, smoke test) FAILED — most likely the new library version breaks the code. **No release was published and no changes were committed.**" + echo "" + echo "Failed run (see logs and the audit summary): $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo "" + echo "Your decision is needed:" + echo "1. Adapt the code to the new library version, then release normally - the gate will pass." + echo "2. Or release anyway via Actions -> Create Release -> Run workflow with skip_checks (not recommended while the smoke test fails)." + echo "" + echo "Close this issue once resolved. The workflow will keep failing daily (without duplicate issues) until the vulnerability is addressed." + } > issue-body.md + gh issue create --repo "$GITHUB_REPOSITORY" \ + --title "$title" \ + --assignee "${{ github.repository_owner }}" \ + --body-file issue-body.md + + - name: Alert owner about vulnerabilities with no published fix + if: steps.patch.outputs.unfixable == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + title="Dependency vulnerability needs a manual decision" + existing=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title \"$title\"" --json number --jq 'length') + if [ "$existing" -gt 0 ]; then + echo "An alert issue is already open; not creating a duplicate." + exit 0 + fi + { + echo "The daily security audit found vulnerabilities that cannot be fixed automatically (no published fix version, or the package is not a direct requirement):" + echo "" + echo '```json' + python -c "import json; print(json.dumps(json.load(open('autopatch-summary.json'))['unfixable'], indent=2))" + echo '```' + echo "" + echo "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo "" + echo "Review the advisories and decide how to proceed (wait for a fix, mitigate, or pin differently). Close this issue once resolved." + } > issue-body.md + gh issue create --repo "$GITHUB_REPOSITORY" \ + --title "$title" \ + --assignee "${{ github.repository_owner }}" \ + --body-file issue-body.md + + - name: Fail the run when autopatch is blocked + if: steps.patch.outputs.changed == 'true' && steps.verify.outcome == 'failure' + run: | + echo "::error::A CVE fix is available but the verification suite failed; see the assigned issue." + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db05551 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Python bytecode and caches +__pycache__/ +*.py[cod] + +# Virtual environments +venv/ +.venv/ +env/ + +# Test and tooling caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# OS cruft +.DS_Store +Thumbs.db diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc deleted file mode 100644 index 095c885..0000000 Binary files a/__pycache__/app.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/__init__.cpython-313.pyc b/ocsp_tester/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index fa7d50b..0000000 Binary files a/ocsp_tester/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/config.cpython-313.pyc b/ocsp_tester/__pycache__/config.cpython-313.pyc deleted file mode 100644 index 8d4e6a3..0000000 Binary files a/ocsp_tester/__pycache__/config.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/exporters.cpython-313.pyc b/ocsp_tester/__pycache__/exporters.cpython-313.pyc deleted file mode 100644 index 1a06265..0000000 Binary files a/ocsp_tester/__pycache__/exporters.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/models.cpython-313.pyc b/ocsp_tester/__pycache__/models.cpython-313.pyc deleted file mode 100644 index fc7706c..0000000 Binary files a/ocsp_tester/__pycache__/models.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/monitor.cpython-313.pyc b/ocsp_tester/__pycache__/monitor.cpython-313.pyc deleted file mode 100644 index 24de7d7..0000000 Binary files a/ocsp_tester/__pycache__/monitor.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/ocsp_client.cpython-313.pyc b/ocsp_tester/__pycache__/ocsp_client.cpython-313.pyc deleted file mode 100644 index d9d800d..0000000 Binary files a/ocsp_tester/__pycache__/ocsp_client.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/path_validator.cpython-313.pyc b/ocsp_tester/__pycache__/path_validator.cpython-313.pyc deleted file mode 100644 index 64c070e..0000000 Binary files a/ocsp_tester/__pycache__/path_validator.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/runner.cpython-313.pyc b/ocsp_tester/__pycache__/runner.cpython-313.pyc deleted file mode 100644 index faf308c..0000000 Binary files a/ocsp_tester/__pycache__/runner.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/tests_crl.cpython-313.pyc b/ocsp_tester/__pycache__/tests_crl.cpython-313.pyc deleted file mode 100644 index 37b057a..0000000 Binary files a/ocsp_tester/__pycache__/tests_crl.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/tests_crl_comprehensive.cpython-313.pyc b/ocsp_tester/__pycache__/tests_crl_comprehensive.cpython-313.pyc deleted file mode 100644 index 8510698..0000000 Binary files a/ocsp_tester/__pycache__/tests_crl_comprehensive.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/tests_ikev2.cpython-313.pyc b/ocsp_tester/__pycache__/tests_ikev2.cpython-313.pyc deleted file mode 100644 index ff8f65d..0000000 Binary files a/ocsp_tester/__pycache__/tests_ikev2.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/tests_path_validation.cpython-313.pyc b/ocsp_tester/__pycache__/tests_path_validation.cpython-313.pyc deleted file mode 100644 index 00e0aec..0000000 Binary files a/ocsp_tester/__pycache__/tests_path_validation.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/tests_performance.cpython-313.pyc b/ocsp_tester/__pycache__/tests_performance.cpython-313.pyc deleted file mode 100644 index c3533aa..0000000 Binary files a/ocsp_tester/__pycache__/tests_performance.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/tests_protocol.cpython-313.pyc b/ocsp_tester/__pycache__/tests_protocol.cpython-313.pyc deleted file mode 100644 index 940dc54..0000000 Binary files a/ocsp_tester/__pycache__/tests_protocol.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/tests_security.cpython-313.pyc b/ocsp_tester/__pycache__/tests_security.cpython-313.pyc deleted file mode 100644 index 697c595..0000000 Binary files a/ocsp_tester/__pycache__/tests_security.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/__pycache__/tests_status.cpython-313.pyc b/ocsp_tester/__pycache__/tests_status.cpython-313.pyc deleted file mode 100644 index acbb5f7..0000000 Binary files a/ocsp_tester/__pycache__/tests_status.cpython-313.pyc and /dev/null differ diff --git a/ocsp_tester/ocsp_client.py b/ocsp_tester/ocsp_client.py index c21cc34..81e6f35 100644 --- a/ocsp_tester/ocsp_client.py +++ b/ocsp_tester/ocsp_client.py @@ -1,6 +1,6 @@ import base64 import time -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Optional, Tuple, Dict, Any, List from urllib.parse import urljoin, quote @@ -21,7 +21,7 @@ class OCSPRequestSpec: issuer: x509.Certificate include_nonce: bool = True nonce_len: int = 32 # RFC 9654 recommends >=32 - hash_algo: hashes.HashAlgorithm = hashes.SHA1() + hash_algo: hashes.HashAlgorithm = field(default_factory=hashes.SHA1) @dataclass diff --git a/scripts/security_autopatch.py b/scripts/security_autopatch.py new file mode 100644 index 0000000..66b0e32 --- /dev/null +++ b/scripts/security_autopatch.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Security autopatch helper, used by .github/workflows/security-autopatch.yml. + +`audit` mode runs pip-audit against the requirements file. For every +dependency whose resolved version has a known vulnerability with a +published fix, it raises that dependency's version range in place so a +fresh install resolves to a fixed version. It writes a JSON summary: + + { + "changed": bool, # the requirements file was rewritten + "packages": {name: {"old": line, "new": spec, "advisories": [ids]}}, + "unfixable": [{"package": ..., "id": ..., "note": ...}] + } + +`notes` mode renders release notes markdown from that summary for the +automated release. + +Exit code is 0 unless the tool itself fails; outcomes are communicated +through the summary file so the workflow can branch on them. +""" + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +from packaging.version import InvalidVersion, Version + + +def run_pip_audit(requirements: Path) -> dict: + proc = subprocess.run( + [ + sys.executable, "-m", "pip_audit", + "-r", str(requirements), + "-f", "json", + "--progress-spinner", "off", + ], + capture_output=True, + text=True, + ) + # pip-audit exits 0 when clean and 1 when vulnerabilities are found; + # anything else (or unparseable output) is an operational error. + try: + return json.loads(proc.stdout) + except json.JSONDecodeError: + print(proc.stdout, file=sys.stderr) + print(proc.stderr, file=sys.stderr) + raise SystemExit(f"pip-audit failed (exit {proc.returncode})") + + +def ceiling_for(version: Version) -> str: + """Upper bound two majors above the fix, matching the existing + `>=49.0.0,<51.0.0` style of requirements.txt.""" + return f"{version.major + 2}.0.0" + + +def audit(args: argparse.Namespace) -> int: + req_path = Path(args.requirements) + report = run_pip_audit(req_path) + + needed: dict[str, dict] = {} + unfixable: list[dict] = [] + for dep in report.get("dependencies", []): + name = dep.get("name", "") + for vuln in dep.get("vulns") or []: + fixes = [] + for fv in vuln.get("fix_versions") or []: + try: + fixes.append(Version(fv)) + except InvalidVersion: + continue + if not fixes: + unfixable.append({ + "package": name, + "id": vuln.get("id", "unknown"), + "note": "no published fix version", + }) + continue + fix = min(fixes) # smallest version that resolves this advisory + entry = needed.setdefault(name, {"fix": fix, "advisories": []}) + entry["advisories"].append(vuln.get("id", "unknown")) + if fix > entry["fix"]: + entry["fix"] = fix + + text = req_path.read_text() + packages: dict[str, dict] = {} + for name, entry in sorted(needed.items()): + pattern = re.compile( + rf"^{re.escape(name)}\s*(?:[<>=!~;\[][^#\n]*)?$", + re.IGNORECASE | re.MULTILINE, + ) + match = pattern.search(text) + new_spec = f"{name}>={entry['fix']},<{ceiling_for(entry['fix'])}" + if match is None: + unfixable.append({ + "package": name, + "id": ", ".join(entry["advisories"]), + "note": "not listed in requirements file (transitive dependency); needs a manual decision", + }) + continue + packages[name] = { + "old": match.group(0).strip(), + "new": new_spec, + "advisories": sorted(set(entry["advisories"])), + } + text = text[: match.start()] + new_spec + text[match.end():] + + changed = bool(packages) + if changed: + req_path.write_text(text) + + summary = {"changed": changed, "packages": packages, "unfixable": unfixable} + Path(args.summary).write_text(json.dumps(summary, indent=2) + "\n") + print(json.dumps(summary, indent=2)) + return 0 + + +def notes(args: argparse.Namespace) -> int: + summary = json.loads(Path(args.summary).read_text()) + lines = [ + f"# OCSP Server Test Suite {args.version}", + "", + "Automated security release. A published vulnerability in a dependency", + "has a fix available outside the previously allowed version range, so", + "this release raises the range; a fresh install now resolves to a", + "patched library. No application code was changed.", + "", + "## Dependency updates", + "", + ] + for name, info in sorted(summary.get("packages", {}).items()): + advisories = ", ".join(info["advisories"]) + lines.append(f"- **{name}**: `{info['old']}` → `{info['new']}` (addresses: {advisories})") + lines += [ + "", + "## Upgrading an existing install", + "", + "```bash", + "git pull # or download the new ZIP", + "pip install -r requirements.txt --upgrade", + "```", + "", + "Published automatically by the security-autopatch workflow after the", + "full check suite (smoke test and re-audit) passed on the updated", + "requirements.", + "", + ] + out = Path(args.output) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text("\n".join(lines)) + print(f"wrote {out}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + p_audit = sub.add_parser("audit", help="audit and rewrite requirement ranges") + p_audit.add_argument("--requirements", default="requirements.txt") + p_audit.add_argument("--summary", default="autopatch-summary.json") + p_audit.set_defaults(func=audit) + + p_notes = sub.add_parser("notes", help="render release notes from a summary") + p_notes.add_argument("--summary", default="autopatch-summary.json") + p_notes.add_argument("--version", required=True) + p_notes.add_argument("--output", required=True) + p_notes.set_defaults(func=notes) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py new file mode 100644 index 0000000..159b9e9 --- /dev/null +++ b/scripts/smoke_test.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Project smoke test, run by CI and by the release gate. + +Checks, in order: + 1. Every Python file in the repository byte-compiles. + 2. Every top-level module imports cleanly (app.py only when tkinter + is available, since it is the GUI entry point). + 3. The core certificate paths work: load the bundled sample CA and + leaf certificate, verify the chain signature, and build and + re-parse a DER OCSP request. + +Exits non-zero on any failure so a workflow step fails loudly. +""" + +import compileall +import importlib +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +MODULES = [ + "comprehensive_ocsp_analyzer", + "enhanced_ocsp_signer_extractor", + "ocsp_check_example", + "ocsp_demo", + "ocsp_signature_analysis", + "ocsp_signature_verifier", + "ocsp_signer_extractor", + "ocsp_tester", + "test_crl_extraction", + "test_dhs_ca4_enhancement", + "test_dhs_ca4_trust_chain", + "test_ocsp_fix", + "test_ocsp_signer_validation", +] + +SKIP_DIRS = re.compile(r"[\\/](\.git|\.github|__pycache__|venv|\.venv|auditenv)([\\/]|$)") + + +def check_compile() -> bool: + print("== byte-compiling all Python files ==") + ok = compileall.compile_dir(str(ROOT), quiet=1, force=True, rx=SKIP_DIRS) + print("byte-compile:", "OK" if ok else "FAILED") + return bool(ok) + + +def check_imports() -> bool: + print("== importing modules ==") + sys.path.insert(0, str(ROOT)) + modules = list(MODULES) + try: + import tkinter # noqa: F401 + modules.append("app") + except ImportError: + print("SKIP app (tkinter not available in this environment)") + ok = True + for name in modules: + try: + importlib.import_module(name) + print("OK ", name) + except Exception as exc: # noqa: BLE001 + ok = False + print(f"FAIL {name}: {type(exc).__name__}: {exc}") + return ok + + +def check_crypto_paths() -> bool: + print("== exercising certificate / OCSP code paths ==") + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.x509.ocsp import OCSPRequestBuilder, load_der_ocsp_request + + ca = x509.load_pem_x509_certificate((ROOT / "sample_ca.pem").read_bytes()) + leaf = x509.load_pem_x509_certificate((ROOT / "sample_cert.pem").read_bytes()) + ca.public_key().verify( + leaf.signature, + leaf.tbs_certificate_bytes, + padding.PKCS1v15(), + leaf.signature_hash_algorithm, + ) + print("OK chain signature verification (sample CA -> sample cert)") + + request = OCSPRequestBuilder().add_certificate(leaf, ca, hashes.SHA1()).build() + der = request.public_bytes(serialization.Encoding.DER) + parsed = load_der_ocsp_request(der) + assert parsed.serial_number == leaf.serial_number + print(f"OK OCSP request build/parse round-trip ({len(der)} bytes DER)") + return True + + +def main() -> int: + import cryptography + + print(f"python {sys.version.split()[0]}, cryptography {cryptography.__version__}") + results = [check_compile(), check_imports(), check_crypto_paths()] + if all(results): + print("SMOKE TEST PASSED") + return 0 + print("SMOKE TEST FAILED") + return 1 + + +if __name__ == "__main__": + sys.exit(main())