Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 20 additions & 13 deletions lib/rules/workflow_audit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -146,35 +146,42 @@ defmodule Hypatia.Rules.WorkflowAudit do
# Naïve YAML scan: any `^ <key>:$` 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 ->
%{
Expand Down
67 changes: 67 additions & 0 deletions test/workflow_audit_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,80 @@ 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})
assert finding.known_sha == "34e114876b0b11c390a56381ad16ebd13914f8d5"
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:"
Expand Down
Loading