diff --git a/lib/rules/workflow_audit.ex b/lib/rules/workflow_audit.ex index 525d3aaa..a77e6309 100644 --- a/lib/rules/workflow_audit.ex +++ b/lib/rules/workflow_audit.ex @@ -146,35 +146,42 @@ defmodule Hypatia.Rules.WorkflowAudit do # Naïve YAML scan: any `^ :$` under top-level `jobs:` # without a subsequent `^ timeout-minutes:` line before the # next sibling block. + # Reusable-workflow caller jobs (a job-level `^ uses:` calling + # `org/repo/.github/workflows/x.yml@ref`) are EXEMPT: a workflow_call + # job cannot carry a job-level `timeout-minutes:` — the timeout lives + # in the called workflow's own jobs. Flagging them is a false positive. lines = String.split(content, "\n") - in_jobs = false - jobs_with_timeout = MapSet.new() - jobs_seen = MapSet.new() - current_job = nil + acc0 = {MapSet.new(), MapSet.new(), MapSet.new(), false, nil} - {jobs_seen, jobs_with_timeout} = - Enum.reduce(lines, {jobs_seen, jobs_with_timeout, false, nil}, fn line, {seen, with_to, in_j, curj} -> + {jobs_seen, jobs_with_timeout, reusable_jobs} = + Enum.reduce(lines, acc0, fn line, {seen, with_to, reusable, in_j, curj} -> cond do String.match?(line, ~r/^jobs:\s*$/) -> - {seen, with_to, true, nil} + {seen, with_to, reusable, true, nil} in_j and String.match?(line, ~r/^ [A-Za-z0-9_-]+:\s*$/) -> [_, name] = Regex.run(~r/^ ([A-Za-z0-9_-]+):/, line) - {MapSet.put(seen, name), with_to, in_j, name} + {MapSet.put(seen, name), with_to, reusable, in_j, name} in_j and curj && String.match?(line, ~r/^ timeout-minutes:\s*\d+/) -> - {seen, MapSet.put(with_to, curj), in_j, curj} + {seen, MapSet.put(with_to, curj), reusable, in_j, curj} + + in_j and curj && String.match?(line, ~r/^ uses:\s*\S/) -> + {seen, with_to, MapSet.put(reusable, curj), in_j, curj} String.match?(line, ~r/^[A-Za-z]/) -> - {seen, with_to, false, curj} + {seen, with_to, reusable, false, curj} true -> - {seen, with_to, in_j, curj} + {seen, with_to, reusable, in_j, curj} end end) - |> then(fn {s, w, _, _} -> {s, w} end) + |> then(fn {s, w, r, _, _} -> {s, w, r} end) - missing = MapSet.difference(jobs_seen, jobs_with_timeout) + missing = + jobs_seen + |> MapSet.difference(jobs_with_timeout) + |> MapSet.difference(reusable_jobs) Enum.map(missing, fn job_name -> %{ diff --git a/test/workflow_audit_test.exs b/test/workflow_audit_test.exs index c04dbba8..3e99c42b 100644 --- a/test/workflow_audit_test.exs +++ b/test/workflow_audit_test.exs @@ -47,6 +47,20 @@ defmodule Hypatia.Rules.WorkflowAuditTest do assert findings == [] end + test "ignores a full-SHA pin with a trailing version comment (setup-java FP guard)" do + content = """ + jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Set up Temurin JRE 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + """ + + findings = WorkflowAudit.check_unpinned_actions(%{"verify-proofs.yml" => content}) + assert findings == [] + end + test "provides known SHA when available" do content = " - uses: actions/checkout@v4\n" [finding] = WorkflowAudit.check_unpinned_actions(%{"ci.yml" => content}) @@ -54,6 +68,59 @@ defmodule Hypatia.Rules.WorkflowAuditTest do end end + describe "check_missing_timeout_minutes/1" do + test "flags a runner job with no timeout-minutes" do + content = """ + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + """ + + findings = WorkflowAudit.check_missing_timeout_minutes(%{"ci.yml" => content}) + assert [%{rule: "missing_timeout_minutes", job: "build"}] = findings + end + + test "does not flag a job that declares timeout-minutes" do + content = """ + jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - run: echo hi + """ + + assert WorkflowAudit.check_missing_timeout_minutes(%{"ci.yml" => content}) == [] + end + + test "does not flag a reusable-workflow caller job (uses:) — it cannot hold a job-level timeout" do + content = """ + jobs: + governance: + uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@5eb28d7d8790d5389b7b6a5233fe6265a775e3d0 + """ + + assert WorkflowAudit.check_missing_timeout_minutes(%{"governance.yml" => content}) == [] + end + + test "still flags a runner job alongside a reusable caller job" do + content = """ + jobs: + call: + uses: hyperpolymath/standards/.github/workflows/x-reusable.yml@5eb28d7d8790d5389b7b6a5233fe6265a775e3d0 + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + """ + + findings = WorkflowAudit.check_missing_timeout_minutes(%{"mixed.yml" => content}) + assert [%{job: "build"}] = findings + end + end + describe "check_permissions/1" do test "flags write-all" do content = "permissions: write-all\njobs:\n build:"