@@ -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