From 77a71783eed59fa815285b8b872f6714c4c61362 Mon Sep 17 00:00:00 2001 From: Viktor Didkovskyi Date: Wed, 12 Aug 2026 09:45:28 +0300 Subject: [PATCH 1/3] ci: add organization PR validation baseline --- .../workflows/organization-pr-baseline.yml | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .github/workflows/organization-pr-baseline.yml diff --git a/.github/workflows/organization-pr-baseline.yml b/.github/workflows/organization-pr-baseline.yml new file mode 100644 index 0000000..5fbe225 --- /dev/null +++ b/.github/workflows/organization-pr-baseline.yml @@ -0,0 +1,106 @@ +name: Organization PR Baseline + +# HEA-7249: reusable, read-only validation for pull requests across Healify. +# Roll this out through callers first. Do not make it an organization-required +# workflow until representative repositories have completed a green trial. + +on: + pull_request: + paths: + - ".github/workflows/**" + workflow_call: + +permissions: + contents: read + +concurrency: + group: organization-pr-baseline-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + GITLEAKS_VERSION: 8.30.1 + GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + +jobs: + validate: + name: Organization PR Baseline + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out the exact commit without retaining credentials + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install checksum-pinned validation tools + env: + GH_HOST: github.com + run: | + set -euo pipefail + install -d "$RUNNER_TEMP/bin" + + curl --fail --silent --show-error --location \ + "https://${GH_HOST}/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + --output "$RUNNER_TEMP/actionlint.tar.gz" + echo "${ACTIONLINT_SHA256} $RUNNER_TEMP/actionlint.tar.gz" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP/bin" actionlint + + curl --fail --silent --show-error --location \ + "https://${GH_HOST}/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + --output "$RUNNER_TEMP/gitleaks.tar.gz" + echo "${GITLEAKS_SHA256} $RUNNER_TEMP/gitleaks.tar.gz" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/gitleaks.tar.gz" -C "$RUNNER_TEMP/bin" gitleaks + echo "$RUNNER_TEMP/bin" >>"$GITHUB_PATH" + + - name: Validate GitHub Actions workflows + run: | + set -euo pipefail + mapfile -d '' workflows < <(find .github/workflows -maxdepth 1 -type f \ + \( -name '*.yml' -o -name '*.yaml' \) -print0 2>/dev/null || true) + if [ "${#workflows[@]}" -eq 0 ]; then + echo "::notice::No GitHub Actions workflow files found." + else + actionlint "${workflows[@]}" + fi + + - name: Reject placeholder CI Summary gates + shell: python + run: | + from pathlib import Path + import re + import sys + + placeholder = re.compile( + r"run:\s*(?:[>|][-+]?\s*\n\s*)?echo\s+[\"']?CI checks passed", + re.IGNORECASE, + ) + failures = [] + for pattern in ("*.yml", "*.yaml"): + for path in Path(".github/workflows").glob(pattern): + text = path.read_text(encoding="utf-8") + if re.search(r"name:\s*CI Summary\b", text) and placeholder.search(text): + failures.append(path) + + if failures: + for path in failures: + print( + f"::error file={path}::CI Summary must aggregate real tests; " + "an unconditional echo is not a merge gate." + ) + sys.exit(1) + + - name: Scan the pull-request range for committed secrets + env: + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + if [ -n "${BASE:-}" ] && [ "$BASE" != "0000000000000000000000000000000000000000" ] \ + && git rev-parse --verify "$BASE^{commit}" >/dev/null 2>&1; then + gitleaks git --log-opts="${BASE}..${HEAD}" --no-banner --verbose --redact + else + gitleaks git --no-banner --verbose --redact + fi From adb558af97dfcdb2d91ea9607bfd630290ac9419 Mon Sep 17 00:00:00 2001 From: Viktor Didkovskyi Date: Wed, 12 Aug 2026 11:18:15 +0300 Subject: [PATCH 2/3] ci: parse workflows in the baseline audit instead of pattern-matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The placeholder-gate check was a literal string match on `echo "CI checks passed"`. It missed four trivial variants (a `set -e` line before the echo, any other phrase, `exit 0`, and `true`) and fired on a workflow whose real gate was fine but which mentioned the phrase in an unrelated job. Parse the YAML and judge the gate job instead: a job is a fake gate when it neither aggregates other jobs through needs..result nor runs a command that can fail. Checked against all 216 workflow files in the organization — it flags the two known fake gates in meditation-service and healify-org, and nothing else. Add advisory checks for the debt the same audit measured: 561 of 657 external action references sit on mutable tags, 84 workflows carry no permissions block, 182 of 309 jobs have no timeout, and ten steps interpolate a secret straight into a run script. These annotate rather than fail so the workflow stays adoptable; a repository opts into enforcement with strict: true. Also stop cancelling in-progress runs on branch pushes, so a rapid series of commits cannot leave one unscanned behind a cancelled run; raise the default timeout to 20 minutes because the fallback secret scan walks all history; retry the tool downloads; and install PyYAML explicitly rather than assuming the runner image provides it. --- .../workflows/organization-pr-baseline.yml | 194 +++++++++++++++--- 1 file changed, 169 insertions(+), 25 deletions(-) diff --git a/.github/workflows/organization-pr-baseline.yml b/.github/workflows/organization-pr-baseline.yml index 5fbe225..6fc82a9 100644 --- a/.github/workflows/organization-pr-baseline.yml +++ b/.github/workflows/organization-pr-baseline.yml @@ -3,19 +3,41 @@ name: Organization PR Baseline # HEA-7249: reusable, read-only validation for pull requests across Healify. # Roll this out through callers first. Do not make it an organization-required # workflow until representative repositories have completed a green trial. +# +# Two tiers, deliberately: +# blocking - syntax, committed secrets, and fake merge gates. Already clean +# across the fleet, so turning these on breaks nobody. +# advisory - action pinning, permissions, job timeouts, secret handling. +# A 2026-08-12 audit of 216 workflows found 561/657 external +# action references on mutable tags, 84 workflows with no +# permissions block, and 182/309 jobs with no timeout. Failing +# on those today would red-line every repository at once, so they +# annotate until a repository opts in with strict: true. on: pull_request: paths: - ".github/workflows/**" workflow_call: + inputs: + strict: + description: "Fail the job on advisory findings (pinning, permissions, timeouts, secret handling)." + type: boolean + default: false + scan-timeout-minutes: + description: "Job timeout. Raise for repositories with long history; the fallback secret scan walks all commits." + type: number + default: 20 permissions: contents: read concurrency: group: organization-pr-baseline-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + # Only cancel superseded pull-request runs. On branch pushes each commit keeps + # its own secret scan, so a rapid series of pushes cannot leave a commit + # unscanned behind a cancelled run. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: ACTIONLINT_VERSION: 1.7.12 @@ -27,29 +49,29 @@ jobs: validate: name: Organization PR Baseline runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: ${{ inputs.scan-timeout-minutes || 20 }} steps: - name: Check out the exact commit without retaining credentials - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 persist-credentials: false - name: Install checksum-pinned validation tools env: - GH_HOST: github.com + DOWNLOAD_HOST: github.com run: | set -euo pipefail install -d "$RUNNER_TEMP/bin" - curl --fail --silent --show-error --location \ - "https://${GH_HOST}/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + curl --fail --silent --show-error --location --retry 3 --retry-delay 2 \ + "https://${DOWNLOAD_HOST}/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ --output "$RUNNER_TEMP/actionlint.tar.gz" echo "${ACTIONLINT_SHA256} $RUNNER_TEMP/actionlint.tar.gz" | sha256sum --check --strict tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP/bin" actionlint - curl --fail --silent --show-error --location \ - "https://${GH_HOST}/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + curl --fail --silent --show-error --location --retry 3 --retry-delay 2 \ + "https://${DOWNLOAD_HOST}/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ --output "$RUNNER_TEMP/gitleaks.tar.gz" echo "${GITLEAKS_SHA256} $RUNNER_TEMP/gitleaks.tar.gz" | sha256sum --check --strict tar -xzf "$RUNNER_TEMP/gitleaks.tar.gz" -C "$RUNNER_TEMP/bin" gitleaks @@ -63,33 +85,152 @@ jobs: if [ "${#workflows[@]}" -eq 0 ]; then echo "::notice::No GitHub Actions workflow files found." else + echo "Linting ${#workflows[@]} workflow file(s) with actionlint ${ACTIONLINT_VERSION}." actionlint "${workflows[@]}" fi - - name: Reject placeholder CI Summary gates + - name: Ensure the YAML parser is present + run: | + set -euo pipefail + # The audit step parses workflows rather than pattern-matching them, so + # PyYAML is a hard dependency. Do not rely on it being preinstalled. + python3 -c 'import yaml' 2>/dev/null || pip install --quiet --disable-pip-version-check pyyaml + + - name: Audit workflow structure shell: python + env: + STRICT: ${{ inputs.strict && 'true' || 'false' }} run: | - from pathlib import Path + """Structural audit of .github/workflows. + + Blocking: a job that is a required merge gate but runs no real command. + Advisory: mutable action references, missing permissions blocks, + missing job timeouts, secrets interpolated into run scripts. + """ + import os import re import sys + from pathlib import Path + + import yaml - placeholder = re.compile( - r"run:\s*(?:[>|][-+]?\s*\n\s*)?echo\s+[\"']?CI checks passed", + STRICT = os.environ.get("STRICT") == "true" + GATE_NAMES = {"ci summary", "ci-summary"} + SHA_REF = re.compile(r"^[0-9a-f]{40}$") + NEEDS_REF = re.compile(r"needs\.[A-Za-z0-9_-]+\.(result|outputs)") + SECRET_REF = re.compile(r"\$\{\{\s*secrets\.[A-Za-z0-9_]+\s*\}\}") + # Commands that cannot make a gate meaningful on their own. + INERT = re.compile( + r"^(echo|printf|true|:|exit\s+0|set\s|cd\s|export\s|shift|sleep\s|#)", re.IGNORECASE, ) - failures = [] - for pattern in ("*.yml", "*.yaml"): - for path in Path(".github/workflows").glob(pattern): - text = path.read_text(encoding="utf-8") - if re.search(r"name:\s*CI Summary\b", text) and placeholder.search(text): - failures.append(path) - - if failures: - for path in failures: - print( - f"::error file={path}::CI Summary must aggregate real tests; " - "an unconditional echo is not a merge gate." + + blocking: list[str] = [] + advisory: list[str] = [] + + + def real_commands(run: str) -> bool: + """True when a run block contains at least one command that can fail.""" + for raw in run.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + # Strip shell noise that wraps a real command. + line = re.sub(r"^[{}()]\s*", "", line) + if line in {"fi", "done", "esac", "}", "{"}: + continue + if not INERT.match(line): + return True + return False + + + def is_fake_gate(job: dict) -> bool: + """A merge gate that aggregates nothing and runs nothing that can fail.""" + serialized = yaml.safe_dump(job) + if NEEDS_REF.search(serialized): + return False # aggregates other jobs' results + steps = job.get("steps") or [] + if not steps: + return True + for step in steps: + if not isinstance(step, dict): + continue + run = step.get("run") + if isinstance(run, str) and real_commands(run): + return False + return True + + + paths = sorted( + p for pat in ("*.yml", "*.yaml") for p in Path(".github/workflows").glob(pat) + ) + for path in paths: + try: + doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, UnicodeDecodeError) as exc: + blocking.append(f"::error file={path}::Unable to parse workflow: {exc}") + continue + if not isinstance(doc, dict): + continue + + if "permissions" not in doc: + advisory.append( + f"::warning file={path}::No top-level permissions block; " + "the job inherits the repository default. Add an explicit " + "least-privilege permissions block." ) + + for name, job in (doc.get("jobs") or {}).items(): + if not isinstance(job, dict): + continue + label = str(job.get("name") or name).strip().lower() + + if label in GATE_NAMES and is_fake_gate(job): + blocking.append( + f"::error file={path}::Job '{name}' is a required merge gate " + "but runs no command that can fail. A gate must aggregate " + "other jobs via needs..result or run real checks." + ) + + if "timeout-minutes" not in job and "uses" not in job: + advisory.append( + f"::warning file={path}::Job '{name}' has no timeout-minutes; " + "a hung job can occupy a runner for six hours." + ) + + for step in job.get("steps") or []: + if not isinstance(step, dict): + continue + ref = step.get("uses") + if isinstance(ref, str) and not ref.startswith((".", "docker://")): + _, _, version = ref.rpartition("@") + if not SHA_REF.match(version): + advisory.append( + f"::warning file={path}::Job '{name}' uses '{ref}' at a " + "mutable ref. Pin third-party actions to a full commit SHA." + ) + run = step.get("run") + if isinstance(run, str) and SECRET_REF.search(run): + advisory.append( + f"::warning file={path}::Job '{name}' interpolates a secret " + "directly into a run script. Pass it through env: so the " + "value is never rendered into the command line." + ) + + for line in advisory: + print(line) + for line in blocking: + print(line) + + summary = Path(os.environ["GITHUB_STEP_SUMMARY"]) + with summary.open("a", encoding="utf-8") as fh: + fh.write("## Organization PR Baseline\n\n") + fh.write(f"- Workflows audited: **{len(paths)}**\n") + fh.write(f"- Blocking findings: **{len(blocking)}**\n") + fh.write(f"- Advisory findings: **{len(advisory)}**") + fh.write(" (failing this run)\n" if STRICT and advisory else "\n") + + if blocking or (STRICT and advisory): sys.exit(1) - name: Scan the pull-request range for committed secrets @@ -99,8 +240,11 @@ jobs: run: | set -euo pipefail if [ -n "${BASE:-}" ] && [ "$BASE" != "0000000000000000000000000000000000000000" ] \ - && git rev-parse --verify "$BASE^{commit}" >/dev/null 2>&1; then + && git rev-parse --verify "$BASE^{commit}" >/dev/null 2>&1 \ + && git rev-parse --verify "$HEAD^{commit}" >/dev/null 2>&1; then + echo "Scanning ${BASE}..${HEAD}" gitleaks git --log-opts="${BASE}..${HEAD}" --no-banner --verbose --redact else + echo "::notice::No usable commit range; scanning full history." gitleaks git --no-banner --verbose --redact fi From c9cb508cb7c4cb953e3a54c6e46b5c15cee13d80 Mon Sep 17 00:00:00 2001 From: Viktor Didkovskyi Date: Wed, 12 Aug 2026 11:26:59 +0300 Subject: [PATCH 3/3] ci: scan every pull request and stop misreading compound commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review. The paths filter meant the secret scan only ran when a pull request touched a workflow file, so a commit that put a credential in application code was never scanned. Drop the filter; the audit is fast and the repository is small. The inert-command check matched on line prefix, so `echo "verifying" && make verify` looked inert and a compliant gate could be reported as fake — a false positive in the blocking tier, which is the one thing that tier must not do. Split each line on shell operators and require every segment to be inert, and match command words whole so `truncate_logs` is no longer read as `true`. `exit 0` stays inert; any other exit can fail and counts as real. Installing PyYAML with a bare pip would have failed on Ubuntu 24.04, whose system Python is PEP 668 externally-managed. That path never ran because the image already ships PyYAML, which is precisely why it needed fixing rather than leaving as untested fallback. Use actions/setup-python, pinned by SHA. Regression cases now cover the compound-command false positive, whole-word matching, and the still-fake variants. Re-checked against all 216 workflow files in the organization: the same two fake gates, nothing else. --- .../workflows/organization-pr-baseline.yml | 59 +++++++++++++------ 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/.github/workflows/organization-pr-baseline.yml b/.github/workflows/organization-pr-baseline.yml index 6fc82a9..91c2c4a 100644 --- a/.github/workflows/organization-pr-baseline.yml +++ b/.github/workflows/organization-pr-baseline.yml @@ -15,9 +15,9 @@ name: Organization PR Baseline # annotate until a repository opts in with strict: true. on: + # No paths filter: the secret scan must see every pull request, not only the + # ones that happen to touch a workflow file. pull_request: - paths: - - ".github/workflows/**" workflow_call: inputs: strict: @@ -89,12 +89,19 @@ jobs: actionlint "${workflows[@]}" fi - - name: Ensure the YAML parser is present + # The audit parses workflows rather than pattern-matching them, so PyYAML is + # a hard dependency. Use a managed interpreter: Ubuntu's system Python is + # PEP 668 externally-managed, where a plain pip install fails. + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install the YAML parser run: | set -euo pipefail - # The audit step parses workflows rather than pattern-matching them, so - # PyYAML is a hard dependency. Do not rely on it being preinstalled. - python3 -c 'import yaml' 2>/dev/null || pip install --quiet --disable-pip-version-check pyyaml + python -m pip install --quiet --disable-pip-version-check pyyaml + python -c 'import yaml; print("PyYAML", yaml.__version__)' - name: Audit workflow structure shell: python @@ -119,27 +126,45 @@ jobs: SHA_REF = re.compile(r"^[0-9a-f]{40}$") NEEDS_REF = re.compile(r"needs\.[A-Za-z0-9_-]+\.(result|outputs)") SECRET_REF = re.compile(r"\$\{\{\s*secrets\.[A-Za-z0-9_]+\s*\}\}") - # Commands that cannot make a gate meaningful on their own. - INERT = re.compile( - r"^(echo|printf|true|:|exit\s+0|set\s|cd\s|export\s|shift|sleep\s|#)", - re.IGNORECASE, - ) + # Commands that cannot make a gate meaningful on their own. Matched as + # whole words, so 'truncate_logs' is not mistaken for 'true'. + INERT_COMMANDS = { + "echo", "printf", "true", ":", "set", "cd", "export", "shift", "sleep", + } + SHELL_SPLIT = re.compile(r"&&|\|\||;|\|") + COMMAND_WORD = re.compile(r"^([A-Za-z_:.\[][A-Za-z0-9_.\-]*)") + BLOCK_NOISE = {"fi", "done", "esac", "then", "else", "do", "{", "}", "(", ")"} blocking: list[str] = [] advisory: list[str] = [] + def segment_is_inert(segment: str) -> bool: + """True when one command segment cannot fail the job.""" + text = segment.strip().lstrip("{}()").strip() + if not text or text.startswith("#") or text in BLOCK_NOISE: + return True + if re.fullmatch(r"exit\s+0", text): + return True + if text.startswith("exit"): + return False # exit 1 and friends can fail the gate + word = COMMAND_WORD.match(text) + if not word: + return True # redirects, assignments, punctuation + return word.group(1) in INERT_COMMANDS + + def real_commands(run: str) -> bool: - """True when a run block contains at least one command that can fail.""" + """True when a run block contains at least one command that can fail. + + Each line is split on shell operators, because 'echo x && make verify' + starts with an inert word but still runs a real command. + """ for raw in run.splitlines(): line = raw.strip() if not line or line.startswith("#"): continue - # Strip shell noise that wraps a real command. - line = re.sub(r"^[{}()]\s*", "", line) - if line in {"fi", "done", "esac", "}", "{"}: - continue - if not INERT.match(line): + if not all(segment_is_inert(s) for s in SHELL_SPLIT.split(line)): return True return False