Skip to content

Commit 386828d

Browse files
feat(rules): WF017 — detect secret-consuming action without presence gate (#396)
## Summary Forward-detection rule for the pattern just root-fixed in hyperpolymath/standards#305 (Mirror radicle + Instant Sync secret-presence gates, 65 estate repos auto-fixed). ## What WF017 detects A workflow step that: - `uses:` one of `@secret_consuming_actions` (curated, data-driven) - reads `\${{ secrets.X }}` as its primary input - lacks an `if: secrets.X != ''` gate (on the step or as an enclosing wrapper) ## Curated action list (initial) | Action | Primary secret param | |---|---| | `webfactory/ssh-agent` | `ssh-private-key` | | `peter-evans/repository-dispatch` | `token` | | `peter-evans/create-pull-request` | `token` | | `actions-ecosystem/action-create-comment` | `github_token` | Add new entries to `@secret_consuming_actions` (no code change). ## Sensitivity / specificity smoke tests | Case | Expected | Result | |---|---|---| | Ungated `webfactory/ssh-agent` | fires | ✓ | | Gated `webfactory/ssh-agent` (`- if: secrets.X != ''`) | silent | ✓ | | Ungated `peter-evans/repository-dispatch` | fires | ✓ | | Gated `peter-evans/repository-dispatch` | silent | ✓ | | Generic `actions/checkout` (not in list) | silent | ✓ | The gate regex handles both step-leading `- if:` and interior `if:`, both quote styles, and inline-`\${{ … }}` expressions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0e4b26c commit 386828d

1 file changed

Lines changed: 134 additions & 1 deletion

File tree

lib/rules/workflow_audit.ex

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,16 @@ defmodule Hypatia.Rules.WorkflowAudit do
7979
scorecard_publish_run = check_scorecard_publish_run_violation(workflow_contents)
8080
nonroot_container_eacces = check_nonroot_container_checkout_eacces(workflow_contents)
8181
orphan_reusable_pins = check_orphan_standards_reusable_pin(workflow_contents)
82+
ungated_secret_action = check_ungated_secret_action(workflow_contents)
8283

8384
%{
8485
findings:
8586
missing ++ unpinned ++ wrong_pins ++ permission_issues ++ duplicates ++
8687
caching_issues ++ run_context_issues ++ download_then_run_issues ++ nperm_typos ++
8788
codeql_lang_mismatch ++ workflow_sha_foreign_ref ++
8889
reusable_caller_context_self_checkout ++ missing_timeouts ++
89-
scorecard_publish_run ++ nonroot_container_eacces ++ orphan_reusable_pins,
90+
scorecard_publish_run ++ nonroot_container_eacces ++ orphan_reusable_pins ++
91+
ungated_secret_action,
9092
missing_count: length(missing),
9193
unpinned_count: length(unpinned),
9294
wrong_pin_count: length(wrong_pins),
@@ -102,6 +104,7 @@ defmodule Hypatia.Rules.WorkflowAudit do
102104
scorecard_publish_run_count: length(scorecard_publish_run),
103105
nonroot_container_eacces_count: length(nonroot_container_eacces),
104106
orphan_reusable_pin_count: length(orphan_reusable_pins),
107+
ungated_secret_action_count: length(ungated_secret_action),
105108
workflow_count: length(workflow_files),
106109
standard_coverage: coverage_percentage(workflow_files)
107110
}
@@ -800,6 +803,136 @@ defmodule Hypatia.Rules.WorkflowAudit do
800803
end)
801804
end
802805

806+
# ─── WF017: Secret-consuming action without secret-presence gate ──────
807+
808+
# Actions whose primary input is a secret and that fail outright when
809+
# the secret is empty. Each entry: `{action_prefix, param_name}` —
810+
# the `param_name` is the `with:` key the action reads from.
811+
#
812+
# When the param value interpolates `${{ secrets.X }}` and the step has
813+
# no `if: secrets.X != ''` gate (or equivalent env-based gate), the
814+
# workflow fails every run on repos where the secret hasn't been
815+
# propagated. The Mirror radicle (26 repos) + Instant Sync (39 repos)
816+
# bugs caught by the 2026-05-30 audit were both this pattern.
817+
@secret_consuming_actions [
818+
{"webfactory/ssh-agent", "ssh-private-key"},
819+
{"peter-evans/repository-dispatch", "token"},
820+
{"peter-evans/create-pull-request", "token"},
821+
{"actions-ecosystem/action-create-comment", "github_token"}
822+
]
823+
824+
@doc """
825+
WF017: Detect a workflow step that uses a known secret-consuming
826+
action with `${{ secrets.X }}` as its primary input, but lacks
827+
an `if: secrets.X != ''` gate.
828+
829+
Caught Mirror radicle (\`webfactory/ssh-agent\` per-forge × 7 jobs) and
830+
Instant Sync (\`peter-evans/repository-dispatch\`) on the 2026-05-30
831+
audit — combined 65 estate repos failing on every push because the
832+
required secret was missing on those repos. Fix recipe is in
833+
hyperpolymath/standards#305 (source-level gate in the reusable).
834+
835+
Sensitivity / specificity:
836+
* Specific — only fires for the curated `@secret_consuming_actions`
837+
list. Generic `uses: actions/checkout` with no secret param does
838+
not fire. Actions that gracefully no-op on empty secret (rare —
839+
most error out) are not in the list. The list is data-driven so
840+
a new entry does not require a code change.
841+
* Sensitive — fires regardless of where the `if:` would appear
842+
(anywhere in the step block), and works for both single-line
843+
`if: secrets.X != ''` and YAML-block `if: \${{ secrets.X != '' }}`.
844+
Both quote styles (\` ` `\`` and \` ' `\`) match.
845+
"""
846+
def check_ungated_secret_action(workflow_contents) do
847+
Enum.flat_map(workflow_contents, fn {filename, content} ->
848+
stripped = strip_comments(content)
849+
850+
stripped
851+
|> extract_steps_using_known_actions()
852+
|> Enum.flat_map(fn step_block ->
853+
Enum.flat_map(@secret_consuming_actions, fn {action_prefix, param} ->
854+
if Regex.match?(~r/uses:\s*#{Regex.escape(action_prefix)}@/, step_block) do
855+
# Pull the secret name out of the `with: <param>: ${{ secrets.X }}` line.
856+
case Regex.run(
857+
~r/#{Regex.escape(param)}:\s*\$\{\{\s*secrets\.([A-Z_][A-Z0-9_]*)\s*\}\}/,
858+
step_block
859+
) do
860+
[_full, secret_name] ->
861+
# Step is "gated" if there is an `if:` line in the step block
862+
# that references the secret being empty/non-empty. The `if:`
863+
# can be the step's leading key (` - if: ...`) or a regular
864+
# interior key (` if: ...` after a `- uses:` head). Both
865+
# quote-styles + both block / `${{ … }}` expression forms
866+
# count.
867+
gate_re =
868+
~r/(?:^|\n)\s*(?:-\s+)?if:[^\n]*secrets\.#{Regex.escape(secret_name)}[^\n]*!=\s*['"]['"]?/
869+
870+
if Regex.match?(gate_re, step_block) do
871+
[]
872+
else
873+
[%{
874+
rule: "WF017",
875+
type: :secret_action_without_presence_gate,
876+
file: filename,
877+
action: action_prefix,
878+
secret: secret_name,
879+
severity: :high,
880+
reason:
881+
"Step uses `#{action_prefix}` with `#{param}: " <>
882+
"\${{ secrets.#{secret_name} }}` but has no " <>
883+
"`if: secrets.#{secret_name} != ''` gate. On repos " <>
884+
"where the secret hasn't been propagated the action " <>
885+
"fails on every push, red-maining the repo. Add the " <>
886+
"step-level gate (or env+if pattern) so the missing-" <>
887+
"secret path is a clean skip instead of a red.",
888+
fix_recipe: :add_secret_presence_gate
889+
}]
890+
end
891+
892+
_ ->
893+
[]
894+
end
895+
else
896+
[]
897+
end
898+
end)
899+
end)
900+
end)
901+
end
902+
903+
# Like `extract_checkout_blocks/1` but yields step blocks that use any
904+
# of the known secret-consuming actions. Re-uses the same column-aware
905+
# step-boundary detection; just changes the final filter predicate.
906+
defp extract_steps_using_known_actions(content) do
907+
prefixes = Enum.map(@secret_consuming_actions, fn {p, _} -> Regex.escape(p) end) |> Enum.join("|")
908+
filter_re = ~r/uses:\s*(?:#{prefixes})@/
909+
910+
content
911+
|> String.split("\n")
912+
|> Enum.reduce({[], nil}, fn line, {steps, current} ->
913+
cond do
914+
matches = Regex.run(~r/^(\s*)-\s+/, line) ->
915+
indent = matches |> Enum.at(1) |> String.length()
916+
{flush_step(steps, current), {indent, [line]}}
917+
918+
is_tuple(current) ->
919+
{step_indent, lines_acc} = current
920+
921+
if String.trim(line) == "" or leading_indent(line) > step_indent do
922+
{steps, {step_indent, [line | lines_acc]}}
923+
else
924+
{flush_step(steps, current), nil}
925+
end
926+
927+
true ->
928+
{steps, current}
929+
end
930+
end)
931+
|> then(fn {steps, current} -> flush_step(steps, current) end)
932+
|> Enum.reverse()
933+
|> Enum.filter(&Regex.match?(filter_re, &1))
934+
end
935+
803936
# ─── Helpers ───────────────────────────────────────────────────────────
804937

805938
# Strip YAML / shell line comments from a workflow body before pattern

0 commit comments

Comments
 (0)