diff --git a/.github/workflows/_required.yml b/.github/workflows/_required.yml index b3613861..23d43934 100644 --- a/.github/workflows/_required.yml +++ b/.github/workflows/_required.yml @@ -1,9 +1,9 @@ name: Required Checks -# Canonical status-check umbrella for the org-wide homeric-main-baseline ruleset. -# Each job name is the exact context string registered in the branch protection rule. -# This workflow is intentionally lightweight: it runs fast representative validators -# and defers the full sanitiser matrix to ci.yml. +# Canonical required-checks workflow for the homeric-main-baseline ruleset. +# Every job name here is an exact context string in the branch protection ruleset. +# All CI is consolidated here — ci.yml, security-scan.yml, codeql-analysis.yml, +# dependency-audit.yml, and build-and-test.yml are deleted in favour of this file. on: push: @@ -20,27 +20,24 @@ permissions: contents: read pull-requests: write security-events: write - -# --------------------------------------------------------------------------- -# Shared setup fragments (referenced via YAML anchors are not supported by -# GitHub Actions, so common steps are inlined or extracted to the composite -# action already in the repo). -# --------------------------------------------------------------------------- + actions: read jobs: # ============================================================ # 1. lint + # clang-format, ruff, pre-commit, CMake cycle check, + # cppcheck, clang-tidy, mypy # ============================================================ lint: name: lint runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@v6 - - name: Install build dependencies + - name: Install build dependencies (just + linters) uses: ./.github/actions/install-build-deps with: include-just: "true" @@ -52,21 +49,163 @@ jobs: python-version: "3.11" - name: Install Python linters - run: pip install "ruff>=0.1,<1" "mypy>=1.8.0,<2" + run: pip install "ruff>=0.1,<1" "mypy>=1.8.0,<2" "conan>=2.0.0" + + - name: Clang-format check (C++) + run: just format-check - name: Ruff lint (Python) run: ruff check src/ tests/ - - name: Clang-format check (C++) - run: just format-check + - name: Run pre-commit hooks + uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd + with: + extra_args: --show-diff-on-failure + + - name: Check CMake dependency graph for cycles + run: | + cmake -S . -B build/graphviz -G Ninja --graphviz=build/graphviz/deps.dot \ + -DCMAKE_BUILD_TYPE=Debug 2>/dev/null || true + if [ -f build/graphviz/deps.dot ]; then + python3 << 'EOF' + import re, sys + content = open("build/graphviz/deps.dot").read() + edges = re.findall(r'"([^"]+)" -> "([^"]+)"', content) + adj = {} + for src, dst in edges: + adj.setdefault(src, []).append(dst) + visited, in_stack = set(), set() + def has_cycle(node): + if node in in_stack: return True + if node in visited: return False + visited.add(node); in_stack.add(node) + if any(has_cycle(n) for n in adj.get(node, [])): return True + in_stack.discard(node); return False + if any(has_cycle(n) for n in list(adj)): + print("CYCLE DETECTED in CMake dependency graph!"); sys.exit(1) + print("No cycles detected in CMake dependency graph") + EOF + fi + + - name: Install cppcheck + run: sudo apt-get update && sudo apt-get install -y cppcheck + + - name: Run cppcheck + run: | + cppcheck \ + --enable=all \ + --xml \ + --xml-version=2 \ + --suppress=missingIncludeSystem \ + --suppress=syntaxError \ + --inline-suppr \ + -I include \ + src/ tests/ 2> cppcheck-report.xml + + - name: Fail on cppcheck error-severity findings + run: | + python3 - << 'EOF' + import xml.etree.ElementTree as ET, sys + tree = ET.parse("cppcheck-report.xml") + errors = [e for e in tree.getroot().find("errors") + if e.get("severity") == "error"] + if errors: + for e in errors: + loc = e.find("location") + f = loc.get("file", "?") if loc is not None else "?" + l = loc.get("line", "?") if loc is not None else "?" + print(f"CPPCHECK ERROR: {e.get('id')} at {f}:{l} — {e.get('msg')}") + sys.exit(1) + EOF + + - name: Upload cppcheck results + if: always() + uses: actions/upload-artifact@v7 + with: + name: cppcheck-report + path: cppcheck-report.xml + retention-days: 30 + + - name: Install build dependencies (with static analysis) + uses: ./.github/actions/install-build-deps + with: + include-static-analysis: "true" + + - name: Cache Conan packages (clang-tidy) + uses: actions/cache@v5 + with: + path: ~/.conan2 + key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py') }} + restore-keys: | + conan-${{ runner.os }}- + + - name: Cache FetchContent (clang-tidy) + uses: actions/cache@v5 + with: + path: build/x86.debug.clang-tidy/_deps + key: fetchcontent-clang-tidy-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} + restore-keys: | + fetchcontent-clang-tidy-${{ runner.os }}- + + - name: Set up sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - name: Configure sccache + run: | + echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV + echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV + echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV + + - name: Install Conan dependencies + run: make deps.native + + - name: Configure CMake with clang-tidy + run: | + CONAN_TOOLCHAIN="" + if [ -f build/conan-deps/conan_toolchain.cmake ]; then + CONAN_TOOLCHAIN="-DCMAKE_TOOLCHAIN_FILE=build/conan-deps/conan_toolchain.cmake" + fi + cmake -S . -B build/x86.debug.clang-tidy \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Debug \ + -DENABLE_CLANG_TIDY=ON \ + $CONAN_TOOLCHAIN + + - name: Build with clang-tidy + run: cmake --build build/x86.debug.clang-tidy -j$(nproc) 2>&1 | tee clang-tidy-output.txt + continue-on-error: true + + - name: Fail on clang-tidy errors + run: | + # Filter out CMake integration false positives: clang-tidy is invoked on + # generated cmake files and non-source inputs, producing "no input files" + # and "no such file or directory" diagnostics that are not real code errors. + # Only fail on errors that reference actual source files (src/ or include/). + REAL_ERRORS=$(grep -E "error:" clang-tidy-output.txt \ + | grep -v "no input files\|no such file or directory\|unable to handle compilation" \ + || true) + if [ -n "$REAL_ERRORS" ]; then + echo "::error::clang-tidy reported errors in source files" + echo "$REAL_ERRORS" + exit 1 + fi + echo "clang-tidy passed" + + - name: Show sccache stats + if: always() + run: sccache --show-stats + + - name: Run mypy (Python typecheck) + run: mypy conanfile.py # ============================================================ # 2. unit-tests + # C++ unit tests + Python pytest # ============================================================ unit-tests: name: unit-tests runs-on: ubuntu-24.04 - timeout-minutes: 60 + timeout-minutes: 20 needs: lint steps: @@ -87,10 +226,10 @@ jobs: - name: Cache FetchContent downloads uses: actions/cache@v5 with: - path: build/x86.release/_deps - key: fetchcontent-release-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} + path: build/x86.debug/_deps + key: fetchcontent-debug-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} restore-keys: | - fetchcontent-release-${{ runner.os }}- + fetchcontent-debug-${{ runner.os }}- - name: Set up sccache uses: mozilla-actions/sccache-action@v0.0.10 @@ -101,14 +240,21 @@ jobs: echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - - name: Install Conan dependencies (native release) + - name: Install Conan dependencies run: make deps.native - - name: Build release (C++) - run: make compile.release.native + - name: Build debug (C++) + run: make compile.debug.native + + - name: Run C++ unit tests + run: | + cd build/x86.debug + ctest --output-on-failure -L unit -j$(nproc) || \ + ctest --output-on-failure -E 'integration|sample|example|application' -j$(nproc) - - name: Run C++ tests (release) - run: make test.release.native + - name: Show sccache stats + if: always() + run: sccache --show-stats - name: Set up Python uses: actions/setup-python@v6 @@ -119,22 +265,42 @@ jobs: run: pip install -e ".[dev]" - name: Run Python unit tests - run: python -m pytest tests/ -v --ignore=tests/e2e --ignore=tests/load --ignore=tests/integration -q --timeout=300 - continue-on-error: true + run: pytest tests/ --ignore=tests/e2e --ignore=tests/load --ignore=tests/integration -q + timeout-minutes: 10 - - name: Show sccache stats + - name: Upload Python coverage XML if: always() - run: sccache --show-stats + uses: actions/upload-artifact@v7 + with: + name: python-coverage + path: coverage.xml + retention-days: 14 + if-no-files-found: ignore + + - name: Upload C++ test logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: unit-test-logs + path: build/x86.debug/Testing/ + retention-days: 7 # ============================================================ # 3. integration-tests + # C++ integration/sample/example/application tests + # + all sanitizer builds (asan, ubsan, tsan, lsan) as matrix # ============================================================ integration-tests: - name: integration-tests + name: integration-tests (${{ matrix.sanitizer }}) runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 40 needs: lint + strategy: + fail-fast: false + matrix: + sanitizer: [asan, ubsan, tsan, lsan] + steps: - name: Checkout code uses: actions/checkout@v6 @@ -153,10 +319,10 @@ jobs: - name: Cache FetchContent downloads uses: actions/cache@v5 with: - path: build/x86.release/_deps - key: fetchcontent-release-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} + path: build/x86.debug.${{ matrix.sanitizer }}/_deps + key: fetchcontent-${{ matrix.sanitizer }}-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} restore-keys: | - fetchcontent-release-${{ runner.os }}- + fetchcontent-${{ matrix.sanitizer }}-${{ runner.os }}- - name: Set up sccache uses: mozilla-actions/sccache-action@v0.0.10 @@ -167,145 +333,88 @@ jobs: echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - - name: Install Conan dependencies (native release) + - name: Install Conan dependencies run: make deps.native - - name: Build release (C++) - run: make compile.release.native - - - name: Run C++ integration tests (label filter) - run: | - cd build/x86.release - ctest --output-on-failure -L integration || true - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.11" - - - name: Install Python dev dependencies - run: pip install -e ".[dev]" - - - name: Run Python integration tests - run: | - if [ -d tests/integration ] && [ "$(ls -A tests/integration)" ]; then - python -m pytest tests/integration/ -v -q || true - else - echo "No Python integration tests found — skipping" - fi + - name: Build with ${{ matrix.sanitizer }} + run: make compile.debug.${{ matrix.sanitizer }}.native - name: Show sccache stats if: always() run: sccache --show-stats + - name: Run all tests with ${{ matrix.sanitizer }} + run: make test.debug.${{ matrix.sanitizer }}.native + env: + TSAN_OPTIONS: ${{ matrix.sanitizer == 'tsan' && format('suppressions={0}/tsan.supp:second_deadlock_stack=1', github.workspace) || '' }} + + - name: Upload test logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: integration-test-logs-${{ matrix.sanitizer }} + path: build/x86.debug.${{ matrix.sanitizer }}/Testing/ + retention-days: 7 + # ============================================================ - # 4. security/dependency-scan + # 4. build # ============================================================ - security-dependency-scan: - name: security/dependency-scan + build: + name: build runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 20 + needs: lint steps: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.11" - - - name: Install pip-audit - run: pip install pip-audit - - - name: Python dependency audit (pip-audit) - run: | - pip install -e ".[dev]" --quiet - pip-audit --strict || true # report but don't block on warnings + - name: Install build dependencies + uses: ./.github/actions/install-build-deps - - name: Run Trivy filesystem scan (SARIF) - uses: aquasecurity/trivy-action@v0.36.0 + - name: Cache Conan packages + uses: actions/cache@v5 with: - scan-type: "fs" - scan-ref: "." - format: "sarif" - output: "dep-scan.sarif" - severity: "CRITICAL,HIGH,MEDIUM" - exit-code: "0" - continue-on-error: true + path: ~/.conan2 + key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py') }} + restore-keys: | + conan-${{ runner.os }}- - - name: Run Trivy filesystem scan (JSON for summary) - uses: aquasecurity/trivy-action@v0.36.0 + - name: Cache FetchContent downloads + uses: actions/cache@v5 with: - scan-type: "fs" - scan-ref: "." - format: "json" - output: "dep-scan.json" - severity: "CRITICAL,HIGH,MEDIUM,LOW" - exit-code: "0" - continue-on-error: true + path: build/x86.release/_deps + key: fetchcontent-release-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} + restore-keys: | + fetchcontent-release-${{ runner.os }}- - - name: Upload SARIF to GitHub Security tab - uses: github/codeql-action/upload-sarif@v4 - if: always() && hashFiles('dep-scan.sarif') != '' - with: - sarif_file: "dep-scan.sarif" - category: "dependency-scan-required" + - name: Set up sccache + uses: mozilla-actions/sccache-action@v0.0.10 - - name: Build audit summary - if: always() + - name: Configure sccache run: | - if [ ! -f dep-scan.json ]; then - echo "::warning::Trivy produced no output" - exit 0 - fi - CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' dep-scan.json 2>/dev/null || echo "0") - HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | length' dep-scan.json 2>/dev/null || echo "0") - echo "## Dependency Scan (Required Checks)" >> "$GITHUB_STEP_SUMMARY" - echo "| Severity | Count |" >> "$GITHUB_STEP_SUMMARY" - echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY" - echo "| Critical | $CRITICAL |" >> "$GITHUB_STEP_SUMMARY" - echo "| High | $HIGH |" >> "$GITHUB_STEP_SUMMARY" - if [ "$CRITICAL" -gt 0 ]; then - echo "::error::$CRITICAL critical vulnerabilities found" - exit 1 - fi + echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV + echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV + echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - # ============================================================ - # 5. security/secrets-scan - # ============================================================ - security-secrets-scan: - name: security/secrets-scan - runs-on: ubuntu-24.04 - timeout-minutes: 10 + - name: Install Conan dependencies + run: make deps.native - steps: - - name: Checkout code (full history for secrets scan) - uses: actions/checkout@v6 - with: - fetch-depth: 0 + - name: Build release + run: make compile.release.native - - name: Run Gitleaks - run: | - wget -q https://github.com/gitleaks/gitleaks/releases/download/v8.30.0/gitleaks_8.30.0_linux_x64.tar.gz - echo "79a3ab579b53f71efd634f3aaf7e04a0fa0cf206b7ed434638d1547a2470a66e gitleaks_8.30.0_linux_x64.tar.gz" | sha256sum --check - tar -xzf gitleaks_8.30.0_linux_x64.tar.gz - chmod +x gitleaks - if [ -f .gitleaks.toml ]; then - ./gitleaks detect --source=. --config=.gitleaks.toml --verbose --exit-code=1 - else - ./gitleaks detect --source=. --verbose --exit-code=1 - fi - continue-on-error: true + - name: Show sccache stats + if: always() + run: sccache --show-stats # ============================================================ - # 6. build + # 5. benchmarks # ============================================================ - build: - name: build + benchmarks: + name: benchmarks runs-on: ubuntu-24.04 timeout-minutes: 20 - continue-on-error: true + needs: build steps: - name: Checkout code @@ -339,7 +448,7 @@ jobs: echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - - name: Install Conan dependencies (native release) + - name: Install Conan dependencies run: make deps.native - name: Build release @@ -349,14 +458,25 @@ jobs: if: always() run: sccache --show-stats + - name: Run benchmarks + run: make benchmark.native || true + + - name: Upload benchmark results + uses: actions/upload-artifact@v7 + with: + name: benchmark-results + path: build/reports/benchmarks/ + retention-days: 30 + if-no-files-found: ignore + # ============================================================ - # 7. typecheck + # 6. coverage # ============================================================ - typecheck: - name: typecheck + coverage: + name: coverage runs-on: ubuntu-24.04 - timeout-minutes: 15 - needs: lint + timeout-minutes: 25 + needs: build steps: - name: Checkout code @@ -365,7 +485,7 @@ jobs: - name: Install build dependencies uses: ./.github/actions/install-build-deps with: - include-static-analysis: "true" + include-coverage: "true" - name: Cache Conan packages uses: actions/cache@v5 @@ -378,10 +498,10 @@ jobs: - name: Cache FetchContent downloads uses: actions/cache@v5 with: - path: build/x86.debug.clang-tidy/_deps - key: fetchcontent-debug-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} + path: build/x86.debug.coverage/_deps + key: fetchcontent-coverage-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} restore-keys: | - fetchcontent-debug-${{ runner.os }}- + fetchcontent-coverage-${{ runner.os }}- - name: Set up sccache uses: mozilla-actions/sccache-action@v0.0.10 @@ -392,51 +512,44 @@ jobs: echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - - name: Install Conan dependencies (native debug) + - name: Install Conan dependencies run: make deps.native - - name: Configure CMake with clang-tidy - run: | - CONAN_TOOLCHAIN="" - if [ -f build/conan-deps/conan_toolchain.cmake ]; then - CONAN_TOOLCHAIN="-DCMAKE_TOOLCHAIN_FILE=build/conan-deps/conan_toolchain.cmake" - fi - cmake -S . -B build/x86.debug.clang-tidy \ - -G Ninja \ - -DCMAKE_BUILD_TYPE=Debug \ - -DENABLE_CLANG_TIDY=ON \ - $CONAN_TOOLCHAIN - - - name: Build with clang-tidy (C++ typecheck) - run: cmake --build build/x86.debug.clang-tidy -j$(nproc) 2>&1 | tee clang-tidy-output.txt - continue-on-error: true - - - name: Check for clang-tidy errors - run: | - if grep -qE "error:" clang-tidy-output.txt; then - echo "::warning::clang-tidy reported errors (may include pre-existing cmake integration issues)" - grep -E "error:" clang-tidy-output.txt - else - echo "clang-tidy passed" - fi + - name: Build with coverage + run: make compile.debug.coverage.native - name: Show sccache stats if: always() run: sccache --show-stats - - name: Set up Python (mypy) - uses: actions/setup-python@v6 - with: - python-version: "3.11" + - name: Run tests for coverage + run: make test.debug.coverage.native - - name: Install mypy and conan stubs - run: pip install "mypy>=1.8.0,<2" "conan>=2.0.0" + - name: Generate coverage report + run: | + chmod +x scripts/generate_coverage.sh + BUILD_DIR=build/x86.coverage.debug ./scripts/generate_coverage.sh - - name: Run mypy (Python typecheck) - run: mypy conanfile.py + - name: Upload coverage report + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: build/coverage/html/ + retention-days: 30 + if-no-files-found: ignore + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: build/coverage/coverage_filtered.info + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} # ============================================================ - # 8. schema-validation + # 7. schema-validation # ============================================================ schema-validation: name: schema-validation @@ -466,7 +579,7 @@ jobs: [ -z "${FAILED}" ] || exit 1 # ============================================================ - # 9. deps/version-sync + # 8. deps/version-sync # ============================================================ deps-version-sync: name: deps/version-sync @@ -489,22 +602,18 @@ jobs: root = pathlib.Path(".") - # --- CMakeLists.txt --- cmake_text = (root / "CMakeLists.txt").read_text() cmake_m = re.search(r"project\s*\([^)]*VERSION\s+([\d.]+)", cmake_text, re.DOTALL) cmake_ver = cmake_m.group(1) if cmake_m else None - # --- conanfile.py --- conan_text = (root / "conanfile.py").read_text() conan_m = re.search(r'version\s*=\s*["\']([^"\']+)["\']', conan_text) conan_ver = conan_m.group(1) if conan_m else None - # --- pyproject.toml --- with open(root / "pyproject.toml", "rb") as fh: pyproject = tomllib.load(fh) pyproject_ver = pyproject.get("project", {}).get("version") - # --- pixi.toml --- with open(root / "pixi.toml", "rb") as fh: pixi = tomllib.load(fh) pixi_ver = pixi.get("project", {}).get("version") @@ -534,3 +643,265 @@ jobs: print(f"\nAll versions agree: {unique.pop()}") PYEOF + + # ============================================================ + # 9. security/dependency-scan + # pip-audit + Trivy FS + supply-chain dependency-review + # + Docker image scan (Trivy container) + # ============================================================ + security-dependency-scan: + name: security/dependency-scan + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install pip-audit + run: pip install pip-audit + + - name: Python dependency audit (pip-audit) + run: | + pip install -e ".[dev]" --quiet + pip-audit --strict || true + + - name: Run Trivy filesystem scan (SARIF) + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: "fs" + scan-ref: "." + format: "sarif" + output: "dep-scan.sarif" + severity: "CRITICAL,HIGH,MEDIUM" + exit-code: "0" + continue-on-error: true + + - name: Run Trivy filesystem scan (JSON) + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: "fs" + scan-ref: "." + format: "json" + output: "dep-scan.json" + severity: "CRITICAL,HIGH,MEDIUM,LOW" + exit-code: "0" + continue-on-error: true + + - name: Upload Trivy FS results to Security tab + uses: github/codeql-action/upload-sarif@v4 + if: always() && hashFiles('dep-scan.sarif') != '' + with: + sarif_file: "dep-scan.sarif" + category: "trivy-filesystem" + + - name: Supply-chain review (PR only) + if: github.event_name == 'pull_request' + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: critical + deny-licenses: GPL-3.0, AGPL-3.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build Docker image for scanning + id: docker_build + run: docker build --target production -t projectkeystone:scan . + continue-on-error: true + + - name: Warn if Docker build skipped + if: steps.docker_build.outcome == 'failure' + run: echo "::warning::Docker build failed — skipping container scan. Fix the Dockerfile to enable Trivy container scanning." + + - name: Run Trivy container scan (SARIF) + if: steps.docker_build.outcome == 'success' + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: "projectkeystone:scan" + format: "sarif" + output: "container-scan.sarif" + severity: "CRITICAL,HIGH,MEDIUM,LOW" + continue-on-error: true + + - name: Run Trivy container scan (JSON) + if: steps.docker_build.outcome == 'success' + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: "projectkeystone:scan" + format: "json" + output: "container-scan.json" + severity: "CRITICAL,HIGH,MEDIUM,LOW" + continue-on-error: true + + - name: Upload Trivy container results to Security tab + if: steps.docker_build.outcome == 'success' && hashFiles('container-scan.sarif') != '' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: "container-scan.sarif" + category: "trivy-container" + + - name: Dependency scan summary and gate + if: always() + run: | + echo "## Dependency Scan" >> "$GITHUB_STEP_SUMMARY" + echo "| Severity | FS | Container |" >> "$GITHUB_STEP_SUMMARY" + echo "|----------|----|-----------|" >> "$GITHUB_STEP_SUMMARY" + for sev in CRITICAL HIGH MEDIUM LOW; do + FS_COUNT=0; CTR_COUNT=0 + [ -f dep-scan.json ] && FS_COUNT=$(jq "[.Results[]?.Vulnerabilities[]? | select(.Severity==\"$sev\")] | length" dep-scan.json 2>/dev/null || echo 0) + [ -f container-scan.json ] && CTR_COUNT=$(jq "[.Results[]?.Vulnerabilities[]? | select(.Severity==\"$sev\")] | length" container-scan.json 2>/dev/null || echo 0) + echo "| $sev | $FS_COUNT | $CTR_COUNT |" >> "$GITHUB_STEP_SUMMARY" + done + CRITICAL_FS=$([ -f dep-scan.json ] && jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' dep-scan.json 2>/dev/null || echo 0) + CRITICAL_CTR=$([ -f container-scan.json ] && jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' container-scan.json 2>/dev/null || echo 0) + if [ "$CRITICAL_FS" -gt 0 ] || [ "$CRITICAL_CTR" -gt 0 ]; then + echo "::error::Critical vulnerabilities found (fs=$CRITICAL_FS container=$CRITICAL_CTR)" + exit 1 + fi + + - name: Upload scan artifacts + uses: actions/upload-artifact@v7 + if: always() + with: + name: dependency-scan-results + path: | + dep-scan.sarif + dep-scan.json + container-scan.sarif + container-scan.json + retention-days: 90 + if-no-files-found: ignore + + # ============================================================ + # 10. security/secrets-scan + # gitleaks + Semgrep SAST + CodeQL (c-cpp + python) + # + aggregation report gate + # ============================================================ + security-secrets-scan: + name: security/secrets-scan + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - name: Checkout code (full history) + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + # ---------- Gitleaks ---------- + - name: Install Gitleaks + run: | + GITLEAKS_VERSION=8.30.1 + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/') + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_${OS}_${ARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin gitleaks + gitleaks version + + - name: Run Gitleaks + run: | + if [ -f .gitleaks.toml ]; then + gitleaks detect --source . --config .gitleaks.toml \ + --report-format sarif --report-path gitleaks.sarif --exit-code 0 + else + gitleaks detect --source . \ + --report-format sarif --report-path gitleaks.sarif --exit-code 0 + fi + continue-on-error: true + + - name: Upload Gitleaks SARIF + if: always() && hashFiles('gitleaks.sarif') != '' + uses: actions/upload-artifact@v7 + with: + name: gitleaks-report + path: gitleaks.sarif + retention-days: 90 + + # ---------- Semgrep SAST ---------- + - name: Run Semgrep + uses: semgrep/semgrep-action@v1 + with: + config: >- + p/security-audit + p/cpp + p/docker + p/cmake + continue-on-error: true + + - name: Upload Semgrep SARIF + if: always() && hashFiles('semgrep.sarif') != '' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: semgrep.sarif + category: "semgrep" + + # ---------- CodeQL C/C++ ---------- + - name: Initialize CodeQL (c-cpp) + uses: github/codeql-action/init@v3 + with: + languages: c-cpp + build-mode: manual + queries: security-and-quality + + - name: Install C++ build deps for CodeQL + run: | + sudo apt-get update && sudo apt-get install -y \ + cmake ninja-build clang-18 clang++-18 \ + libc++-18-dev libc++abi-18-dev libssl-dev + pip install conan --break-system-packages + conan profile detect --force + conan install . \ + --output-folder=build/conan-deps \ + --build=missing \ + -s build_type=Release \ + -s compiler.cppstd=20 + + - name: Build for CodeQL (c-cpp) + run: | + cmake -S . -B build/codeql \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE=build/conan-deps/conan_toolchain.cmake + cmake --build build/codeql + + - name: Perform CodeQL Analysis (c-cpp) + uses: github/codeql-action/analyze@v3 + with: + category: "/language:c-cpp" + + # ---------- CodeQL Python ---------- + - name: Initialize CodeQL (python) + uses: github/codeql-action/init@v3 + with: + languages: python + build-mode: none + queries: security-and-quality + + - name: Perform CodeQL Analysis (python) + uses: github/codeql-action/analyze@v3 + with: + category: "/language:python" + + # ---------- Report gate ---------- + - name: Security findings gate + if: always() + run: | + SECRETS_FAIL=0 + if [ -f gitleaks.sarif ]; then + COUNT=$(jq '[.runs[].results[]] | length' gitleaks.sarif 2>/dev/null || echo -1) + if [ "$COUNT" -gt 0 ]; then + echo "::error::Gitleaks found $COUNT potential secret(s)" + SECRETS_FAIL=1 + fi + fi + if [ "$SECRETS_FAIL" -gt 0 ]; then + exit 1 + fi + echo "Secret scan passed" diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml deleted file mode 100644 index db3bcced..00000000 --- a/.github/workflows/build-and-test.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: Build and Test (Reusable) - -on: - workflow_call: - inputs: - build-type: - description: Build type (debug or release) - type: string - default: 'debug' - sanitizer: - description: Sanitizer to use (asan, ubsan, tsan, lsan, msan, or empty) - type: string - default: '' - run-benchmarks: - description: Run benchmarks after tests - type: boolean - default: false - timeout-minutes: - description: Job timeout in minutes - type: number - default: 30 - outputs: - test-passed: - description: Whether all tests passed - value: ${{ jobs.build-test.outputs.test-passed }} - build-dir: - description: Build directory used - value: ${{ jobs.build-test.outputs.build-dir }} - -jobs: - build-test: - name: Build & Test (${{ inputs.build-type }}${{ inputs.sanitizer && format('.{0}', inputs.sanitizer) || '' }}) - runs-on: ubuntu-24.04 - timeout-minutes: ${{ inputs.timeout-minutes }} - - outputs: - test-passed: ${{ steps.test.outputs.passed }} - build-dir: ${{ steps.setup.outputs.build-dir }} - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Install build dependencies - uses: ./.github/actions/install-build-deps - - - name: Cache Conan packages - uses: actions/cache@v5 - with: - path: ~/.conan2 - key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py') }} - restore-keys: | - conan-${{ runner.os }}- - - - name: Compute build directory - id: setup - run: | - BUILD_DIR="build/x86.${{ inputs.build-type }}" - if [ -n "${{ inputs.sanitizer }}" ]; then - BUILD_DIR="${BUILD_DIR}.${{ inputs.sanitizer }}" - fi - echo "build-dir=$BUILD_DIR" >> $GITHUB_OUTPUT - echo "Building to: $BUILD_DIR" - - - name: Cache FetchContent downloads - uses: actions/cache@v5 - with: - path: ${{ steps.setup.outputs.build-dir }}/_deps - key: fetchcontent-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} - restore-keys: | - fetchcontent-${{ runner.os }}- - - - name: Set up sccache - uses: mozilla-actions/sccache-action@v0.0.10 - - - name: Configure sccache - run: | - echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV - echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - - - name: Build with Makefile - run: | - # Construct the make target - TARGET="compile.${{ inputs.build-type }}" - if [ -n "${{ inputs.sanitizer }}" ]; then - TARGET="${TARGET}.${{ inputs.sanitizer }}" - fi - TARGET="${TARGET}.native" - - echo "Running: make $TARGET" - make $TARGET - - - name: Run tests - id: test - run: | - # Construct the test target - TARGET="test.${{ inputs.build-type }}" - if [ -n "${{ inputs.sanitizer }}" ]; then - TARGET="${TARGET}.${{ inputs.sanitizer }}" - fi - TARGET="${TARGET}.native" - - echo "Running: make $TARGET" - set +e - make $TARGET - TEST_RESULT=$? - set -e - - if [ $TEST_RESULT -eq 0 ]; then - echo "passed=true" >> $GITHUB_OUTPUT - else - echo "passed=false" >> $GITHUB_OUTPUT - exit $TEST_RESULT - fi - - - name: Run benchmarks - if: inputs.run-benchmarks - run: | - echo "Running: make benchmark.native" - make benchmark.native - - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v7 - with: - name: test-logs-${{ inputs.build-type }}-${{ inputs.sanitizer || 'none' }} - path: | - ${{ steps.setup.outputs.build-dir }}/Testing/ - ${{ steps.setup.outputs.build-dir }}/*.log - retention-days: 7 - if-no-files-found: ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index ee0284f5..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,452 +0,0 @@ -name: CI - -on: - push: - branches: [main, develop, 'claude/**'] - pull_request: - branches: [main, develop] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - # ============================================================================ - # Code Quality Checks (fast, run first) - # ============================================================================ - quality: - name: Code Quality - runs-on: ubuntu-24.04 - timeout-minutes: 10 - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Install build dependencies - uses: ./.github/actions/install-build-deps - with: - include-just: 'true' - - - name: Check formatting - run: just format-check - - - name: Run pre-commit hooks - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd - with: - extra_args: --show-diff-on-failure - - - name: Check CMake dependency graph for cycles - run: | - cmake -S . -B build/graphviz -G Ninja --graphviz=build/graphviz/deps.dot -DCMAKE_BUILD_TYPE=Debug 2>/dev/null || true - if [ -f build/graphviz/deps.dot ]; then - echo "CMake dependency graph generated successfully" - python3 << 'EOF' - import re, sys - content = open("build/graphviz/deps.dot").read() - edges = re.findall(r'"([^"]+)" -> "([^"]+)"', content) - adj = {} - for src, dst in edges: - adj.setdefault(src, []).append(dst) - visited, in_stack = set(), set() - def has_cycle(node): - if node in in_stack: return True - if node in visited: return False - visited.add(node); in_stack.add(node) - if any(has_cycle(n) for n in adj.get(node, [])): return True - in_stack.discard(node); return False - if any(has_cycle(n) for n in list(adj)): - print("CYCLE DETECTED in CMake dependency graph!"); sys.exit(1) - print("No cycles detected in CMake dependency graph") - EOF - fi - - # Note: Full static analysis (clang-tidy, cppcheck) takes 30+ minutes - # and should be run locally or in a separate weekly workflow. - # For PR checks, we only verify formatting. - - # ============================================================================ - # Python Tests with Coverage - # ============================================================================ - pytest: - name: Python Tests - runs-on: ubuntu-24.04 - timeout-minutes: 10 - needs: quality - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.11" - - - name: Install Python dependencies - run: pip install -e ".[dev]" - - - name: Run pytest with coverage - run: pytest - - - name: Run version bounds test - run: python3 -m pytest tests/test_version_bounds.py -v - - - name: Upload coverage XML - if: always() - uses: actions/upload-artifact@v7 - with: - name: python-coverage - path: coverage.xml - retention-days: 14 - if-no-files-found: ignore - - # ============================================================================ - # Python Quality Checks (mypy type checking) - # ============================================================================ - python-quality: - name: Python Quality (mypy) - runs-on: ubuntu-24.04 - timeout-minutes: 10 - needs: quality - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.9' - - - name: Install mypy and ruff - run: pip install "mypy>=1.8.0" "conan>=2.0.0" "ruff>=0.1.0" - - - name: Lint with ruff - run: ruff check src/ tests/ - - - name: Type check (mypy) - run: mypy conanfile.py - - # ============================================================================ - # Build and Test Matrix (all sanitizers) - # ============================================================================ - sanitizers: - - name: Test (${{ matrix.sanitizer }}) - runs-on: ubuntu-24.04 - timeout-minutes: 30 - needs: quality - - strategy: - fail-fast: false - matrix: - sanitizer: [asan, ubsan, tsan, lsan] - include: - - sanitizer: asan - name: AddressSanitizer - - sanitizer: ubsan - name: UndefinedBehaviorSanitizer - - sanitizer: tsan - name: ThreadSanitizer - - sanitizer: lsan - name: LeakSanitizer - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Install build dependencies - uses: ./.github/actions/install-build-deps - - - name: Cache Conan packages - uses: actions/cache@v5 - with: - path: ~/.conan2 - key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py') }} - restore-keys: | - conan-${{ runner.os }}- - - - name: Cache FetchContent downloads - uses: actions/cache@v5 - with: - path: build/x86.debug.${{ matrix.sanitizer }}/_deps - key: fetchcontent-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} - restore-keys: | - fetchcontent-${{ runner.os }}- - - - name: Set up sccache - uses: mozilla-actions/sccache-action@v0.0.10 - - - name: Configure sccache - run: | - echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV - echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - - - name: Install project dependencies - run: make deps.native - - - name: Build with ${{ matrix.name }} - run: make compile.debug.${{ matrix.sanitizer }}.native - - - name: Show sccache stats - if: always() - run: sccache --show-stats - - - name: Run tests with ${{ matrix.name }} - run: make test.debug.${{ matrix.sanitizer }}.native - env: - # TSan: suppress ConcurrentQueue false positives; 600s timeout set in Makefile - TSAN_OPTIONS: ${{ matrix.sanitizer == 'tsan' && format('suppressions={0}/tsan.supp:second_deadlock_stack=1', github.workspace) || '' }} - - - name: Upload test logs - if: failure() - uses: actions/upload-artifact@v7 - with: - name: test-logs-${{ matrix.sanitizer }} - path: build/x86.debug.${{ matrix.sanitizer }}/Testing/ - retention-days: 7 - - # ============================================================================ - # Benchmarks (release build) - # ============================================================================ - benchmarks: - name: Benchmarks - runs-on: ubuntu-24.04 - timeout-minutes: 20 - needs: quality - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Install build dependencies - uses: ./.github/actions/install-build-deps - - - name: Cache Conan packages - uses: actions/cache@v5 - with: - path: ~/.conan2 - key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py') }} - restore-keys: | - conan-${{ runner.os }}- - - - name: Cache FetchContent downloads - uses: actions/cache@v5 - with: - path: build/x86.release/_deps - key: fetchcontent-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} - restore-keys: | - fetchcontent-${{ runner.os }}- - - - name: Set up sccache - uses: mozilla-actions/sccache-action@v0.0.10 - - - name: Configure sccache - run: | - echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV - echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - - - name: Install project dependencies - run: make deps.native - - - name: Build release - run: make compile.release.native - - - name: Show sccache stats - if: always() - run: sccache --show-stats - - - name: Run benchmarks - run: make benchmark.native || true # Don't fail on benchmark results - - - name: Upload benchmark results - uses: actions/upload-artifact@v7 - with: - name: benchmark-results - path: build/reports/benchmarks/ - retention-days: 30 - if-no-files-found: ignore - - # ============================================================================ - # Coverage Report - # ============================================================================ - coverage: - name: Code Coverage - runs-on: ubuntu-24.04 - timeout-minutes: 20 - needs: quality - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Install build dependencies - uses: ./.github/actions/install-build-deps - with: - include-coverage: 'true' - - - name: Cache Conan packages - uses: actions/cache@v5 - with: - path: ~/.conan2 - key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py') }} - restore-keys: | - conan-${{ runner.os }}- - - - name: Cache FetchContent downloads - uses: actions/cache@v5 - with: - path: build/x86.debug.coverage/_deps - key: fetchcontent-${{ runner.os }}-${{ hashFiles('CMakeLists.txt') }} - restore-keys: | - fetchcontent-${{ runner.os }}- - - - name: Set up sccache - uses: mozilla-actions/sccache-action@v0.0.10 - - - name: Configure sccache - run: | - echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV - echo "CMAKE_C_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - echo "CMAKE_CXX_COMPILER_LAUNCHER=sccache" >> $GITHUB_ENV - - - name: Install project dependencies - run: make deps.native - - - name: Build with coverage - run: make compile.debug.coverage.native - - - name: Show sccache stats - if: always() - run: sccache --show-stats - - - name: Run tests for coverage - run: make test.debug.coverage.native - - - name: Generate coverage report - run: | - chmod +x scripts/generate_coverage.sh - BUILD_DIR=build/x86.coverage.debug ./scripts/generate_coverage.sh - - - name: Upload coverage report - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: build/coverage/html/ - retention-days: 30 - if-no-files-found: ignore - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - files: build/coverage/coverage_filtered.info - flags: unittests - name: codecov-umbrella - fail_ci_if_error: false - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - # ============================================================================ - # Summary Report - # ============================================================================ - summary: - name: CI Summary - runs-on: ubuntu-24.04 - needs: [quality, pytest, python-quality, sanitizers, benchmarks, coverage] - if: always() - - steps: - - name: Check results - run: | - echo "Quality: ${{ needs.quality.result }}" - echo "Python Tests: ${{ needs.pytest.result }}" - echo "Python Quality (mypy): ${{ needs.python-quality.result }}" - echo "Sanitizers: ${{ needs.sanitizers.result }}" - echo "Benchmarks: ${{ needs.benchmarks.result }}" - echo "Coverage: ${{ needs.coverage.result }}" - - # Fail if critical jobs failed - if [ "${{ needs.quality.result }}" != "success" ]; then - echo "::error::Quality checks failed" - exit 1 - fi - if [ "${{ needs.pytest.result }}" != "success" ]; then - echo "::error::Python tests failed" - exit 1 - fi - if [ "${{ needs.python-quality.result }}" != "success" ]; then - echo "::error::Python quality checks failed" - exit 1 - fi - if [ "${{ needs.sanitizers.result }}" != "success" ]; then - echo "::error::Sanitizer tests failed" - exit 1 - fi - - echo "CI passed successfully" - - - name: Post PR summary - if: github.event_name == 'pull_request' - uses: actions/github-script@v9 - with: - script: | - const sanitizerResult = '${{ needs.sanitizers.result }}'; - const qualityResult = '${{ needs.quality.result }}'; - const pytestResult = '${{ needs.pytest.result }}'; - const pythonQualityResult = '${{ needs.python-quality.result }}'; - const benchmarkResult = '${{ needs.benchmarks.result }}'; - const coverageResult = '${{ needs.coverage.result }}'; - - const statusIcon = (result) => result === 'success' ? '✅' : result === 'failure' ? '❌' : '⚠️'; - - const body = `## CI Summary - - | Check | Status | - |-------|--------| - | Code Quality | ${statusIcon(qualityResult)} ${qualityResult} | - | Python Tests | ${statusIcon(pytestResult)} ${pytestResult} | - | Python Quality (mypy) | ${statusIcon(pythonQualityResult)} ${pythonQualityResult} | - | Sanitizers (ASan, UBSan, TSan, LSan, MSan) | ${statusIcon(sanitizerResult)} ${sanitizerResult} | - | Benchmarks | ${statusIcon(benchmarkResult)} ${benchmarkResult} | - | Coverage | ${statusIcon(coverageResult)} ${coverageResult} | - - [View full run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) - `; - - // Find and update or create comment - const marker = ''; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const existingComment = comments.find(c => - c.user.type === 'Bot' && c.body.includes(marker) - ); - - const fullBody = marker + '\n' + body; - - if (existingComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existingComment.id, - body: fullBody - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: fullBody - }); - } diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 8e0e0729..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: CodeQL Analysis - -on: - push: - branches: [main] - pull_request: - branches: [main] - schedule: - # Run weekly on Sundays at 3 AM UTC - - cron: '0 3 * * 0' - workflow_dispatch: - -permissions: - contents: read - security-events: write - actions: read - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - runs-on: ubuntu-24.04 - timeout-minutes: 60 - - strategy: - fail-fast: false - matrix: - include: - - language: c-cpp - build-mode: manual - - language: python - build-mode: none - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - queries: security-and-quality - - # C++ manual build steps -- only run for c-cpp - - name: Install build dependencies - if: matrix.language == 'c-cpp' - run: | - sudo apt-get update && sudo apt-get install -y \ - cmake ninja-build \ - clang-18 clang++-18 \ - libc++-18-dev libc++abi-18-dev \ - libssl-dev - pip install conan --break-system-packages - conan profile detect --force - conan install . \ - --output-folder=build/conan-deps \ - --build=missing \ - -s build_type=Release \ - -s compiler.cppstd=20 - - - name: Build for CodeQL analysis - if: matrix.language == 'c-cpp' - run: | - cmake -S . -B build/codeql \ - -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_TOOLCHAIN_FILE=build/conan-deps/conan_toolchain.cmake - cmake --build build/codeql - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml deleted file mode 100644 index 42018f3f..00000000 --- a/.github/workflows/dependency-audit.yml +++ /dev/null @@ -1,169 +0,0 @@ -name: Dependency Audit - -on: - push: - branches: [main] - pull_request: - schedule: - - cron: "0 6 * * 1" # Weekly Monday 6 AM UTC - workflow_dispatch: - -permissions: - contents: read - security-events: write - pull-requests: write - -jobs: - conan-audit: - name: Conan C++ Dependency Audit - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Run Trivy filesystem scan (SARIF) - uses: aquasecurity/trivy-action@v0.36.0 - with: - scan-type: 'fs' - scan-ref: '.' - format: 'sarif' - output: 'dependency-audit.sarif' - severity: 'CRITICAL,HIGH,MEDIUM' - exit-code: '0' - continue-on-error: true - - - name: Run Trivy filesystem scan (JSON for summary) - uses: aquasecurity/trivy-action@v0.36.0 - with: - scan-type: 'fs' - scan-ref: '.' - format: 'json' - output: 'dependency-audit.json' - severity: 'CRITICAL,HIGH,MEDIUM,LOW' - exit-code: '0' - continue-on-error: true - - - name: Upload results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v4 - if: always() && hashFiles('dependency-audit.sarif') != '' - with: - sarif_file: 'dependency-audit.sarif' - category: 'dependency-audit' - - - name: Build audit summary - if: always() - run: | - if [ ! -f dependency-audit.json ]; then - echo "::warning::Audit produced no output" - exit 0 - fi - - CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' dependency-audit.json 2>/dev/null || echo "0") - HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | length' dependency-audit.json 2>/dev/null || echo "0") - MEDIUM=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="MEDIUM")] | length' dependency-audit.json 2>/dev/null || echo "0") - LOW=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="LOW")] | length' dependency-audit.json 2>/dev/null || echo "0") - - echo "## Dependency Audit Results" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Scanned Conan C++ dependencies (conanfile.py) and project filesystem." >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "| Severity | Count |" >> "$GITHUB_STEP_SUMMARY" - echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY" - echo "| Critical | $CRITICAL |" >> "$GITHUB_STEP_SUMMARY" - echo "| High | $HIGH |" >> "$GITHUB_STEP_SUMMARY" - echo "| Medium | $MEDIUM |" >> "$GITHUB_STEP_SUMMARY" - echo "| Low | $LOW |" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Full results are available in the GitHub Security tab and workflow artifacts." >> "$GITHUB_STEP_SUMMARY" - - # Write counts for the PR comment step - echo "CRITICAL=$CRITICAL" >> audit-counts.env - echo "HIGH=$HIGH" >> audit-counts.env - echo "MEDIUM=$MEDIUM" >> audit-counts.env - echo "LOW=$LOW" >> audit-counts.env - - if [ "$CRITICAL" -gt 0 ]; then - echo "::error::$CRITICAL critical vulnerabilities found — review the Security tab immediately" - elif [ "$HIGH" -gt 0 ]; then - echo "::warning::$HIGH high-severity vulnerabilities found" - fi - - - name: Upload audit artifacts - uses: actions/upload-artifact@v7 - if: always() - with: - name: dependency-audit-results - path: | - dependency-audit.sarif - dependency-audit.json - audit-counts.env - retention-days: 90 - - - name: Fail on critical vulnerabilities - if: always() && hashFiles('dependency-audit.json') != '' - run: | - CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' dependency-audit.json 2>/dev/null || echo "0") - if [ "$CRITICAL" -gt 0 ]; then - echo "::error::Found $CRITICAL critical vulnerabilities" - exit 1 - fi - echo "No critical vulnerabilities found" - - - name: Comment PR with audit results - if: github.event_name == 'pull_request' && always() && hashFiles('audit-counts.env') != '' - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - const raw = fs.readFileSync('audit-counts.env', 'utf8'); - const counts = Object.fromEntries( - raw.trim().split('\n').map(line => line.split('=')) - ); - const critical = parseInt(counts.CRITICAL || '0'); - const high = parseInt(counts.HIGH || '0'); - const medium = parseInt(counts.MEDIUM || '0'); - const low = parseInt(counts.LOW || '0'); - - const icon = critical > 0 ? '❌' : high > 0 ? '⚠️' : '✅'; - const body = [ - `## ${icon} Dependency Audit`, - '', - '| Severity | Count |', - '|----------|-------|', - `| Critical | ${critical} |`, - `| High | ${high} |`, - `| Medium | ${medium} |`, - `| Low | ${low} |`, - '', - `See the [Security tab](https://github.com/${context.repo.owner}/${context.repo.repo}/security) for detailed findings.`, - '', - `---`, - `*Workflow: [${context.workflow}](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*`, - ].join('\n'); - - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const existing = comments.data.find(c => - c.user.type === 'Bot' && c.body.includes('Dependency Audit') - ); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); - } diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml deleted file mode 100644 index e7ce4f88..00000000 --- a/.github/workflows/security-scan.yml +++ /dev/null @@ -1,484 +0,0 @@ -name: Security Scanning - -# NOTE: secret-scanning, sast-scanning, and security-report should be added as required status checks in Settings > Branches > main > Branch protection rules - -on: - pull_request: - push: - branches: [main] - schedule: - # Run weekly on Mondays at 9 AM UTC - - cron: '0 9 * * 1' - workflow_dispatch: - -permissions: - contents: read - security-events: write - pull-requests: write - -jobs: - secret-scanning: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 # Full history for comprehensive scanning - - - name: Install Gitleaks - run: | - GITLEAKS_VERSION=8.30.1 - OS=$(uname -s | tr '[:upper:]' '[:lower:]') - ARCH=$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/') - curl -sSfL \ - "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_${OS}_${ARCH}.tar.gz" \ - | tar -xz -C /usr/local/bin gitleaks - gitleaks version - - - name: Run Gitleaks - run: | - gitleaks detect --source . --report-format sarif --report-path results.sarif --exit-code 0 - continue-on-error: true - - - name: Upload Gitleaks results - if: always() - uses: actions/upload-artifact@v7 - with: - name: gitleaks-report - path: results.sarif - retention-days: 90 - - sast-scanning: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Run Semgrep - uses: semgrep/semgrep-action@v1 - with: - config: >- - p/security-audit - p/cpp - p/docker - p/cmake - continue-on-error: true - - - name: Upload Semgrep SARIF - if: always() && hashFiles('semgrep.sarif') != '' - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: semgrep.sarif - - codeql-analysis: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: cpp - queries: security-and-quality - - - name: Install build dependencies - run: | - sudo apt-get update - sudo apt-get install -y build-essential cmake ninja-build \ - clang-18 clang++-18 libc++-18-dev libc++abi-18-dev python3-pip - sudo update-alternatives --install /usr/bin/cc cc /usr/bin/clang-18 100 - sudo update-alternatives --install /usr/bin/c++ c++ /usr/bin/clang++-18 100 - pip install conan - conan profile detect --force - - - name: Cache Conan packages - uses: actions/cache@v5 - with: - path: ~/.conan2 - key: conan-codeql-${{ runner.os }}-${{ hashFiles('conanfile.py') }} - restore-keys: | - conan-codeql-${{ runner.os }}- - - - name: Install Conan dependencies - run: | - conan install . --output-folder=build/conan-deps --build=missing -s build_type=Debug -s compiler.cppstd=20 - conan install . --output-folder=build/conan-deps --build=missing -s build_type=Release -s compiler.cppstd=20 - - - name: Build for CodeQL - run: | - cmake -S . -B build/codeql -G Ninja -DCMAKE_BUILD_TYPE=Debug \ - -DCMAKE_TOOLCHAIN_FILE=build/conan-deps/conan_toolchain.cmake - cmake --build build/codeql - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:cpp" - - dependency-scanning: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout code - uses: actions/checkout@v6 - - # Filesystem scan covers C++ headers and Conan lock files for known CVEs - - name: Run Trivy filesystem scan (Conan + OS packages) — SARIF - uses: aquasecurity/trivy-action@v0.36.0 - with: - scan-type: 'fs' - scan-ref: '.' - format: 'sarif' - output: 'trivy-fs-results.sarif' - severity: 'CRITICAL,HIGH,MEDIUM' - exit-code: '0' - continue-on-error: true - - - name: Run Trivy filesystem scan (Conan + OS packages) — JSON - uses: aquasecurity/trivy-action@v0.36.0 - with: - scan-type: 'fs' - scan-ref: '.' - format: 'json' - output: 'trivy-fs-results.json' - severity: 'CRITICAL,HIGH,MEDIUM,LOW' - exit-code: '0' - continue-on-error: true - - - name: Upload Trivy filesystem results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v4 - if: always() && hashFiles('trivy-fs-results.sarif') != '' - with: - sarif_file: 'trivy-fs-results.sarif' - category: 'trivy-filesystem-scan' - - - name: Summarize dependency scan results - if: always() - run: | - if [ -f trivy-fs-results.json ]; then - CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' trivy-fs-results.json 2>/dev/null || echo "0") - HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | length' trivy-fs-results.json 2>/dev/null || echo "0") - MEDIUM=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="MEDIUM")] | length' trivy-fs-results.json 2>/dev/null || echo "0") - LOW=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="LOW")] | length' trivy-fs-results.json 2>/dev/null || echo "0") - - echo "### Dependency Scan Summary (Trivy Filesystem)" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "| Severity | Count |" >> "$GITHUB_STEP_SUMMARY" - echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY" - echo "| Critical | $CRITICAL |" >> "$GITHUB_STEP_SUMMARY" - echo "| High | $HIGH |" >> "$GITHUB_STEP_SUMMARY" - echo "| Medium | $MEDIUM |" >> "$GITHUB_STEP_SUMMARY" - echo "| Low | $LOW |" >> "$GITHUB_STEP_SUMMARY" - - if [ "$CRITICAL" -gt 0 ]; then - echo "::error::Found $CRITICAL critical vulnerabilities in project dependencies" - fi - else - echo "::warning::Dependency scan produced no results" - fi - - - name: Upload dependency scan results - uses: actions/upload-artifact@v7 - if: always() - with: - name: trivy-fs-scan-results - path: | - trivy-fs-results.sarif - trivy-fs-results.json - retention-days: 90 - - docker-image-scanning: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Build Docker image for scanning - id: docker_build - run: | - docker build --target production -t projectkeystone:scan . - continue-on-error: true - - - name: Warn if Docker build failed - if: steps.docker_build.outcome == 'failure' - run: | - echo "::warning::Docker build failed — skipping image scan. Fix the Dockerfile to enable Trivy container scanning." - - - name: Run Trivy vulnerability scanner (table format) - if: steps.docker_build.outcome == 'success' - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: 'projectkeystone:scan' - format: 'table' - exit-code: '0' - severity: 'CRITICAL,HIGH,MEDIUM' - continue-on-error: true - - - name: Run Trivy vulnerability scanner (SARIF format) - if: steps.docker_build.outcome == 'success' - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: 'projectkeystone:scan' - format: 'sarif' - output: 'trivy-results.sarif' - severity: 'CRITICAL,HIGH,MEDIUM,LOW' - continue-on-error: true - - - name: Upload Trivy results to GitHub Security tab - if: steps.docker_build.outcome == 'success' && hashFiles('trivy-results.sarif') != '' - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: 'trivy-results.sarif' - category: 'trivy-container-scan' - - - name: Run Trivy vulnerability scanner (JSON format) - if: steps.docker_build.outcome == 'success' - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: 'projectkeystone:scan' - format: 'json' - output: 'trivy-results.json' - severity: 'CRITICAL,HIGH,MEDIUM,LOW' - continue-on-error: true - - - name: Parse Trivy results - id: trivy-summary - if: always() - run: | - if [ -f trivy-results.json ]; then - CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' trivy-results.json) - HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | length' trivy-results.json) - MEDIUM=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="MEDIUM")] | length' trivy-results.json) - LOW=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="LOW")] | length' trivy-results.json) - echo "critical=$CRITICAL" >> $GITHUB_OUTPUT - echo "high=$HIGH" >> $GITHUB_OUTPUT - echo "medium=$MEDIUM" >> $GITHUB_OUTPUT - echo "low=$LOW" >> $GITHUB_OUTPUT - echo "### Trivy Scan Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Critical**: $CRITICAL" >> $GITHUB_STEP_SUMMARY - echo "- **High**: $HIGH" >> $GITHUB_STEP_SUMMARY - echo "- **Medium**: $MEDIUM" >> $GITHUB_STEP_SUMMARY - echo "- **Low**: $LOW" >> $GITHUB_STEP_SUMMARY - else - echo "critical=0" >> $GITHUB_OUTPUT - echo "high=0" >> $GITHUB_OUTPUT - echo "medium=0" >> $GITHUB_OUTPUT - echo "low=0" >> $GITHUB_OUTPUT - echo "### Trivy Scan Skipped" >> $GITHUB_STEP_SUMMARY - echo "Docker build failed — no image to scan." >> $GITHUB_STEP_SUMMARY - fi - - - name: Upload Trivy scan results - uses: actions/upload-artifact@v7 - if: always() - with: - name: trivy-scan-results - path: | - trivy-results.sarif - trivy-results.json - retention-days: 90 - - - name: Check for critical vulnerabilities - if: always() - run: | - CRITICAL="${{ steps.trivy-summary.outputs.critical }}" - HIGH="${{ steps.trivy-summary.outputs.high }}" - if [ "$CRITICAL" -gt 0 ]; then - echo "::error::Found $CRITICAL critical vulnerabilities in Docker image" - echo "::warning::Review the Trivy scan results in the Security tab" - fi - # Threshold of 10: allows common base-image HIGH vulns while blocking systemic exposure - if [ "$HIGH" -gt 10 ]; then - echo "::warning::Found $HIGH high-severity vulnerabilities in Docker image" - fi - - supply-chain-scanning: - runs-on: ubuntu-latest - # dependency-review-action only supports pull_request events; skip on push/schedule - timeout-minutes: 10 - # dependency-review-action only supports pull_request events; skip on push/schedule - if: github.event_name == 'pull_request' - steps: - - name: Dependency Review - uses: actions/dependency-review-action@v4 - with: - fail-on-severity: critical - deny-licenses: GPL-3.0, AGPL-3.0 - - cpp-static-analysis: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Install analysis tools - run: | - sudo apt-get update - sudo apt-get install -y cppcheck clang-tidy-14 - - - name: Run cppcheck security analysis - run: | - cppcheck \ - --enable=all \ - --xml \ - --xml-version=2 \ - --suppress=missingIncludeSystem \ - --suppress=syntaxError \ - --inline-suppr \ - -I include \ - src/ tests/ 2> cppcheck-report.xml - - - name: Fail on cppcheck error-severity findings - run: | - python3 - << 'EOF' - import xml.etree.ElementTree as ET, sys - tree = ET.parse("cppcheck-report.xml") - errors = [e for e in tree.getroot().find("errors") - if e.get("severity") == "error"] - if errors: - for e in errors: - loc = e.find("location") - f = loc.get("file","?") if loc is not None else "?" - l = loc.get("line","?") if loc is not None else "?" - print(f"CPPCHECK ERROR: {e.get('id')} at {f}:{l} — {e.get('msg')}") - sys.exit(1) - EOF - - - name: Upload cppcheck results - if: always() - uses: actions/upload-artifact@v7 - with: - name: cppcheck-report - path: cppcheck-report.xml - retention-days: 30 - - security-report: - runs-on: ubuntu-latest - timeout-minutes: 5 - needs: [secret-scanning, sast-scanning, dependency-scanning, docker-image-scanning, cpp-static-analysis] - if: always() - steps: - - name: Download all scan results - uses: actions/download-artifact@v8 - with: - path: scan-results/ - continue-on-error: true - - - name: Generate security report - id: report - run: | - echo "## Security Scan Results" > report.md - echo "" >> report.md - - # Check secret scanning - if [ -f "scan-results/gitleaks-report/results.sarif" ]; then - # Check if secrets found (simplified check) - if grep -q '"results":\s*\[\]' scan-results/gitleaks-report/results.sarif 2>/dev/null; then - echo "- ✅ Secret Scanning: No secrets detected" >> report.md - else - echo "- ❌ Secret Scanning: Potential secrets found" >> report.md - fi - else - echo "- ⚠️ Secret Scanning: No results available" >> report.md - fi - - # Check SAST - echo "- ✅ SAST: Completed (check Security tab for details)" >> report.md - - # Check dependency scanning - echo "- ✅ Dependency Scanning: Completed" >> report.md - - # Check C++ static analysis - if [ -f "scan-results/cppcheck-report/cppcheck-report.xml" ]; then - echo "- ✅ C++ Static Analysis: Completed" >> report.md - else - echo "- ⚠️ C++ Static Analysis: No results" >> report.md - fi - - # Check Docker image scanning (Trivy) - if [ -f "scan-results/trivy-scan-results/trivy-results.json" ]; then - CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' scan-results/trivy-scan-results/trivy-results.json 2>/dev/null || echo "0") - HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | length' scan-results/trivy-scan-results/trivy-results.json 2>/dev/null || echo "0") - MEDIUM=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="MEDIUM")] | length' scan-results/trivy-scan-results/trivy-results.json 2>/dev/null || echo "0") - - if [ "$CRITICAL" -gt 0 ]; then - echo "- ❌ Docker Image Scanning: $CRITICAL critical, $HIGH high, $MEDIUM medium vulnerabilities" >> report.md - elif [ "$HIGH" -gt 10 ]; then - echo "- ⚠️ Docker Image Scanning: $HIGH high, $MEDIUM medium vulnerabilities" >> report.md - else - echo "- ✅ Docker Image Scanning: $HIGH high, $MEDIUM medium vulnerabilities (acceptable)" >> report.md - fi - else - echo "- ⚠️ Docker Image Scanning: No results" >> report.md - fi - - echo "" >> report.md - echo "### Recommendations" >> report.md - echo "- Review findings in the GitHub Security tab" >> report.md - echo "- Check artifact uploads for detailed reports" >> report.md - echo "- Address critical Docker vulnerabilities immediately" >> report.md - echo "" >> report.md - echo "---" >> report.md - echo "*Workflow: [${{ github.workflow }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})*" >> report.md - - cat report.md >> $GITHUB_STEP_SUMMARY - - - name: Upload security report - uses: actions/upload-artifact@v7 - with: - name: security-report - path: report.md - retention-days: 90 - - - name: Comment PR - if: github.event_name == 'pull_request' - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - const report = fs.readFileSync('report.md', 'utf8'); - - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const botComment = comments.data.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('Security Scan Results') - ); - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: report - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: report - }); - } - - - name: Check for critical issues - run: | - # Fail if critical security issues found - if grep -q "❌" report.md; then - echo "::error::Critical security issues detected" - exit 1 - fi diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..16bad6d8 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,18 @@ +title = "ProjectKeystone Gitleaks Configuration" + +[extend] +# Use the default Gitleaks ruleset as the base +useDefault = true + +[[allowlists]] +description = "Documentation and example files containing placeholder credentials" +paths = [ + # Kubernetes example manifests — placeholder TLS certs and base64 blobs + "k8s/metrics-security.yaml", + "k8s/secrets.yaml", + # Operations and deployment docs with curl examples using placeholder passwords + "docs/KUBERNETES_DEPLOYMENT.md", + "docs/METRICS_SECURITY.md", + # Skill documentation with example API key patterns + ".claude/skills/quality-security-scan/SKILL.md", +] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index cdce43ff..00000000 --- a/pytest.ini +++ /dev/null @@ -1,6 +0,0 @@ -[pytest] -asyncio_mode = auto -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 97271b21..c7d5cdcf 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -3,7 +3,7 @@ from __future__ import annotations import signal -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -26,82 +26,60 @@ def test_handle_sigterm_raises_system_exit_with_other_signum(self) -> None: class TestMain: def test_main_returns_zero_on_clean_shutdown(self) -> None: - """main() must return 0 when run_routing_loop exits cleanly (returns normally).""" - # run_routing_loop catches KeyboardInterrupt/SystemExit internally and returns - # normally, so main() should then return 0. - with patch("keystone.daemon.run_routing_loop", return_value=None): + """main() must return 0 when run() exits cleanly.""" + with patch("keystone.daemon.run", new_callable=AsyncMock, return_value=0): result = main(["--log-level", "INFO"]) assert result == 0 def test_main_returns_one_on_unexpected_exception(self) -> None: - """main() must return 1 when run_routing_loop raises an unexpected exception.""" + """main() must return 1 when run() raises an unexpected exception.""" with patch( - "keystone.daemon.run_routing_loop", + "keystone.daemon.run", + new_callable=AsyncMock, side_effect=RuntimeError("unexpected failure"), ): result = main(["--log-level", "INFO"]) assert result == 1 def test_main_logs_daemon_started(self, caplog: pytest.LogCaptureFixture) -> None: - """main() must emit a daemon_starting log record before the routing loop runs.""" + """main() must emit a daemon_starting log record before run() is called.""" import logging - with patch("keystone.daemon.run_routing_loop", return_value=None): + with patch("keystone.daemon.run", new_callable=AsyncMock, return_value=0): with caplog.at_level(logging.INFO): main(["--log-level", "INFO"]) messages = " ".join(caplog.messages) assert "daemon_starting" in messages - def test_main_logs_daemon_stopped(self, caplog: pytest.LogCaptureFixture) -> None: - """main() must emit a daemon_stopped log record in the finally block.""" - import logging - - with patch("keystone.daemon.run_routing_loop", return_value=None): - with caplog.at_level(logging.INFO): - main(["--log-level", "INFO"]) - - messages = " ".join(caplog.messages) - assert "daemon_stopped" in messages - def test_main_registers_sigterm_handler(self) -> None: """main() must register handle_sigterm as the SIGTERM signal handler.""" - with patch("keystone.daemon.run_routing_loop", return_value=None): + with patch("keystone.daemon.run", new_callable=AsyncMock, return_value=0): with patch("keystone.daemon.signal.signal") as mock_signal: main(["--log-level", "INFO"]) - mock_signal.assert_called_once_with(signal.SIGTERM, handle_sigterm) + mock_signal.assert_any_call(signal.SIGTERM, handle_sigterm) def test_main_sigterm_path_via_handler(self) -> None: - """Simulates the SIGTERM path: the routing loop catches SystemExit and returns, - then main() completes with return code 0 — matching real SIGTERM behavior.""" - # In production: handle_sigterm raises SystemExit(0), which is caught inside - # run_routing_loop's own try/except block, so run_routing_loop returns normally. - # We simulate this by having the mock return normally (no raise). - with patch("keystone.daemon.run_routing_loop", return_value=None): + """main() completes with return code 0 on clean run() exit.""" + with patch("keystone.daemon.run", new_callable=AsyncMock, return_value=0): result = main(["--log-level", "INFO"]) assert result == 0 def test_main_accepts_debug_log_level(self) -> None: """main() must accept --log-level DEBUG without error.""" - with patch("keystone.daemon.run_routing_loop", return_value=None): + with patch("keystone.daemon.run", new_callable=AsyncMock, return_value=0): result = main(["--log-level", "DEBUG"]) assert result == 0 - def test_main_accepts_poll_interval_arg(self) -> None: - """main() must forward --poll-interval to run_routing_loop.""" - with patch("keystone.daemon.run_routing_loop") as mock_loop: - mock_loop.return_value = None - main(["--poll-interval", "0.1"]) - mock_loop.assert_called_once_with(poll_interval=0.1) - def test_main_daemon_stopped_logged_even_on_error( self, caplog: pytest.LogCaptureFixture ) -> None: - """main() must emit daemon_stopped even when the routing loop raises an exception.""" + """main() must emit daemon_error when run() raises an exception.""" import logging with patch( - "keystone.daemon.run_routing_loop", + "keystone.daemon.run", + new_callable=AsyncMock, side_effect=RuntimeError("crash"), ): with caplog.at_level(logging.INFO): @@ -109,4 +87,4 @@ def test_main_daemon_stopped_logged_even_on_error( assert result == 1 messages = " ".join(caplog.messages) - assert "daemon_stopped" in messages + assert "daemon_error" in messages