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
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
116 changes: 88 additions & 28 deletions lib/rules/research_extensions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
"""
Expand All @@ -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@<SHA>\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 """
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
34 changes: 32 additions & 2 deletions test/research_extensions_wiring_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())

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