From bb94e9c85bf9d08bc123cfefe3c2fc5191080d18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 13 Oct 2026 21:52:25 +0900 Subject: [PATCH 1/2] ci: add isolated central CO security supplier --- .../contextual-orchestrator-security.yml | 341 +++++++++++++++++ ...textual-orchestrator-security-8c8daebf.yml | 345 ++++++++++++++++++ ...ntextual_orchestrator_security_supplier.py | 114 ++++++ 3 files changed, 800 insertions(+) create mode 100644 .github/workflows/contextual-orchestrator-security.yml create mode 100644 tests/fixtures/contextual-orchestrator-security-8c8daebf.yml create mode 100644 tests/test_contextual_orchestrator_security_supplier.py diff --git a/.github/workflows/contextual-orchestrator-security.yml b/.github/workflows/contextual-orchestrator-security.yml new file mode 100644 index 0000000000..9dd9a45fb7 --- /dev/null +++ b/.github/workflows/contextual-orchestrator-security.yml @@ -0,0 +1,341 @@ +# Private source proposal for the existing central workflow owner; not deployed. +# Runner labels are source placement only, not clean-per-job admission evidence. +name: Contextual Orchestrator Security Supplier + +on: + workflow_call: {} + +permissions: + contents: read + +jobs: + tests: + name: Tests and package quality + if: >- + github.repository == 'ContextualWisdomLab/contextual-orchestrator' && (github.event_name != 'schedule' && + (github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false))) + runs-on: + group: CWL CI isolated + labels: [self-hosted, linux, x64, cwlab-ci-isolated] + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up pinned Docker Compose + uses: docker/setup-compose-action@54042514f505b273907334ae2b9cdbb9a0213c1a # docker/setup-compose-action@v2 + with: + version: "v5.4.0" + + - name: Verify Docker Compose CLI + run: docker compose version + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.12.5" + + - name: Set up pinned Rust toolchain + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 + with: + toolchain: "1.97.1" # Keep aligned with mise.toml. + components: rustfmt, clippy + + - name: Build locked native decision measurement + run: | + set -euo pipefail + rustup show active-toolchain + uv sync --locked --extra api --extra db --extra queue --group dev --group native-build + uv run --no-sync maturin develop --locked --release --features pyo3/extension-module --manifest-path rust/decision_receipt/Cargo.toml + uv run --no-sync maturin build --locked --release --features pyo3/extension-module --manifest-path rust/decision_receipt/Cargo.toml --out "$RUNNER_TEMP/decision-wheels" + + - name: Run full test suite + run: uv run --no-sync python -m pytest -q -ra + + - name: Install hash-locked quality tools + run: | + python -m pip install --require-hashes -r requirements.lock + python -m pip install --require-hashes -r requirements-opencode-review-ci.txt + + - name: Prove complete benchmark coverage and public docstrings + run: | + set -euo pipefail + python -m coverage erase + python -m coverage run --branch \ + --source=contextual_orchestrator.nim_benchmark \ + -m pytest \ + tests/test_nim_benchmark.py \ + tests/test_nim_benchmark_release_acceptance.py \ + tests/test_nim_benchmark_workflow_contract.py \ + -q + python -m coverage report \ + --include=contextual_orchestrator/nim_benchmark.py \ + --show-missing \ + --fail-under=100 + python -m interrogate -f 100 contextual_orchestrator/nim_benchmark.py + + - name: Build, install, and import the wheel + run: | + set -euo pipefail + rm -rf dist "$RUNNER_TEMP/nim-wheel-site" + python -m pip wheel --no-deps --no-build-isolation . --wheel-dir dist + python scripts/verify_decision_wheel_manifest.py dist/contextual_orchestrator-*.whl "$RUNNER_TEMP"/decision-wheels/*.whl + python -m pip install --no-deps \ + --target "$RUNNER_TEMP/nim-wheel-site" \ + dist/contextual_orchestrator-*.whl "$RUNNER_TEMP"/decision-wheels/*.whl + cd "$RUNNER_TEMP" + PYTHONPATH="$RUNNER_TEMP/nim-wheel-site" \ + python -c "import pathlib, contextual_orchestrator as core; import contextual_orchestrator.nim_benchmark; import contextual_orchestrator._decision_receipt as native; root = pathlib.Path('nim-wheel-site').resolve(); assert pathlib.Path(core.__file__).is_relative_to(root); assert pathlib.Path(native.__file__).is_relative_to(root)" + PYTHONPATH="$RUNNER_TEMP/nim-wheel-site" \ + python -m pytest --noconftest --import-mode=importlib "$GITHUB_WORKSPACE/tests/test_decision_receipts.py" -q + + fuzz: + name: Property and coverage-guided fuzzing + if: >- + github.repository == 'ContextualWisdomLab/contextual-orchestrator' && ((github.event_name != 'schedule' || github.event.schedule == '41 4 * * 2') && + (github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false))) + runs-on: + group: CWL CI isolated + labels: [self-hosted, linux, x64, cwlab-ci-isolated] + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install fuzz dependencies + run: python -m pip install --require-hashes -r requirements.lock -r fuzz/requirements-property.txt -r fuzz/requirements-atheris.txt + + - name: Run property-based fuzz tests + run: python -m pytest tests/fuzz -q + + - name: Set per-target time budget + env: + EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$EVENT_NAME" = "pull_request" ] || [ "$EVENT_NAME" = "push" ]; then + echo "FUZZ_SECONDS=60" >>"$GITHUB_ENV" + else + echo "FUZZ_SECONDS=300" >>"$GITHUB_ENV" + fi + + - name: Run coverage-guided fuzz targets + run: | + set -euo pipefail + python fuzz/fuzz_request_body.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/request_body + python fuzz/fuzz_agent_config.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/agent_config + python fuzz/fuzz_redaction.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/redaction + python fuzz/fuzz_orchestration.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/orchestration + python fuzz/fuzz_model_judge.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/judge + python fuzz/fuzz_pii_key.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/pii_key + python fuzz/fuzz_reasoning_effort_profile.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/reasoning_effort_profile + python fuzz/fuzz_nim_catalog.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/nim_catalog + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 + with: + name: fuzz-crashes + path: crash-* + if-no-files-found: ignore + + rust: + name: Rust workspace gate + if: >- + github.repository == 'ContextualWisdomLab/contextual-orchestrator' && (github.event_name != 'schedule' && + (github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false))) + runs-on: + group: CWL CI isolated + labels: [self-hosted, linux, x64, cwlab-ci-isolated] + defaults: + run: + working-directory: rust + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + id: python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up pinned Rust toolchain + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 + with: + toolchain: "1.97.1" # Keep aligned with mise.toml. + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Locate relocated Python shared library + id: python-library + run: | + python - <<'PY' + import os + from pathlib import Path + import sys + import sysconfig + directory = Path(sys.base_prefix) / "lib" + library = directory / sysconfig.get_config_var("LDLIBRARY") + if not library.is_file(): + raise SystemExit(f"Python shared library missing: {library}") + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"directory={directory}\n") + PY + + - name: Clippy (deny warnings) + env: + PYO3_PYTHON: ${{ steps.python.outputs.python-path }} + RUSTFLAGS: -L native=${{ steps.python-library.outputs.directory }} + run: cargo clippy --workspace --all-targets --locked -- -D warnings + + - name: Test workspace + env: + PYO3_PYTHON: ${{ steps.python.outputs.python-path }} + RUSTFLAGS: -L native=${{ steps.python-library.outputs.directory }} + run: cargo test --workspace --locked + + - name: Install cargo-audit + run: | + set -euo pipefail + audit_version="0.22.2" + archive="cargo-audit-x86_64-unknown-linux-gnu-v${audit_version}.tgz" + curl -sSLf \ + "https://github.com/rustsec/rustsec/releases/download/cargo-audit%2Fv${audit_version}/${archive}" \ + -o "${RUNNER_TEMP}/${archive}" + tar -xzf "${RUNNER_TEMP}/${archive}" -C "${RUNNER_TEMP}" + mkdir -p "${RUNNER_TEMP}/cargo-audit-bin" + install -m 0755 \ + "${RUNNER_TEMP}/cargo-audit-x86_64-unknown-linux-gnu-v${audit_version}/cargo-audit" \ + "${RUNNER_TEMP}/cargo-audit-bin/cargo-audit" + echo "${RUNNER_TEMP}/cargo-audit-bin" >> "${GITHUB_PATH}" + "${RUNNER_TEMP}/cargo-audit-bin/cargo-audit" --version + + - name: Audit Cargo.lock advisories + run: cargo audit --file Cargo.lock + + security: + name: CodeQL, supply chain, and SBOM + if: >- + github.repository == 'ContextualWisdomLab/contextual-orchestrator' && ((github.event_name != 'schedule' || github.event.schedule == '17 3 * * 1') && + (github.event_name != 'pull_request' || + (github.event.action != 'closed' && + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository))) + runs-on: + group: CWL CI isolated + labels: [self-hosted, linux, x64, cwlab-ci-isolated] + permissions: + actions: read + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # github/codeql-action/init@v4.38.0 + with: + languages: python + build-mode: none + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install audit and project dependencies + run: | + python -m pip install --require-hashes -r requirements-security-ci.txt + python -m pip install --require-hashes -r requirements.lock + python -m pip install --no-deps -e . + + - name: Audit pinned dependencies and generate SBOM + run: | + set -euo pipefail + python -m pip_audit -r requirements.lock + cyclonedx-py environment --output-format json --output-file cyclonedx-sbom.json + + - name: Upload CycloneDX SBOM + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 + with: + name: cyclonedx-sbom + path: cyclonedx-sbom.json + + # Analyze without uploading, then upload with an explicit retry loop. + # Issue #1172: analysis completed and SARIF exported, then the code-scanning + # upload died silently (~17s, no step error line) during a GitHub API 5xx + # window; reruns passed. Keep the gate fail-closed (no continue-on-error). + - name: Run CodeQL analysis + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # github/codeql-action/analyze@v4.38.0 + with: + category: "/language:python" + output: ${{ runner.temp }}/codeql-sarif + upload: never + wait-for-processing: false + + - name: Upload CodeQL SARIF with transient retries + env: + GITHUB_TOKEN: ${{ github.token }} + SARIF_DIR: ${{ runner.temp }}/codeql-sarif + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + GITHUB_REF_NAME: ${{ github.ref }} + GITHUB_SHA_VALUE: ${{ github.sha }} + GITHUB_REPOSITORY_VALUE: ${{ github.repository }} + run: | + set -euo pipefail + sarif="$(find "$SARIF_DIR" -name '*.sarif' -type f | head -n 1)" + if [ -z "$sarif" ] || [ ! -f "$sarif" ]; then + echo "No SARIF produced under $SARIF_DIR" >&2 + ls -la "$SARIF_DIR" >&2 || true + exit 1 + fi + if [ "$EVENT_NAME" = "pull_request" ]; then + ref="refs/pull/${PR_NUMBER}/merge" + sha="$GITHUB_SHA_VALUE" + else + ref="$GITHUB_REF_NAME" + sha="$GITHUB_SHA_VALUE" + fi + echo "Uploading $sarif for $ref @ $sha" + attempt=1 + max_attempts=5 + while [ "$attempt" -le "$max_attempts" ]; do + echo "SARIF upload attempt ${attempt}/${max_attempts}" + if printf '%s' "$GITHUB_TOKEN" | codeql github upload-results \ + --repository="$GITHUB_REPOSITORY_VALUE" \ + --ref="$ref" \ + --commit="$sha" \ + --sarif="$sarif" \ + --github-auth-stdin + then + echo "SARIF upload succeeded on attempt ${attempt}" + exit 0 + else + status=$? + fi + echo "Upload attempt ${attempt} failed with status ${status}" + if [ "$attempt" -eq "$max_attempts" ]; then + exit "$status" + fi + sleep $((attempt * 20)) + attempt=$((attempt + 1)) + done diff --git a/tests/fixtures/contextual-orchestrator-security-8c8daebf.yml b/tests/fixtures/contextual-orchestrator-security-8c8daebf.yml new file mode 100644 index 0000000000..2bfb208e9b --- /dev/null +++ b/tests/fixtures/contextual-orchestrator-security-8c8daebf.yml @@ -0,0 +1,345 @@ +name: Security and Quality + +# One local workflow replaces the former Tests, Fuzz, and Security workflows. +# Central required workflows still own dependency review, Trivy, OSV, +# Scorecard, SAST, OpenCode, Strix, Noema, and merge scheduling. + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] + schedule: + - cron: "17 3 * * 1" + - cron: "41 4 * * 2" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.event.schedule || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + tests: + name: Tests and package quality + if: >- + github.event_name != 'schedule' && + (github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false)) + runs-on: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && fromJSON('["self-hosted","linux","x64","cwlab"]') || 'ubuntu-24.04' }} + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up pinned Docker Compose + uses: docker/setup-compose-action@54042514f505b273907334ae2b9cdbb9a0213c1a # docker/setup-compose-action@v2 + with: + version: "v5.4.0" + + - name: Verify Docker Compose CLI + run: docker compose version + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.12.5" + + - name: Set up pinned Rust toolchain + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 + with: + toolchain: "1.97.1" # Keep aligned with mise.toml. + components: rustfmt, clippy + + - name: Build locked native decision measurement + run: | + set -euo pipefail + rustup show active-toolchain + uv sync --locked --extra api --extra db --extra queue --group dev --group native-build + uv run --no-sync maturin develop --locked --release --features pyo3/extension-module --manifest-path rust/decision_receipt/Cargo.toml + uv run --no-sync maturin build --locked --release --features pyo3/extension-module --manifest-path rust/decision_receipt/Cargo.toml --out "$RUNNER_TEMP/decision-wheels" + + - name: Run full test suite + run: uv run --no-sync python -m pytest -q -ra + + - name: Install hash-locked quality tools + run: | + python -m pip install --require-hashes -r requirements.lock + python -m pip install --require-hashes -r requirements-opencode-review-ci.txt + + - name: Prove complete benchmark coverage and public docstrings + run: | + set -euo pipefail + python -m coverage erase + python -m coverage run --branch \ + --source=contextual_orchestrator.nim_benchmark \ + -m pytest \ + tests/test_nim_benchmark.py \ + tests/test_nim_benchmark_release_acceptance.py \ + tests/test_nim_benchmark_workflow_contract.py \ + -q + python -m coverage report \ + --include=contextual_orchestrator/nim_benchmark.py \ + --show-missing \ + --fail-under=100 + python -m interrogate -f 100 contextual_orchestrator/nim_benchmark.py + + - name: Build, install, and import the wheel + run: | + set -euo pipefail + rm -rf dist "$RUNNER_TEMP/nim-wheel-site" + python -m pip wheel --no-deps --no-build-isolation . --wheel-dir dist + python scripts/verify_decision_wheel_manifest.py dist/contextual_orchestrator-*.whl "$RUNNER_TEMP"/decision-wheels/*.whl + python -m pip install --no-deps \ + --target "$RUNNER_TEMP/nim-wheel-site" \ + dist/contextual_orchestrator-*.whl "$RUNNER_TEMP"/decision-wheels/*.whl + cd "$RUNNER_TEMP" + PYTHONPATH="$RUNNER_TEMP/nim-wheel-site" \ + python -c "import pathlib, contextual_orchestrator as core; import contextual_orchestrator.nim_benchmark; import contextual_orchestrator._decision_receipt as native; root = pathlib.Path('nim-wheel-site').resolve(); assert pathlib.Path(core.__file__).is_relative_to(root); assert pathlib.Path(native.__file__).is_relative_to(root)" + PYTHONPATH="$RUNNER_TEMP/nim-wheel-site" \ + python -m pytest --noconftest --import-mode=importlib "$GITHUB_WORKSPACE/tests/test_decision_receipts.py" -q + + fuzz: + name: Property and coverage-guided fuzzing + if: >- + (github.event_name != 'schedule' || github.event.schedule == '41 4 * * 2') && + (github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false)) + runs-on: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && fromJSON('["self-hosted","linux","x64","cwlab"]') || 'ubuntu-24.04' }} + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install fuzz dependencies + run: python -m pip install --require-hashes -r requirements.lock -r fuzz/requirements-property.txt -r fuzz/requirements-atheris.txt + + - name: Run property-based fuzz tests + run: python -m pytest tests/fuzz -q + + - name: Set per-target time budget + env: + EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$EVENT_NAME" = "pull_request" ] || [ "$EVENT_NAME" = "push" ]; then + echo "FUZZ_SECONDS=60" >>"$GITHUB_ENV" + else + echo "FUZZ_SECONDS=300" >>"$GITHUB_ENV" + fi + + - name: Run coverage-guided fuzz targets + run: | + set -euo pipefail + python fuzz/fuzz_request_body.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/request_body + python fuzz/fuzz_agent_config.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/agent_config + python fuzz/fuzz_redaction.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/redaction + python fuzz/fuzz_orchestration.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/orchestration + python fuzz/fuzz_model_judge.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/judge + python fuzz/fuzz_pii_key.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/pii_key + python fuzz/fuzz_reasoning_effort_profile.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/reasoning_effort_profile + python fuzz/fuzz_nim_catalog.py -max_total_time="${FUZZ_SECONDS}" -artifact_prefix=crash- fuzz/corpus/nim_catalog + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 + with: + name: fuzz-crashes + path: crash-* + if-no-files-found: ignore + + rust: + name: Rust workspace gate + if: >- + github.event_name != 'schedule' && + (github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false)) + runs-on: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && fromJSON('["self-hosted","linux","x64","cwlab"]') || 'ubuntu-24.04' }} + defaults: + run: + working-directory: rust + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + id: python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up pinned Rust toolchain + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 + with: + toolchain: "1.97.1" # Keep aligned with mise.toml. + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Locate relocated Python shared library + id: python-library + run: | + python - <<'PY' + import os + from pathlib import Path + import sys + import sysconfig + directory = Path(sys.base_prefix) / "lib" + library = directory / sysconfig.get_config_var("LDLIBRARY") + if not library.is_file(): + raise SystemExit(f"Python shared library missing: {library}") + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"directory={directory}\n") + PY + + - name: Clippy (deny warnings) + env: + PYO3_PYTHON: ${{ steps.python.outputs.python-path }} + RUSTFLAGS: -L native=${{ steps.python-library.outputs.directory }} + run: cargo clippy --workspace --all-targets --locked -- -D warnings + + - name: Test workspace + env: + PYO3_PYTHON: ${{ steps.python.outputs.python-path }} + RUSTFLAGS: -L native=${{ steps.python-library.outputs.directory }} + run: cargo test --workspace --locked + + - name: Install cargo-audit + run: | + set -euo pipefail + audit_version="0.22.2" + archive="cargo-audit-x86_64-unknown-linux-gnu-v${audit_version}.tgz" + curl -sSLf \ + "https://github.com/rustsec/rustsec/releases/download/cargo-audit%2Fv${audit_version}/${archive}" \ + -o "${RUNNER_TEMP}/${archive}" + tar -xzf "${RUNNER_TEMP}/${archive}" -C "${RUNNER_TEMP}" + mkdir -p "${RUNNER_TEMP}/cargo-audit-bin" + install -m 0755 \ + "${RUNNER_TEMP}/cargo-audit-x86_64-unknown-linux-gnu-v${audit_version}/cargo-audit" \ + "${RUNNER_TEMP}/cargo-audit-bin/cargo-audit" + echo "${RUNNER_TEMP}/cargo-audit-bin" >> "${GITHUB_PATH}" + "${RUNNER_TEMP}/cargo-audit-bin/cargo-audit" --version + + - name: Audit Cargo.lock advisories + run: cargo audit --file Cargo.lock + + security: + name: CodeQL, supply chain, and SBOM + if: >- + (github.event_name != 'schedule' || github.event.schedule == '17 3 * * 1') && + (github.event_name != 'pull_request' || + (github.event.action != 'closed' && + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository)) + runs-on: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && fromJSON('["self-hosted","linux","x64","cwlab"]') || 'ubuntu-24.04' }} + permissions: + actions: read + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # github/codeql-action/init@v4.38.0 + with: + languages: python + build-mode: none + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install audit and project dependencies + run: | + python -m pip install --require-hashes -r requirements-security-ci.txt + python -m pip install --require-hashes -r requirements.lock + python -m pip install --no-deps -e . + + - name: Audit pinned dependencies and generate SBOM + run: | + set -euo pipefail + python -m pip_audit -r requirements.lock + cyclonedx-py environment --output-format json --output-file cyclonedx-sbom.json + + - name: Upload CycloneDX SBOM + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 + with: + name: cyclonedx-sbom + path: cyclonedx-sbom.json + + # Analyze without uploading, then upload with an explicit retry loop. + # Issue #1172: analysis completed and SARIF exported, then the code-scanning + # upload died silently (~17s, no step error line) during a GitHub API 5xx + # window; reruns passed. Keep the gate fail-closed (no continue-on-error). + - name: Run CodeQL analysis + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # github/codeql-action/analyze@v4.38.0 + with: + category: "/language:python" + output: ${{ runner.temp }}/codeql-sarif + upload: never + wait-for-processing: false + + - name: Upload CodeQL SARIF with transient retries + env: + GITHUB_TOKEN: ${{ github.token }} + SARIF_DIR: ${{ runner.temp }}/codeql-sarif + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + GITHUB_REF_NAME: ${{ github.ref }} + GITHUB_SHA_VALUE: ${{ github.sha }} + GITHUB_REPOSITORY_VALUE: ${{ github.repository }} + run: | + set -euo pipefail + sarif="$(find "$SARIF_DIR" -name '*.sarif' -type f | head -n 1)" + if [ -z "$sarif" ] || [ ! -f "$sarif" ]; then + echo "No SARIF produced under $SARIF_DIR" >&2 + ls -la "$SARIF_DIR" >&2 || true + exit 1 + fi + if [ "$EVENT_NAME" = "pull_request" ]; then + ref="refs/pull/${PR_NUMBER}/merge" + sha="$GITHUB_SHA_VALUE" + else + ref="$GITHUB_REF_NAME" + sha="$GITHUB_SHA_VALUE" + fi + echo "Uploading $sarif for $ref @ $sha" + attempt=1 + max_attempts=5 + while [ "$attempt" -le "$max_attempts" ]; do + echo "SARIF upload attempt ${attempt}/${max_attempts}" + if printf '%s' "$GITHUB_TOKEN" | codeql github upload-results \ + --repository="$GITHUB_REPOSITORY_VALUE" \ + --ref="$ref" \ + --commit="$sha" \ + --sarif="$sarif" \ + --github-auth-stdin + then + echo "SARIF upload succeeded on attempt ${attempt}" + exit 0 + fi + status=$? + echo "Upload attempt ${attempt} failed with status ${status}" + if [ "$attempt" -eq "$max_attempts" ]; then + exit "$status" + fi + sleep $((attempt * 20)) + attempt=$((attempt + 1)) + done diff --git a/tests/test_contextual_orchestrator_security_supplier.py b/tests/test_contextual_orchestrator_security_supplier.py new file mode 100644 index 0000000000..8e6dfe1ef9 --- /dev/null +++ b/tests/test_contextual_orchestrator_security_supplier.py @@ -0,0 +1,114 @@ +"""Source-only contracts for the existing central owner's proposed CO supplier. + +No Actions dispatch or timing assertion; all commands below inspect private bytes. +""" +from copy import deepcopy +from pathlib import Path +import re +import subprocess +import shutil +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / '.github/workflows/contextual-orchestrator-security.yml' +BASE = Path(__file__).with_name('fixtures') / 'contextual-orchestrator-security-8c8daebf.yml' + +class Loader(yaml.SafeLoader): + """Use YAML 1.2 Boolean spelling without mutating ordinary SafeLoader.""" + +Loader.yaml_implicit_resolvers = { + key: [(tag, rx) for tag, rx in values if tag != 'tag:yaml.org,2002:bool'] + for key, values in yaml.SafeLoader.yaml_implicit_resolvers.items() +} +Loader.add_implicit_resolver('tag:yaml.org,2002:bool', re.compile(r'^(?:true|false|True|False|TRUE|FALSE)$'), list('tTfF')) + +def load(path): + return yaml.load(path.read_text(), Loader=Loader) + +def assert_supplier(current): + """Reject placement, admission, permission or executable workload drift.""" + base = load(BASE) + assert current['on'] == {'workflow_call': {}}, 'missing reusable callee' + assert current['permissions'] == base['permissions'] + assert 'concurrency' not in current, 'caller owns unchanged concurrency' + assert list(current['jobs']) == ['tests', 'fuzz', 'rust', 'security'] + for name, old in base['jobs'].items(): + job = current['jobs'][name] + assert job['runs-on'] == {'group':'CWL CI isolated', 'labels':['self-hosted','linux','x64','cwlab-ci-isolated']} + assert job['if'] == "github.repository == 'ContextualWisdomLab/contextual-orchestrator' && (" + old['if'] + ')' + for key in ('name','permissions','defaults'): + assert job.get(key) == old.get(key) + expected_steps = deepcopy(old['steps']) + if name == 'security': + upload = next(s for s in expected_steps if s['name'] == 'Upload CodeQL SARIF with transient retries') + upload['run'] = upload['run'].replace( + ' exit 0\n fi\n status=$?\n', + ' exit 0\n else\n status=$?\n fi\n') + assert job['steps'] == expected_steps + assert 'secrets' not in current['on']['workflow_call'] + assert not any('secrets' in j for j in current['jobs'].values()) + + +def test_fixed_caller_supplier_keeps_every_security_workload(): + """Centralization preserves the complete executable four-job source.""" + assert_supplier(load(WORKFLOW)) + + +@pytest.mark.parametrize('mutation', ['label', 'privileged', 'hosted', 'workload', + 'secrets', 'caller', 'permission', 'tolerant']) +def test_supplier_contract_rejects_otherwise_valid_drift(mutation): + """Sensitivity controls for the shared actual-source oracle, not CI admission.""" + current = deepcopy(load(WORKFLOW)) + job = current['jobs']['tests'] + if mutation == 'label': + job['runs-on']['labels'].remove('cwlab-ci-isolated') + elif mutation == 'privileged': + job['runs-on']['group'] = 'CWL central control' + elif mutation == 'hosted': + job['runs-on'] = 'ubuntu-24.04' + elif mutation == 'workload': + next(s for s in job['steps'] if s['name'] == 'Run full test suite')['run'] = 'echo omitted' + elif mutation == 'secrets': + current['on']['workflow_call']['secrets'] = {'KEY':{'required':True}} + elif mutation == 'caller': + job['if'] = load(BASE)['jobs']['tests']['if'] + elif mutation == 'permission': + job['permissions'] = {'contents':'write'} + elif mutation == 'tolerant': + job['steps'][0]['continue-on-error'] = True + with pytest.raises(AssertionError): + assert_supplier(current) + + +@pytest.mark.parametrize('statuses,expected_calls,expected_exit', [ + ('19', 5, 19), ('0', 1, 0), ('19,19,0', 3, 0)]) +def test_sarif_upload_terminal_status(tmp_path, statuses, expected_calls, expected_exit): + """Execute the actual uploader with synthetic command adapters, no network.""" + workflow = load(WORKFLOW) + step = next(s for s in workflow['jobs']['security']['steps'] + if s['name'] == 'Upload CodeQL SARIF with transient retries') + bin_dir = tmp_path / 'bin' + bin_dir.mkdir() + log = tmp_path / 'calls' + status_values = statuses.split(',') + cases = '\n'.join(f'{i}) exit {status} ;;' for i, status in enumerate(status_values, 1)) + codeql = 'printf "attempt\\n" >> "$CALL_LOG"\ncase $(wc -l < "$CALL_LOG" | tr -d " ") in\n' + cases + '\n*) exit 19 ;;\nesac' + for name, body in [('codeql', codeql), ('sleep', 'exit 0')]: + path = bin_dir / name + path.write_text('#!/bin/sh\n' + body + '\n') + path.chmod(0o755) + sarif_dir = tmp_path / 'sarif' + sarif_dir.mkdir() + (sarif_dir / 'python.sarif').write_text('{"runs":[]}') + bash = shutil.which('bash') + assert bash, 'Bash is mandatory for workflow source contracts' + result = subprocess.run([bash, '-c', step['run']], + cwd=tmp_path, text=True, capture_output=True, timeout=30, + env={'PATH': str(bin_dir) + ':/usr/bin:/bin', 'SARIF_DIR':str(sarif_dir), + 'CALL_LOG':str(log), 'GITHUB_TOKEN':'synthetic-fixture-token', + 'EVENT_NAME':'push','PR_NUMBER':'','GITHUB_REF_NAME':'refs/heads/main', + 'GITHUB_SHA_VALUE':'a'*40, + 'GITHUB_REPOSITORY_VALUE':'ContextualWisdomLab/contextual-orchestrator'}) + assert log.read_text().splitlines() == ['attempt'] * expected_calls + assert result.returncode == expected_exit, result.stdout + result.stderr From 19490ad895e94a9e3700dd5c0006cb24a73039c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 13 Oct 2026 22:53:57 +0900 Subject: [PATCH 2/2] fix(ci): verify cargo-audit release digest before execution --- .../contextual-orchestrator-security.yml | 2 + tests/test_cargo_audit_digest.py | 84 +++++++++++++++++++ ...ntextual_orchestrator_security_supplier.py | 23 ++++- 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/test_cargo_audit_digest.py diff --git a/.github/workflows/contextual-orchestrator-security.yml b/.github/workflows/contextual-orchestrator-security.yml index 9dd9a45fb7..3bd61b73de 100644 --- a/.github/workflows/contextual-orchestrator-security.yml +++ b/.github/workflows/contextual-orchestrator-security.yml @@ -218,6 +218,8 @@ jobs: curl -sSLf \ "https://github.com/rustsec/rustsec/releases/download/cargo-audit%2Fv${audit_version}/${archive}" \ -o "${RUNNER_TEMP}/${archive}" + audit_sha256="ab28a1bdb54db4d5d8ad5981cf1f959410370b3d28250dbd35f6a44248620e39" + printf '%s %s\n' "$audit_sha256" "${RUNNER_TEMP}/${archive}" | sha256sum --check --strict - tar -xzf "${RUNNER_TEMP}/${archive}" -C "${RUNNER_TEMP}" mkdir -p "${RUNNER_TEMP}/cargo-audit-bin" install -m 0755 \ diff --git a/tests/test_cargo_audit_digest.py b/tests/test_cargo_audit_digest.py new file mode 100644 index 0000000000..c50d2f18d7 --- /dev/null +++ b/tests/test_cargo_audit_digest.py @@ -0,0 +1,84 @@ +"""Offline execution of the actual installer; only download bytes are synthetic.""" +import hashlib +import io +import os +from pathlib import Path +import shutil +import subprocess +import tarfile + +import pytest + +from tests.test_contextual_orchestrator_security_supplier import load, WORKFLOW + +DIGEST = 'ab28a1bdb54db4d5d8ad5981cf1f959410370b3d28250dbd35f6a44248620e39' + + +def installer(): + return next(s['run'] for s in load(WORKFLOW)['jobs']['rust']['steps'] + if s['name'] == 'Install cargo-audit') + + +def run_installer(tmp_path, script, valid=False): + """Use real SHA256/tar/install and an inert fixture executable, never releases.""" + archive = tmp_path / 'fixture.tgz' + body = b'#!/bin/sh\nprintf "exec\\n" >> "$CALL_LOG"\n' + with tarfile.open(archive, 'w:gz') as tar: + member = tarfile.TarInfo('cargo-audit-x86_64-unknown-linux-gnu-v0.22.2/cargo-audit') + member.mode = 0o755 + member.size = len(body) + tar.addfile(member, io.BytesIO(body)) + if valid: + script = script.replace(DIGEST, hashlib.sha256(archive.read_bytes()).hexdigest()) + bin_dir = tmp_path / 'bin' + bin_dir.mkdir() + for name in ('tar', 'install'): + native = shutil.which(name) + assert native + wrapper = bin_dir / name + wrapper.write_text(f'#!/bin/sh\nprintf "{name}\\n" >> "$CALL_LOG"\nexec "{native}" "$@"\n') + wrapper.chmod(0o755) + sha = shutil.which('sha256sum') + assert sha, 'native sha256sum required' + (bin_dir / 'sha256sum').symlink_to(sha) + curl = bin_dir / 'curl' + curl.write_text('#!/bin/sh\nwhile [ "$#" -gt 0 ]; do\n if [ "$1" = -o ]; then cp "$FIXTURE_ARCHIVE" "$2"; exit $?; fi\n shift\ndone\nexit 2\n') + curl.chmod(0o755) + runner = tmp_path / 'runner' + runner.mkdir() + log = tmp_path / 'calls' + bash = shutil.which('bash') + assert bash + result = subprocess.run([bash, '-c', script], cwd=tmp_path, + env={'PATH': str(bin_dir) + ':/usr/bin:/bin', + 'RUNNER_TEMP': str(runner), 'GITHUB_PATH': str(tmp_path / 'path'), + 'FIXTURE_ARCHIVE': str(archive), 'CALL_LOG': str(log)}, + text=True, capture_output=True, timeout=30) + calls = log.read_text().splitlines() if log.exists() else [] + return result, calls + + +def test_checksum_mismatch_blocks_every_consumer(tmp_path): + result, calls = run_installer(tmp_path, installer()) + assert result.returncode != 0, result.stdout + result.stderr + assert calls == [], 'mismatch must cause zero tar/install/execution' + assert not (tmp_path / 'path').exists() + + +def test_valid_archive_reaches_real_tar_install_and_fixture_exec(tmp_path): + result, calls = run_installer(tmp_path, installer(), valid=True) + assert result.returncode == 0, result.stdout + result.stderr + assert calls == ['tar', 'install', 'exec'] + assert (tmp_path / 'path').read_text().strip() == str(tmp_path / 'runner/cargo-audit-bin') + + +@pytest.mark.parametrize('mutation', ['missing_check', 'late_check']) +def test_shell_negative_controls_detect_unverified_consumption(tmp_path, mutation): + lines = installer().splitlines(keepends=True) + check = next(line for line in lines if 'sha256sum --check' in line) + lines.remove(check) + if mutation == 'late_check': + lines.append(check) + result, calls = run_installer(tmp_path, ''.join(lines)) + assert calls == ['tar', 'install', 'exec'], 'fixture must detect premature consumption' + assert result.returncode == (0 if mutation == 'missing_check' else 1) diff --git a/tests/test_contextual_orchestrator_security_supplier.py b/tests/test_contextual_orchestrator_security_supplier.py index 8e6dfe1ef9..329b020ff7 100644 --- a/tests/test_contextual_orchestrator_security_supplier.py +++ b/tests/test_contextual_orchestrator_security_supplier.py @@ -40,6 +40,15 @@ def assert_supplier(current): for key in ('name','permissions','defaults'): assert job.get(key) == old.get(key) expected_steps = deepcopy(old['steps']) + # Exactly two intentional baseline workload repairs: SARIF status and + # official 0.22.2 release-asset SHA256 admission before extraction. + if name == 'rust': + audit = next(s for s in expected_steps if s['name'] == 'Install cargo-audit') + audit['run'] = audit['run'].replace( + 'tar -xzf', + 'audit_sha256="ab28a1bdb54db4d5d8ad5981cf1f959410370b3d28250dbd35f6a44248620e39"\n' + 'printf \'%s %s\\n\' "$audit_sha256" "${RUNNER_TEMP}/${archive}" | sha256sum --check --strict -\n' + 'tar -xzf', 1) if name == 'security': upload = next(s for s in expected_steps if s['name'] == 'Upload CodeQL SARIF with transient retries') upload['run'] = upload['run'].replace( @@ -56,7 +65,8 @@ def test_fixed_caller_supplier_keeps_every_security_workload(): @pytest.mark.parametrize('mutation', ['label', 'privileged', 'hosted', 'workload', - 'secrets', 'caller', 'permission', 'tolerant']) + 'secrets', 'caller', 'permission', 'tolerant', + 'audit_hash', 'audit_order', 'audit_missing_check']) def test_supplier_contract_rejects_otherwise_valid_drift(mutation): """Sensitivity controls for the shared actual-source oracle, not CI admission.""" current = deepcopy(load(WORKFLOW)) @@ -77,6 +87,17 @@ def test_supplier_contract_rejects_otherwise_valid_drift(mutation): job['permissions'] = {'contents':'write'} elif mutation == 'tolerant': job['steps'][0]['continue-on-error'] = True + elif mutation.startswith('audit_'): + audit = next(s for s in current['jobs']['rust']['steps'] if s['name'] == 'Install cargo-audit') + lines = audit['run'].splitlines(keepends=True) + if mutation == 'audit_hash': + audit['run'] = audit['run'].replace('ab28a1bd', '00000000') + else: + check = next(line for line in lines if 'sha256sum --check' in line) + lines.remove(check) + if mutation == 'audit_order': + lines.append(check) + audit['run'] = ''.join(lines) with pytest.raises(AssertionError): assert_supplier(current)