@@ -48,8 +48,14 @@ defmodule Hypatia.ScorecardReconciler do
4848 # :dismiss_accept — code change *possible* but the canonical remediation
4949 # is HARMFUL / it is correct-by-design. Dismiss
5050 # "won't fix" with rationale. (SLSA pin-exempt)
51- # :fix — safe, deterministic remediation exists. Emit a
52- # fix-request (PR; auto-merge if CI-green + low-risk).
51+ # :fix — safe, deterministic *code* remediation exists. Emit
52+ # a fix-request (PR; auto-merge if CI-green + low-risk).
53+ # :fix_settings — safe, deterministic *repository-configuration*
54+ # remediation exists via the GitHub settings API
55+ # (enable branch protection, require reviews). NOT a
56+ # code change and NOT non-actionable — a third, narrow
57+ # action class so config findings are auto-remediated
58+ # under full-auto policy instead of escalated. (#265)
5359 # :open_escalate — unknown / not safely automatable. Leave OPEN and
5460 # ensure a tracking issue (never silently drop —
5561 # "didn't look" is an escaped-defect KPI breach).
@@ -58,6 +64,12 @@ defmodule Hypatia.ScorecardReconciler do
5864 # itself states no remediation is needed.
5965 @ info_rules ~w( MaintainedID ContributorsID CITestsID CIIBestPracticesID)
6066
67+ # Scorecard rules that are repo-CONFIGURATION-actionable: not code, not
68+ # non-actionable — deterministically remediable via the GitHub settings
69+ # API (#265). Surfaced by the natsci-studio live calibration where these
70+ # were being :open_escalate'd despite a safe automatic fix existing.
71+ @ settings_rules ~w( BranchProtectionID CodeReviewID)
72+
6173 @ doc """
6274 Pure classification of a single code-scanning alert map (as returned by
6375 the GitHub API). `ctx` may carry `:located_action_ref` — the `uses:` ref
@@ -91,6 +103,14 @@ defmodule Hypatia.ScorecardReconciler do
91103 "Pin the third-party dependency to a full-length commit SHA " <>
92104 "(not pin-exempt)." }
93105
106+ rule in @ settings_rules ->
107+ { :fix_settings , nil ,
108+ "Repository-configuration finding `#{ rule } ` — deterministically " <>
109+ "remediable via the GitHub settings API (enable branch " <>
110+ "protection / require pull-request reviews on the default " <>
111+ "branch). Not a code change and not non-actionable; auto-applied " <>
112+ "under full-auto policy, escalated under conservative. (#265)" }
113+
94114 true ->
95115 { :open_escalate , nil ,
96116 "No safe automated lifecycle action known for rule `#{ rule } ` — " <>
@@ -118,6 +138,10 @@ defmodule Hypatia.ScorecardReconciler do
118138 """
119139 def reconcile ( owner , repo , opts \\ [ ] ) do
120140 dry = Keyword . get ( opts , :dry_run , false )
141+ # :full_auto (default) auto-applies :fix_settings via the settings API;
142+ # :conservative escalates settings findings for human review instead of
143+ # mutating repo configuration automatically. (#265)
144+ policy = Keyword . get ( opts , :policy , :full_auto )
121145
122146 case fetch_open_alerts ( owner , repo ) do
123147 { :error , reason } ->
@@ -135,11 +159,24 @@ defmodule Hypatia.ScorecardReconciler do
135159
136160 acted =
137161 cond do
138- dry -> :skipped_dry_run
162+ dry ->
163+ :skipped_dry_run
164+
139165 action in [ :dismiss_info , :dismiss_accept ] ->
140166 do_dismiss ( owner , repo , number , reason_code , rationale )
141- action == :fix -> :fix_requested
142- true -> :escalated
167+
168+ action == :fix ->
169+ :fix_requested
170+
171+ action == :fix_settings and policy == :full_auto ->
172+ do_fix_settings ( owner , repo , alert )
173+
174+ action == :fix_settings ->
175+ # :conservative — never silently drop; escalate.
176+ :escalated_conservative
177+
178+ true ->
179+ :escalated
143180 end
144181
145182 reg_acc =
@@ -163,9 +200,10 @@ defmodule Hypatia.ScorecardReconciler do
163200 looked: true ,
164201 found: length ( alerts ) ,
165202 classified: length ( results ) ,
166- dismissed:
167- Enum . count ( results , & ( & 1 . acted in [ :dismissed , :already_dismissed ] ) ) ,
203+ dismissed: Enum . count ( results , & ( & 1 . acted in [ :dismissed , :already_dismissed ] ) ) ,
168204 fix_requested: Enum . count ( results , & ( & 1 . action == :fix ) ) ,
205+ settings_remediated: Enum . count ( results , & ( & 1 . acted == :settings_remediated ) ) ,
206+ settings_actionable: Enum . count ( results , & ( & 1 . action == :fix_settings ) ) ,
169207 escalated: Enum . count ( results , & ( & 1 . action == :open_escalate ) ) ,
170208 results: results
171209 }
@@ -185,8 +223,12 @@ defmodule Hypatia.ScorecardReconciler do
185223 for alert <- alerts ,
186224 entry = Registry . get ( reg , fingerprint ( owner , repo , alert ) ) ,
187225 entry [ "action" ] in [ "dismiss_info" , "dismiss_accept" ] do
188- % { alert: alert [ "number" ] , fp: fingerprint ( owner , repo , alert ) ,
189- recurrence_defect: true , prior: entry }
226+ % {
227+ alert: alert [ "number" ] ,
228+ fp: fingerprint ( owner , repo , alert ) ,
229+ recurrence_defect: true ,
230+ prior: entry
231+ }
190232 end
191233
192234 { :ok , % { repo: "#{ owner } /#{ repo } " , recurrence_defects: regressions } }
@@ -205,13 +247,21 @@ defmodule Hypatia.ScorecardReconciler do
205247 "#{ @ github_api_base } /repos/#{ owner } /#{ repo } /code-scanning/alerts" <>
206248 "?state=open&per_page=#{ @ max_alerts } "
207249
208- case System . cmd ( "curl" , [
209- "-s" , "-f" ,
210- "-H" , "Accept: application/vnd.github+json" ,
211- "-H" , "Authorization: Bearer #{ token } " ,
212- "-H" , "X-GitHub-Api-Version: 2022-11-28" ,
213- url
214- ] , stderr_to_stdout: true ) do
250+ case System . cmd (
251+ "curl" ,
252+ [
253+ "-s" ,
254+ "-f" ,
255+ "-H" ,
256+ "Accept: application/vnd.github+json" ,
257+ "-H" ,
258+ "Authorization: Bearer #{ token } " ,
259+ "-H" ,
260+ "X-GitHub-Api-Version: 2022-11-28" ,
261+ url
262+ ] ,
263+ stderr_to_stdout: true
264+ ) do
215265 { body , 0 } ->
216266 case Jason . decode ( body ) do
217267 { :ok , a } when is_list ( a ) -> { :ok , a }
@@ -240,20 +290,122 @@ defmodule Hypatia.ScorecardReconciler do
240290 url =
241291 "#{ @ github_api_base } /repos/#{ owner } /#{ repo } /code-scanning/alerts/#{ number } "
242292
243- case System . cmd ( "curl" , [
244- "-s" , "-f" , "-X" , "PATCH" ,
245- "-H" , "Accept: application/vnd.github+json" ,
246- "-H" , "Authorization: Bearer #{ token } " ,
247- "-H" , "X-GitHub-Api-Version: 2022-11-28" ,
248- "-d" , payload , url
249- ] , stderr_to_stdout: true ) do
250- { _ , 0 } -> :dismissed
293+ case System . cmd (
294+ "curl" ,
295+ [
296+ "-s" ,
297+ "-f" ,
298+ "-X" ,
299+ "PATCH" ,
300+ "-H" ,
301+ "Accept: application/vnd.github+json" ,
302+ "-H" ,
303+ "Authorization: Bearer #{ token } " ,
304+ "-H" ,
305+ "X-GitHub-Api-Version: 2022-11-28" ,
306+ "-d" ,
307+ payload ,
308+ url
309+ ] ,
310+ stderr_to_stdout: true
311+ ) do
312+ { _ , 0 } ->
313+ :dismissed
314+
251315 { err , _ } ->
252316 Logger . warning ( "reconciler dismiss ##{ number } failed: #{ String . slice ( err , 0 , 160 ) } " )
253317 :dismiss_failed
254318 end
255319 end
256320
321+ # #265: deterministic, safe, API-driven repository-configuration
322+ # remediation for BranchProtectionID / CodeReviewID. Enables default-branch
323+ # protection requiring at least one approving pull-request review (this one
324+ # change satisfies both checks). Idempotent — the protection PUT is
325+ # declarative, so re-running converges to the same state.
326+ defp do_fix_settings ( owner , repo , _alert ) do
327+ token = System . get_env ( "GITHUB_TOKEN" )
328+
329+ with branch when is_binary ( branch ) <- default_branch ( owner , repo , token ) do
330+ payload =
331+ Jason . encode! ( % {
332+ "required_status_checks" => nil ,
333+ "enforce_admins" => false ,
334+ "required_pull_request_reviews" => % {
335+ "required_approving_review_count" => 1 ,
336+ "dismiss_stale_reviews" => true
337+ } ,
338+ "restrictions" => nil ,
339+ "allow_force_pushes" => false ,
340+ "allow_deletions" => false
341+ } )
342+
343+ url =
344+ "#{ @ github_api_base } /repos/#{ owner } /#{ repo } /branches/#{ branch } /protection"
345+
346+ case System . cmd (
347+ "curl" ,
348+ [
349+ "-s" ,
350+ "-f" ,
351+ "-X" ,
352+ "PUT" ,
353+ "-H" ,
354+ "Accept: application/vnd.github+json" ,
355+ "-H" ,
356+ "Authorization: Bearer #{ token } " ,
357+ "-H" ,
358+ "X-GitHub-Api-Version: 2022-11-28" ,
359+ "-d" ,
360+ payload ,
361+ url
362+ ] ,
363+ stderr_to_stdout: true
364+ ) do
365+ { _ , 0 } ->
366+ :settings_remediated
367+
368+ { err , _ } ->
369+ Logger . warning (
370+ "reconciler fix_settings #{ owner } /#{ repo } failed: #{ String . slice ( err , 0 , 160 ) } "
371+ )
372+
373+ :settings_failed
374+ end
375+ else
376+ _ -> :settings_failed
377+ end
378+ end
379+
380+ defp default_branch ( owner , repo , token ) do
381+ url = "#{ @ github_api_base } /repos/#{ owner } /#{ repo } "
382+
383+ case System . cmd (
384+ "curl" ,
385+ [
386+ "-s" ,
387+ "-f" ,
388+ "-H" ,
389+ "Accept: application/vnd.github+json" ,
390+ "-H" ,
391+ "Authorization: Bearer #{ token } " ,
392+ "-H" ,
393+ "X-GitHub-Api-Version: 2022-11-28" ,
394+ url
395+ ] ,
396+ stderr_to_stdout: true
397+ ) do
398+ { body , 0 } ->
399+ case Jason . decode ( body ) do
400+ { :ok , % { "default_branch" => b } } when is_binary ( b ) -> b
401+ _ -> nil
402+ end
403+
404+ _ ->
405+ nil
406+ end
407+ end
408+
257409 # Best-effort resolution of the `uses:` ref at an alert's location so
258410 # SLSA-class pin-exemptions are detected. Reads the workflow file at the
259411 # reported path+line via the contents API; nil on any miss (caller then
@@ -268,13 +420,21 @@ defmodule Hypatia.ScorecardReconciler do
268420 url =
269421 "#{ @ github_api_base } /repos/#{ owner } /#{ repo } /contents/#{ path } " ,
270422 { body , 0 } <-
271- System . cmd ( "curl" , [
272- "-s" , "-f" ,
273- "-H" , "Accept: application/vnd.github.raw+json" ,
274- "-H" , "Authorization: Bearer #{ token } " ,
275- "-H" , "X-GitHub-Api-Version: 2022-11-28" ,
276- url
277- ] , stderr_to_stdout: true ) ,
423+ System . cmd (
424+ "curl" ,
425+ [
426+ "-s" ,
427+ "-f" ,
428+ "-H" ,
429+ "Accept: application/vnd.github.raw+json" ,
430+ "-H" ,
431+ "Authorization: Bearer #{ token } " ,
432+ "-H" ,
433+ "X-GitHub-Api-Version: 2022-11-28" ,
434+ url
435+ ] ,
436+ stderr_to_stdout: true
437+ ) ,
278438 lines = String . split ( body , "\n " ) ,
279439 target when is_binary ( target ) <- Enum . at ( lines , line - 1 ) ,
280440 [ _ , ref ] <- Regex . run ( ~r/ uses:\s *(\S +)/ , target ) do
0 commit comments