diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 04da3687..2466b667 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -304,6 +304,11 @@ dependencies: commit: 'sha1-2155aa26a21758f2ba119f61bc7e0e1981c106fb' owner_id: 6759885 repo_id: 1275650185 + 'hyperpolymath/smtp-notify-action@v0.2.0': + ref: 'v0.2.0' + commit: 'sha1-ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7' + owner_id: 6759885 + repo_id: 1352485172 'ruby/setup-ruby@v1.321.0': ref: 'v1.321.0' commit: 'sha1-95ef2b042f9d7a56d8268cba8559e2842e2ad01b' diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 4742b314..86842d10 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -54,7 +54,7 @@ concurrency: jobs: automerge: # Only run for PRs actually authored by Dependabot. - if: github.actor == 'dependabot[bot]' && github.event.pull_request.user.login == 'dependabot[bot]' + if: github.actor_id == '49699333' && github.event.pull_request.user.login == 'dependabot[bot]' runs-on: ubuntu-latest timeout-minutes: 30 permissions: diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index 8e3ef65b..2eac987a 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -158,9 +158,12 @@ defmodule Hypatia.Rules.ResearchExtensions do # ─── RE001: Harden-Runner absent on secrets-touching workflow ───────── @doc """ - RE001: Workflow references `${{ secrets.* }}` but does not install - `step-security/harden-runner`. Provenance: StepSecurity Harden-Runner - deployment guide. + RE001: Reports each locally executed workflow job that references + `${{ secrets.* }}` without installing `step-security/harden-runner` + in that job. Fully commented lines and reusable-only jobs are ignored. + + Each warning points to the first active secret reference in the affected + job. Provenance: StepSecurity Harden-Runner deployment guide. Severity: `:warn`. Action: `:report`. """ @@ -171,37 +174,94 @@ defmodule Hypatia.Rules.ResearchExtensions do content = File.read!(path) rel = Path.relative_to(path, repo_path) - touches_secrets? = Regex.match?(~r/\$\{\{\s*secrets\.[A-Za-z_][A-Za-z0-9_]*/, content) + # Comments are not runner configuration. Preserve physical line numbers + # so existing findings are not reported against a newly added line-1 header. + active_lines = + content + |> String.split("\n") + |> Enum.with_index(1) + |> Enum.reject(fn {line, _} -> String.starts_with?(String.trim_leading(line), "#") end) - installs_harden? = Regex.match?(~r/uses:\s*step-security\/harden-runner/, content) + active_lines + |> workflow_job_lines() + |> Enum.flat_map(fn job_lines -> + active_content = Enum.map_join(job_lines, "\n", &elem(&1, 0)) - if touches_secrets? and not installs_harden? do - [ - %{ - rule: "RE001", - file: rel, - severity: :warn, - reason: - "workflow #{rel} references `secrets.*` but does not install " <> - "`step-security/harden-runner` — no outbound-egress telemetry", - action: :report, - detail: %{ - fix: - "Add as the first step of each job:\n" <> - " - uses: step-security/harden-runner@\n" <> - " with:\n" <> - " egress-policy: block\n" <> - " allowed-endpoints: >\n" <> - " github.com:443" + secret_line = + Enum.find(job_lines, fn {line, _} -> + Regex.match?(~r/\$\{\{\s*secrets\.[A-Za-z_][A-Za-z0-9_]*/, line) + end) + + # Reusable jobs delegate their runtime to the source workflow. A sibling's + # runner or hardener cannot establish this job's execution policy. + local_runner? = Regex.match?(~r/^\s+runs-on:/m, active_content) + + installs_harden? = + Regex.match?(~r/^\s+(?:-\s+)?uses:\s*step-security\/harden-runner@/m, active_content) + + if not is_nil(secret_line) and local_runner? and not installs_harden? do + {_source, line} = secret_line + + [ + %{ + rule: "RE001", + file: rel, + severity: :warn, + line: line, + reason: + "job in #{rel} references `secrets.*` but does not install " <> + "`step-security/harden-runner` — review outbound-egress monitoring", + action: :report, + detail: %{ + fix: + "Add harden-runner as the first step of this job, with egress-policy: block " <> + "and an allowlist derived from the job's actual required endpoints." + } } - } - ] - else - [] - end + ] + else + [] + end + end) end) end + # Follow block-style jobs by indentation, retaining physical source lines. + # As with the other research rules, this is a local static text analysis. + defp workflow_job_lines(lines) do + {_in_jobs, _indent, groups} = + Enum.reduce(lines, {false, nil, []}, fn {line, _} = entry, {in_jobs, indent, groups} -> + cond do + Regex.match?(~r/^jobs:\s*(?:#.*)?$/, line) -> + {true, nil, groups} + + not in_jobs -> + {false, indent, groups} + + Regex.match?(~r/^\S/, line) -> + {false, nil, groups} + + true -> + header = Regex.run(~r/^(\s+)(?:[A-Za-z0-9_-]+|"[^"]+"|'[^']+'):/, line) + width = if header, do: String.length(Enum.at(header, 1)), else: nil + + cond do + width && (is_nil(indent) || width == indent) -> + {true, width, [[entry] | groups]} + + groups != [] -> + [current | rest] = groups + {true, indent, [[entry | current] | rest]} + + true -> + {true, indent, groups} + end + end + end) + + groups |> Enum.reverse() |> Enum.map(&Enum.reverse/1) + end + # ─── RE002: Harden-Runner in audit-only mode ───────────────────────── @doc """ diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index 4e57e37b..bcaf94ab 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -43,6 +43,40 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do assert length(findings) == 1 assert hd(findings).rule == "RE001" assert hd(findings).severity == :warn + assert hd(findings).line == 7 + File.rm_rf!(repo) + end + + test "reusable-only callers delegate runner hardening to the workflow source" do + repo = + create_repo_with_workflow(""" + jobs: + mirror: + uses: owner/standards/.github/workflows/mirror.yml@main + secrets: + MIRROR_KEY: ${{ secrets.MIRROR_KEY }} + """) + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + end + + test "commented hardening does not hide a real secret reference or move its location" do + repo = + create_repo_with_workflow(""" + # A managed header added by the action-lock tool + # uses: step-security/harden-runner@main + # Example: ${{ secrets.EXAMPLE }} + jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: deploy --token=${{ secrets.DEPLOY_KEY }} + """) + + [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.line == 8 + assert finding.severity == :warn File.rm_rf!(repo) end @@ -63,6 +97,86 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do File.rm_rf!(repo) end + test "passes when harden-runner uses values are quoted" do + repo = + create_repo_with_workflow(""" + jobs: + double-quoted: + runs-on: ubuntu-latest + steps: + - uses: "step-security/harden-runner@main" + - run: deploy --token=${{ secrets.DOUBLE_QUOTED_KEY }} + single-quoted: + runs-on: ubuntu-latest + steps: + - uses: 'step-security/harden-runner@main' + - run: deploy --token=${{ secrets.SINGLE_QUOTED_KEY }} + """) + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + end + + test "does not treat nested multiline values as runner configuration or hardening" do + repo = + create_repo_with_workflow(""" + jobs: + nested-values: + env: + WORKFLOW_EXAMPLE: | + runs-on: ubuntu-latest + - uses: step-security/harden-runner@main + steps: + - run: deploy --token=${{ secrets.NESTED_ONLY }} + exposed: + runs-on: ubuntu-latest + steps: + - run: | + runs-on: ubuntu-latest + - uses: step-security/harden-runner@main + deploy --token=${{ secrets.EXPOSED }} + """) + + [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.line == 15 + File.rm_rf!(repo) + end + + test "mixed reusable and local jobs do not share runner or hardening state" do + repo = + create_repo_with_workflow(""" + jobs: + shared: + uses: owner/standards/.github/workflows/mirror.yml@main + secrets: + MIRROR_KEY: ${{ secrets.MIRROR_KEY }} + local: + runs-on: ubuntu-latest + steps: + - run: echo no credentials + """) + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + + File.write!(Path.join([repo, ".github/workflows", "test.yml"]), """ + jobs: + hardened: + runs-on: ubuntu-latest + steps: + - uses: step-security/harden-runner@main + - run: deploy --token=${{ secrets.ONE }} + exposed: + runs-on: ubuntu-latest + steps: + - run: deploy --token=${{ secrets.TWO }} + """) + + [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.line == 10 + assert finding.severity == :warn + File.rm_rf!(repo) + end + test "passes when no secrets are referenced" do repo = create_repo_with_workflow(""" diff --git a/test/research_extensions_wiring_test.exs b/test/research_extensions_wiring_test.exs index 288399b2..b232befd 100644 --- a/test/research_extensions_wiring_test.exs +++ b/test/research_extensions_wiring_test.exs @@ -25,7 +25,7 @@ defmodule Hypatia.Rules.ResearchExtensionsWiringTest do @tmp_dir System.tmp_dir!() - # Trips RE001 (touches `secrets.*` with no harden-runner; :warn, no line) + # Trips RE001 (touches `secrets.*` with no harden-runner; :warn, source line) # and RE004 (`docker://` pinned by tag; :warn, line nested under :detail). @tripwire """ name: Deploy @@ -81,6 +81,28 @@ defmodule Hypatia.Rules.ResearchExtensionsWiringTest do end describe ":line carry-through" do + test "RE001 points to the secret reference through CLI and SARIF" do + repo = tripwire_repo() + + finding = + Enum.find(CLI.collect_findings(repo, [:research_extensions]), &(&1.type == "RE001")) + + assert finding.line == 8 + sarif = SARIF.from_findings([finding], repo) + + assert get_in(sarif, [ + "runs", + Access.at(0), + "results", + Access.at(0), + "locations", + Access.at(0), + "physicalLocation", + "region", + "startLine" + ]) == 8 + end + test "RE004's line, nested under :detail, survives normalization" do f = re004(tripwire_repo()) @@ -121,7 +143,15 @@ defmodule Hypatia.Rules.ResearchExtensionsWiringTest do ExUnit.CaptureIO.capture_io(:stderr, fn -> output = ExUnit.CaptureIO.capture_io(fn -> - CLI.main(["scan", repo, "--rules", "research_extensions", "--format", "github", "--exit-zero"]) + CLI.main([ + "scan", + repo, + "--rules", + "research_extensions", + "--format", + "github", + "--exit-zero" + ]) end) assert output =~ "::warning"