diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 339ff99..2de7121 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,7 @@ jobs: - name: Run Entry Check env: GH_TOKEN: ${{ github.token }} + CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN: ${{ secrets.CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN }} run: bash -euo pipefail tools/ci/bin/run.sh preflight - name: Upload Artifact if: always() diff --git a/.github/workflows/codeql-default-setup-guardrail.yml b/.github/workflows/codeql-default-setup-guardrail.yml new file mode 100644 index 0000000..620e99f --- /dev/null +++ b/.github/workflows/codeql-default-setup-guardrail.yml @@ -0,0 +1,45 @@ +name: codeql-default-setup-guardrail + +on: + schedule: + # Daily drift detection for security settings that cannot be protected by branch rules. + - cron: "17 4 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: codeql-default-setup-guardrail + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + guardrail: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Read CodeQL Default Setup State + id: state + env: + GH_TOKEN: ${{ github.token }} + CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN: ${{ secrets.CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN }} + run: bash tools/ci/codeql-default-setup-guardrail/get_state.sh + + - name: Open Issue On Drift (idempotent) + if: steps.state.outputs.drift == 'true' + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN: ${{ secrets.CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN }} + run: bash tools/ci/codeql-default-setup-guardrail/ensure_issue.sh + + - name: Fail Run On Drift (fail-closed) + if: steps.state.outputs.drift == 'true' + run: exit 1 diff --git a/docs/security/010_CODEQL_DEFAULT_SETUP_GUARDRAIL.MD b/docs/security/010_CODEQL_DEFAULT_SETUP_GUARDRAIL.MD new file mode 100644 index 0000000..9ac1f64 --- /dev/null +++ b/docs/security/010_CODEQL_DEFAULT_SETUP_GUARDRAIL.MD @@ -0,0 +1,30 @@ +# CodeQL Default Setup Guardrail + +## Ziel +Dieses Repository nutzt ein **CodeQL Advanced Setup** (`.github/workflows/codeql.yml`) fuer C# (build-mode `manual`). +GitHub **CodeQL Default Setup** muss deshalb **deaktiviert** sein (`state=not-configured`), sonst werden Advanced-SARIF Uploads abgelehnt. + +## Verifikation +```bash +gh api repos/{owner}/{repo}/code-scanning/default-setup --jq '{state,updated_at,schedule}' +``` + +Erwartung: +- `state: "not-configured"` + +## Guardrails +- Merge-Gate (fail-closed): `tools/ci/check-codeql-default-setup.sh` ist Teil von `preflight`. +- Drift Detection: `.github/workflows/codeql-default-setup-guardrail.yml` laeuft taeglich und erstellt bei Drift ein Issue. + +## Token / Permissions (wichtig) +Der GitHub API Endpoint `GET /repos/{owner}/{repo}/code-scanning/default-setup` ist in GitHub Actions mit dem standardmaessigen `GITHUB_TOKEN` in diesem Repo nicht erreichbar (typisch: `HTTP 403 Resource not accessible by integration`). + +Damit der Guardrail in PR-CI und im scheduled Workflow deterministisch funktioniert, ist ein Fine-Grained PAT als Repo-Secret erforderlich: +- Secret-Name: `CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN` +- Minimalrechte (Repository permissions): + - Administration: Read-only + - Security events: Read-only + +Wiring: +- PR-CI: `.github/workflows/ci.yml` setzt `CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN` als env fuer `preflight`. +- Scheduled: `.github/workflows/codeql-default-setup-guardrail.yml` nutzt `CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN`, falls vorhanden. diff --git a/tools/ci/bin/run.sh b/tools/ci/bin/run.sh index e19635b..4b5d57b 100755 --- a/tools/ci/bin/run.sh +++ b/tools/ci/bin/run.sh @@ -179,6 +179,7 @@ run_preflight() { run_or_fail "CI-PREFLIGHT-001" "Label engine tests" node "${ROOT_DIR}/tools/versioning/test-compute-pr-labels.js" run_or_fail "CI-PREFLIGHT-001" "PR governance checks" bash "${ROOT_DIR}/tools/ci/check-pr-governance.sh" run_or_fail "CI-PREFLIGHT-001" "Code scanning tools open alerts must be zero" bash "${ROOT_DIR}/tools/ci/check-code-scanning-tools-zero.sh" + run_or_fail "CI-CODEQL-001" "CodeQL default setup must be not-configured" bash "${ROOT_DIR}/tools/ci/check-codeql-default-setup.sh" run_or_fail "CI-PREFLIGHT-001" "Doc consistency drift guard" python3 "${ROOT_DIR}/tools/check-doc-consistency.py" run_or_fail "CI-PREFLIGHT-001" "Doc shell compatibility guard" python3 "${ROOT_DIR}/tools/check-doc-shell-compat.py" run_or_fail "CI-PREFLIGHT-001" "Docs checker unit tests" python3 -m unittest discover -s "${ROOT_DIR}/tools/tests" -p "test_*.py" -v diff --git a/tools/ci/check-codeql-default-setup.sh b/tools/ci/check-codeql-default-setup.sh new file mode 100755 index 0000000..50f5806 --- /dev/null +++ b/tools/ci/check-codeql-default-setup.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +export LC_ALL=C + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT_DIR="${ROOT_DIR}/artifacts/ci/preflight/codeql-default-setup-guardrail" +RAW_LOG="${OUT_DIR}/raw.log" +SUMMARY_MD="${OUT_DIR}/summary.md" +RESULT_JSON="${OUT_DIR}/result.json" +DEFAULT_SETUP_JSON="${OUT_DIR}/default-setup.json" + +mkdir -p "${OUT_DIR}" +: > "${RAW_LOG}" + +BASE_GH_TOKEN="${GH_TOKEN:-}" +CODEQL_TOKEN="${CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN:-}" + +log() { + printf '%s\n' "$*" | tee -a "${RAW_LOG}" >/dev/null +} + +fail() { + local reason="$1" + log "FAIL: ${reason}" + { + echo "# CodeQL Default Setup Guardrail" + echo + echo "- status: fail" + echo "- reason: ${reason}" + } > "${SUMMARY_MD}" + if [[ -f "${DEFAULT_SETUP_JSON}" ]]; then + jq -n --arg reason "${reason}" \ + '{schema_version:1,check_id:"codeql-default-setup-guardrail",status:"fail",reason:$reason,evidence_paths:["artifacts/ci/preflight/codeql-default-setup-guardrail/raw.log","artifacts/ci/preflight/codeql-default-setup-guardrail/default-setup.json","artifacts/ci/preflight/codeql-default-setup-guardrail/summary.md"]}' \ + > "${RESULT_JSON}" + else + jq -n --arg reason "${reason}" \ + '{schema_version:1,check_id:"codeql-default-setup-guardrail",status:"fail",reason:$reason,evidence_paths:["artifacts/ci/preflight/codeql-default-setup-guardrail/raw.log","artifacts/ci/preflight/codeql-default-setup-guardrail/summary.md"]}' \ + > "${RESULT_JSON}" + fi + exit 1 +} + +if ! command -v gh >/dev/null 2>&1; then + fail "gh fehlt" +fi +if ! command -v jq >/dev/null 2>&1; then + fail "jq fehlt" +fi + +REPO="${GITHUB_REPOSITORY:-}" +if [[ -z "${REPO}" ]]; then + origin_url="$(git -C "${ROOT_DIR}" remote get-url origin 2>/dev/null || true)" + if [[ "${origin_url}" =~ github.com[:/]([^/]+/[^/.]+)(\.git)?$ ]]; then + REPO="${BASH_REMATCH[1]}" + fi +fi +if [[ -z "${REPO}" ]]; then + fail "Repository-Slug konnte nicht bestimmt werden" +fi +if [[ ! "${REPO}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + fail "Ungueltiger Repository-Slug: '${REPO}' (erwartet owner/name)" +fi + +gh_api() { + local -r endpoint="$1"; shift + if [[ -n "${CODEQL_TOKEN}" ]]; then + GH_TOKEN="${CODEQL_TOKEN}" GH_REPO="${REPO}" gh api "${endpoint}" "$@" + else + GH_REPO="${REPO}" gh api "${endpoint}" "$@" + fi +} + +attempt=1 +delay=2 +max_attempts=3 +while true; do + if gh_api "repos/{owner}/{repo}/code-scanning/default-setup" > "${DEFAULT_SETUP_JSON}" 2>> "${RAW_LOG}"; then + break + fi + if (( attempt >= max_attempts )); then + if grep -qF "Resource not accessible by integration (HTTP 403)" "${RAW_LOG}"; then + fail "GitHub API 403 fuer CodeQL Default Setup. GITHUB_TOKEN reicht hier nicht aus; setze Secret CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN (Fine-Grained PAT, Repo: Administration Read, Security Events Read)." + fi + fail "GitHub API fuer CodeQL Default Setup fehlgeschlagen" + fi + log "WARN: API-Fehler, retry ${attempt}/${max_attempts}" + sleep "${delay}" + attempt=$((attempt + 1)) + delay=$((delay * 2)) +done + +state="$(jq -r '.state // empty' "${DEFAULT_SETUP_JSON}")" +if [[ -z "${state}" ]]; then + fail "Ungueltige API-Antwort: state fehlt" +fi + +if [[ "${state}" != "not-configured" ]]; then + fail "CodeQL Default Setup ist aktiv (state=${state}). Advanced-Setup erfordert state=not-configured." +fi + +{ + echo "# CodeQL Default Setup Guardrail" + echo + echo "- status: pass" + echo "- repo: ${REPO}" + echo "- state: ${state}" +} > "${SUMMARY_MD}" + +jq -n \ + '{schema_version:1,check_id:"codeql-default-setup-guardrail",status:"pass",state:"not-configured",evidence_paths:["artifacts/ci/preflight/codeql-default-setup-guardrail/raw.log","artifacts/ci/preflight/codeql-default-setup-guardrail/default-setup.json","artifacts/ci/preflight/codeql-default-setup-guardrail/summary.md"]}' \ + > "${RESULT_JSON}" + +log "PASS: CodeQL Default Setup ist not-configured." diff --git a/tools/ci/codeql-default-setup-guardrail/ensure_issue.sh b/tools/ci/codeql-default-setup-guardrail/ensure_issue.sh new file mode 100755 index 0000000..ec8ae5f --- /dev/null +++ b/tools/ci/codeql-default-setup-guardrail/ensure_issue.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +export LC_ALL=C + +repo="${GITHUB_REPOSITORY:-${REPO:-}}" +if [[ -z "${repo}" ]]; then + echo "ERROR: missing GITHUB_REPOSITORY/REPO env (expected owner/name)." >&2 + exit 2 +fi +if [[ ! "${repo}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "ERROR: invalid repo slug: '${repo}' (expected owner/name)." >&2 + exit 2 +fi + +CODEQL_TOKEN="${CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN:-}" + +retry() { + local -r max="${1}"; shift + local -r base_sleep="${1}"; shift + local attempt=1 + local sleep_s="${base_sleep}" + while true; do + if "$@"; then + return 0 + fi + if [[ "${attempt}" -ge "${max}" ]]; then + return 1 + fi + echo "WARN: command failed (attempt ${attempt}/${max}); retrying in ${sleep_s}s..." >&2 + sleep "${sleep_s}" + attempt="$((attempt + 1))" + sleep_s="$((sleep_s * 2))" + done +} + +gh_api() { + local -r endpoint="$1"; shift + if [[ -n "${CODEQL_TOKEN}" ]]; then + GH_TOKEN="${CODEQL_TOKEN}" GH_REPO="${repo}" gh api "${endpoint}" "$@" + else + GH_REPO="${repo}" gh api "${endpoint}" "$@" + fi +} + +state="" +if ! state="$(retry 4 1 gh_api "repos/{owner}/{repo}/code-scanning/default-setup" --jq .state)"; then + echo "ERROR: failed to query CodeQL default-setup state via GitHub API." >&2 + exit 3 +fi +if [[ -z "${state}" ]]; then + echo "ERROR: invalid API response (missing state)." >&2 + exit 3 +fi + +if [[ "${state}" == "not-configured" ]]; then + echo "INFO: state is not-configured; no drift issue required." + exit 0 +fi + +title="SECURITY: CodeQL Default Setup ist aktiviert (Guardrail)" +marker="" + +body=$'Der CI-Guardrail hat festgestellt, dass **GitHub CodeQL Default Setup** aktiv ist.\n\n' +body+=$'Impact:\n- Advanced CodeQL Workflow (`.github/workflows/codeql.yml`) kann dadurch keine SARIF-Ergebnisse wie erwartet verarbeiten.\n\n' +body+=$'Fix:\n- In GitHub UI: Settings -> Code security and analysis -> CodeQL -> Default setup deaktivieren\n- Oder per API: `PATCH /repos/{owner}/{repo}/code-scanning/default-setup` mit `state=not-configured`\n\n' +body+=$'Evidence:\n- Siehe Workflow-Logs und `artifacts/ci/preflight/codeql-default-setup-guardrail/`.\n\n' +if [[ ! "${state}" =~ ^[a-z-]+$ ]]; then + state="unknown" +fi +body+="Observed state: \`${state}\`"$'\n\n' +body+="${marker}"$'\n' + +existing="$(gh_api "repos/{owner}/{repo}/issues?state=open&per_page=100" --paginate --slurp | jq -r --arg t "${title}" '[.[] | .[] | select(.pull_request? | not) | select(.title == $t) | .number][0] // empty')" +if [[ -n "${existing}" ]]; then + echo "INFO: drift issue already open (#${existing}); nothing to do." + exit 0 +fi + +issue_number="" +if ! issue_number="$(gh_api "repos/{owner}/{repo}/issues" -X POST -f title="${title}" -f body="${body}" --jq .number)"; then + # Benign race: another run may have created the issue after our initial check. + existing_after_create="$(gh_api "repos/{owner}/{repo}/issues?state=open&per_page=100" --paginate --slurp | jq -r --arg t "${title}" '[.[] | .[] | select(.pull_request? | not) | select(.title == $t) | .number][0] // empty')" + if [[ -n "${existing_after_create}" ]]; then + issue_number="${existing_after_create}" + echo "INFO: drift issue was created concurrently (#${issue_number}); proceeding." + else + echo "ERROR: failed to create drift issue in ${repo}." >&2 + exit 4 + fi +fi + +echo "INFO: created drift issue #${issue_number}." + +# Best-effort labels: only add labels that already exist, otherwise the API call would hard-fail. +desired_labels=("security" "area:pipeline") +labels_available="$(gh label list -R "${repo}" -L 200 --json name 2>/dev/null || echo '[]')" +for label in "${desired_labels[@]}"; do + if jq -er --arg label "${label}" '.[] | select(.name == $label) | true' <<<"${labels_available}" >/dev/null 2>&1; then + if gh issue edit "${issue_number}" -R "${repo}" --add-label "${label}" >/dev/null 2>&1; then + echo "INFO: added label '${label}'." + else + echo "WARN: failed to add label '${label}' to issue #${issue_number}." >&2 + fi + else + echo "INFO: label '${label}' not present in repo; skipping." + fi +done diff --git a/tools/ci/codeql-default-setup-guardrail/get_state.sh b/tools/ci/codeql-default-setup-guardrail/get_state.sh new file mode 100755 index 0000000..0fbe265 --- /dev/null +++ b/tools/ci/codeql-default-setup-guardrail/get_state.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +export LC_ALL=C + +repo="${GITHUB_REPOSITORY:-${REPO:-}}" +if [[ -z "${repo}" ]]; then + echo "ERROR: missing GITHUB_REPOSITORY/REPO env (expected owner/name)." >&2 + exit 2 +fi +if [[ ! "${repo}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "ERROR: invalid repo slug: '${repo}' (expected owner/name)." >&2 + exit 2 +fi + +CODEQL_TOKEN="${CODEQL_DEFAULT_SETUP_GUARDRAIL_TOKEN:-}" + +retry() { + local -r max="${1}"; shift + local -r base_sleep="${1}"; shift + local attempt=1 + local sleep_s="${base_sleep}" + while true; do + if "$@"; then + return 0 + fi + if [[ "${attempt}" -ge "${max}" ]]; then + return 1 + fi + echo "WARN: command failed (attempt ${attempt}/${max}); retrying in ${sleep_s}s..." >&2 + sleep "${sleep_s}" + attempt="$((attempt + 1))" + sleep_s="$((sleep_s * 2))" + done +} + +gh_api() { + local -r endpoint="$1"; shift + if [[ -n "${CODEQL_TOKEN}" ]]; then + GH_TOKEN="${CODEQL_TOKEN}" GH_REPO="${repo}" gh api "${endpoint}" "$@" + else + GH_REPO="${repo}" gh api "${endpoint}" "$@" + fi +} + +state="" +# Backoff schedule for max=4, base=1s: 1s, 2s, 4s. +if ! state="$(retry 4 1 gh_api "repos/{owner}/{repo}/code-scanning/default-setup" --jq .state)"; then + echo "ERROR: failed to query CodeQL default-setup state via GitHub API." >&2 + exit 3 +fi +if [[ -z "${state}" ]]; then + echo "ERROR: invalid API response (missing state)." >&2 + exit 3 +fi + +drift="false" +if [[ "${state}" != "not-configured" ]]; then + drift="true" +fi + +echo "INFO: default-setup state='${state}', drift='${drift}'." + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "state=${state}" + echo "drift=${drift}" + } >> "${GITHUB_OUTPUT}" +fi