Skip to content
Open
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
5 changes: 5 additions & 0 deletions .github/workflows/actions.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dependabot-automerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/governance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ permissions:

jobs:
governance:
uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd
uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@da2c748aad55c1a1dcba00b60fe4a35017bc6540
2 changes: 1 addition & 1 deletion .github/workflows/hypatia-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ permissions:

jobs:
hypatia:
uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd
uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@da2c748aad55c1a1dcba00b60fe4a35017bc6540
secrets: inherit
2 changes: 1 addition & 1 deletion .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ permissions:

jobs:
scorecard:
uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd
uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@da2c748aad55c1a1dcba00b60fe4a35017bc6540
permissions:
actions: read
contents: read
Expand Down
210 changes: 182 additions & 28 deletions lib/rules/research_extensions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,13 @@ 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 a `step-security/harden-runner@...` step in the
same job. Full-line comments and reusable-workflow jobs without a direct
`runs-on` field are ignored.

Each finding points to the first matching secret reference in the job.
Provenance: StepSecurity Harden-Runner deployment guide.

Severity: `:warn`. Action: `:report`.
"""
Expand All @@ -171,35 +175,185 @@ 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)

installs_harden? = Regex.match?(~r/uses:\s*step-security\/harden-runner/, 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)

active_lines
|> workflow_job_lines()
|> Enum.flat_map(fn job_lines ->
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? = direct_job_field?(job_lines, "runs-on")
installs_harden? = harden_runner_step?(job_lines)

if not is_nil(secret_line) and local_runner? and not installs_harden? do
{_source, line} = secret_line

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@<SHA>\n" <>
" with:\n" <>
" egress-policy: block\n" <>
" allowed-endpoints: >\n" <>
" github.com:443"
[
%{
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
[]
]
else
[]
end
end)
end)
end

defp direct_job_field?(job_lines, field) do
case job_field_indent(job_lines) do
nil ->
false

field_indent ->
Enum.any?(job_lines, fn {line, _} ->
indent_of(line) == field_indent and
String.starts_with?(String.trim_leading(line), "#{field}:")
end)
end
end

defp harden_runner_step?(job_lines) do
with field_indent when is_integer(field_indent) <- job_field_indent(job_lines),
step_lines when step_lines != [] <- lines_in_steps(job_lines, field_indent),
step_indent when is_integer(step_indent) <- step_indent(step_lines, field_indent) do
case Enum.reduce_while(step_lines, {false, nil, nil}, fn {line, _},
{in_step, property_indent,
scalar_indent} ->
indent = indent_of(line)

cond do
scalar_indent && indent > scalar_indent ->
{:cont, {in_step, property_indent, scalar_indent}}

indent == step_indent and Regex.match?(~r/^\s*-\s+/, line) ->
if harden_runner_uses?(line) do
{:halt, true}
else
{:cont, {true, nil, block_scalar?(line) && indent}}
end

in_step and indent > step_indent ->
direct_property? = is_nil(property_indent) or indent == property_indent
property_indent = property_indent || indent

if direct_property? and harden_runner_uses?(line) do
{:halt, true}
else
{:cont, {in_step, property_indent, block_scalar?(line) && indent}}
end

true ->
{:cont, {in_step, property_indent, nil}}
end
end) do
true -> true
_ -> false
end
else
_ -> false
end
end

defp job_field_indent([_header | job_lines]) do
job_lines
|> Enum.map(&elem(&1, 0))
|> Enum.reject(&(String.trim(&1) == ""))
|> Enum.map(&indent_of/1)
|> Enum.min(fn -> nil end)
end

defp job_field_indent([]), do: nil

defp lines_in_steps(job_lines, field_indent) do
case Enum.find_index(job_lines, fn {line, _} ->
indent_of(line) == field_indent and
String.starts_with?(String.trim_leading(line), "steps:")
end) do
nil ->
[]

index ->
job_lines
|> Enum.drop(index + 1)
|> Enum.take_while(fn {line, _} ->
String.trim(line) == "" or indent_of(line) > field_indent
end)
end
end

defp step_indent(step_lines, field_indent) do
step_lines
|> Enum.filter(fn {line, _} ->
indent_of(line) > field_indent and Regex.match?(~r/^\s*-\s+/, line)
end)
|> Enum.map(fn {line, _} -> indent_of(line) end)
|> Enum.min(fn -> nil end)
end

defp harden_runner_uses?(line) do
Regex.match?(~r/^\s*(?:-\s+)?uses:\s*["']?step-security\/harden-runner@/, line)
end

defp block_scalar?(line), do: Regex.match?(~r/:\s*[>|][0-9+-]*\s*(?:#.*)?$/, line)

# 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 ─────────────────────────
Expand Down
114 changes: 114 additions & 0 deletions test/research_extensions_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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("""
Expand Down
Loading
Loading