diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7485c5b..2a8727d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -2,28 +2,111 @@ name: CI on: pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - test: + lint-and-security: + name: lint-and-security runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Poetry + run: pip install "poetry>=2,<3" + + - name: Install dev dependencies (lockfile-pinned pre-commit) + run: poetry install --with dev --no-root + + - name: Run pre-commit hooks + run: poetry run pre-commit run --all-files --color always + + - name: Type check + run: poetry run mypy + + - name: Install pip-audit + run: pip install "pip-audit==2.10.1" + + - name: Audit dependencies (Poetry lockfile) + run: | + set -euo pipefail + + poetry self add poetry-plugin-export + poetry export -f requirements.txt --without-hashes -o requirements-audit.txt + + # pip-audit exits non-zero for ANY vulnerability; capture the report, then gate below. + pip-audit --no-deps -r requirements-audit.txt -f json -o pip-audit.json || true + + echo "Fixable vulnerabilities:" + jq -r ' + .dependencies[]? + | select(has("vulns")) + | . as $d + | .vulns[] + | select((.fix_versions | length) > 0) + | "\($d.name)@\($d.version) -> \(.id) [fix: \(.fix_versions | join(", "))]" + ' pip-audit.json + + echo + echo "All reported vulnerabilities:" + jq -r ' + .dependencies[]? + | select(has("vulns")) + | . as $d + | .vulns[] + | "\($d.name)@\($d.version) -> \(.id) [fix: \(if (.fix_versions | length) > 0 then (.fix_versions | join(", ")) else "none" end)]" + ' pip-audit.json + + # Fail only on fixable findings. The type guard ensures a crashed or empty + # report fails rather than silently passing. Dependency security updates + # are handled by the central Renovate job. + jq -e ' + (.dependencies | type) == "array" + and + ( [ .dependencies[] | (.vulns // [])[] | select((.fix_versions | length) > 0) ] | length == 0 ) + ' pip-audit.json + + tests: + name: tests + runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: python-version: ["3.11", "3.13"] + steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install project and tooling - run: pip install -e . pytest mypy pre-commit + - name: Install Poetry + run: pip install "poetry>=2,<3" - - name: Lint and typecheck - if: matrix.python-version == '3.13' - run: | - pre-commit run --all-files --color always - mypy + - name: Install project and dependencies + run: poetry install --with dev - - name: Unit and security tests - run: pytest tests/unit tests/security + - name: Run tests + run: poetry run pytest diff --git a/.gitea/workflows/security-scan.yml b/.gitea/workflows/security-scan.yml deleted file mode 100644 index d311e78..0000000 --- a/.gitea/workflows/security-scan.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Security Scan - -on: - schedule: - - cron: 27 8 * * * - workflow_dispatch: - -jobs: - security-scan: - runs-on: running-man - - env: - TARGET_DIR: . - steps: - - name: Checkout - uses: actions/checkout@v4 - - # Tool versions are centralised in the shared actions; bump them there and - # re-tag rather than editing every repository. - - name: Install Grype - uses: https://git.sysmd.uk/mdaleo404/actions/install-grype@v1 - - - name: Install Syft - uses: https://git.sysmd.uk/mdaleo404/actions/install-syft@v1 - - - name: Generate SBOM - working-directory: ${{ env.TARGET_DIR }} - run: | - syft dir:. -o json > sbom.json - - - name: Show SBOM contents - working-directory: ${{ env.TARGET_DIR }} - run: | - echo "Packages discovered by Syft:" - jq -r '.artifacts[] | "\(.name)@\(.version) [\(.type)]"' sbom.json | sort - - # Composite actions run from the workspace root, so pass the target and - # report relative to it (TARGET_DIR is "." for most repositories). - - name: Scan SBOM with Grype - id: audit - continue-on-error: true - uses: https://git.sysmd.uk/mdaleo404/actions/scan-image@v1 - with: - target: sbom:${{ env.TARGET_DIR }}/sbom.json - severities: Medium,High,Critical - report-path: ${{ env.TARGET_DIR }}/grype.json - - - name: Show full Grype table - working-directory: ${{ env.TARGET_DIR }} - run: | - echo "Full Grype report:" - grype sbom:sbom.json -o table - - - name: Notify Node-RED on vulnerabilities - if: steps.audit.outcome == 'failure' - working-directory: ${{ env.TARGET_DIR }} - run: | - jq ' - { - repo: "mdaleo404/schedls", - summary: ( - "Total: " + - ( - [ - .matches[] - | select((.vulnerability.fix.versions | length) > 0) - ] | length | tostring - ) - ), - vulnerabilities: [ - .matches[] - | select((.vulnerability.fix.versions | length) > 0) - | { - library: .artifact.name, - cve: .vulnerability.id, - severity: .vulnerability.severity, - installed: .artifact.version, - fixed: (.vulnerability.fix.versions[0]), - title: .vulnerability.description, - url: .vulnerability.dataSource - } - ] - } - ' grype.json \ - | curl -s -X POST https://nodered.sysmd.uk/vulns-alert \ - -H "Content-Type: application/json" \ - --data-binary @- - - - name: Fail workflow if vulnerabilities found - if: steps.audit.outcome == 'failure' - run: exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7485c5b..2a8727d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,28 +2,111 @@ name: CI on: pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - test: + lint-and-security: + name: lint-and-security runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Poetry + run: pip install "poetry>=2,<3" + + - name: Install dev dependencies (lockfile-pinned pre-commit) + run: poetry install --with dev --no-root + + - name: Run pre-commit hooks + run: poetry run pre-commit run --all-files --color always + + - name: Type check + run: poetry run mypy + + - name: Install pip-audit + run: pip install "pip-audit==2.10.1" + + - name: Audit dependencies (Poetry lockfile) + run: | + set -euo pipefail + + poetry self add poetry-plugin-export + poetry export -f requirements.txt --without-hashes -o requirements-audit.txt + + # pip-audit exits non-zero for ANY vulnerability; capture the report, then gate below. + pip-audit --no-deps -r requirements-audit.txt -f json -o pip-audit.json || true + + echo "Fixable vulnerabilities:" + jq -r ' + .dependencies[]? + | select(has("vulns")) + | . as $d + | .vulns[] + | select((.fix_versions | length) > 0) + | "\($d.name)@\($d.version) -> \(.id) [fix: \(.fix_versions | join(", "))]" + ' pip-audit.json + + echo + echo "All reported vulnerabilities:" + jq -r ' + .dependencies[]? + | select(has("vulns")) + | . as $d + | .vulns[] + | "\($d.name)@\($d.version) -> \(.id) [fix: \(if (.fix_versions | length) > 0 then (.fix_versions | join(", ")) else "none" end)]" + ' pip-audit.json + + # Fail only on fixable findings. The type guard ensures a crashed or empty + # report fails rather than silently passing. Dependency security updates + # are handled by the central Renovate job. + jq -e ' + (.dependencies | type) == "array" + and + ( [ .dependencies[] | (.vulns // [])[] | select((.fix_versions | length) > 0) ] | length == 0 ) + ' pip-audit.json + + tests: + name: tests + runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: python-version: ["3.11", "3.13"] + steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install project and tooling - run: pip install -e . pytest mypy pre-commit + - name: Install Poetry + run: pip install "poetry>=2,<3" - - name: Lint and typecheck - if: matrix.python-version == '3.13' - run: | - pre-commit run --all-files --color always - mypy + - name: Install project and dependencies + run: poetry install --with dev - - name: Unit and security tests - run: pytest tests/unit tests/security + - name: Run tests + run: poetry run pytest diff --git a/README.md b/README.md index cb0e705..1c82d7a 100644 --- a/README.md +++ b/README.md @@ -193,9 +193,9 @@ Formatting and linting use [ruff](https://docs.astral.sh/ruff/); security scanning uses [Bandit](https://bandit.readthedocs.io/); both run through [pre-commit](https://pre-commit.com/) alongside trailing-whitespace, end-of-file, YAML and TOML checks. CI runs on pull requests, applying the same hooks and a -strict type check on Python 3.13, and running the unit and security tests on -Python 3.11 and 3.13. Build distributions locally with `poetry build`. The -Gitea scheduled workflow builds an SBOM and scans it with Grype. +strict type check on Python 3.13, a `pip-audit` dependency audit, and the test +suite on Python 3.11 and 3.13. Dependency security updates are handled centrally +by Renovate. Build distributions locally with `poetry build`. ## Support