Skip to content

Commit 19fb955

Browse files
feat(reconciler): :fix_settings action class — natsci-studio calibration (#265) (#271)
Refs #260 / #263 / #265 — **do not auto-close**. Follow-up increment to merged #264 (which explicitly deferred this). ### Problem (natsci-studio live calibration) `BranchProtectionID` / `CodeReviewID` were `:open_escalate` — correct (never silently dropped) but suboptimal: they are *repository-configuration-actionable* via the GitHub settings API, not code fixes and not non-actionable. natsci-studio dry-run: alerts #1 BranchProtection / #3 CodeReview should auto-remediate under full-auto. ### Change Third, narrow action class between `:fix` (code) and `:open_escalate`: - `classify/2`: `BranchProtectionID`/`CodeReviewID` → `:fix_settings`. - `reconcile/3`: new `:policy` opt (default `:full_auto`); `do_fix_settings/3` is an idempotent branch-protection PUT requiring ≥1 PR review on the default branch (satisfies both checks). `:conservative` escalates instead. - `mix hypatia.reconcile … --conservative`; summary gains `settings_remediated`/`settings_actionable`. ### Verification `mix compile` clean. Full suite **809/809** (+3 new tests). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 617c64d commit 19fb955

3 files changed

Lines changed: 225 additions & 36 deletions

File tree

lib/mix/tasks/hypatia.reconcile.ex

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@ defmodule Mix.Tasks.Hypatia.Reconcile do
55
66
## Usage
77
8-
mix hypatia.reconcile owner/repo # reconcile + write registry
9-
mix hypatia.reconcile owner/repo --dry-run # classify only, no mutation
10-
mix hypatia.reconcile owner/repo --verify # recurrence-defect check
8+
mix hypatia.reconcile owner/repo # reconcile + write registry
9+
mix hypatia.reconcile owner/repo --dry-run # classify only, no mutation
10+
mix hypatia.reconcile owner/repo --verify # recurrence-defect check
11+
mix hypatia.reconcile owner/repo --conservative # escalate :fix_settings
12+
# instead of auto-applying
13+
14+
Policy (#265): default `:full_auto` auto-applies repo-configuration
15+
remediations (`:fix_settings` — branch protection / required reviews) via
16+
the GitHub settings API. `--conservative` escalates those for human review
17+
instead.
1118
1219
Requires `GITHUB_TOKEN` (security_events scope). Non-actionable and
1320
design-correct-exception findings are dismissed-with-rationale on GitHub
@@ -23,7 +30,9 @@ defmodule Mix.Tasks.Hypatia.Reconcile do
2330
@impl true
2431
def run(argv) do
2532
{opts, rest, _} =
26-
OptionParser.parse(argv, switches: [dry_run: :boolean, verify: :boolean])
33+
OptionParser.parse(argv,
34+
switches: [dry_run: :boolean, verify: :boolean, conservative: :boolean]
35+
)
2736

2837
case rest do
2938
[slug] ->
@@ -34,7 +43,10 @@ defmodule Mix.Tasks.Hypatia.Reconcile do
3443
if opts[:verify] do
3544
ScorecardReconciler.verify(owner, repo)
3645
else
37-
ScorecardReconciler.reconcile(owner, repo, dry_run: !!opts[:dry_run])
46+
ScorecardReconciler.reconcile(owner, repo,
47+
dry_run: !!opts[:dry_run],
48+
policy: if(opts[:conservative], do: :conservative, else: :full_auto)
49+
)
3850
end
3951

4052
IO.puts(Jason.encode!(result, pretty: true))

lib/scorecard_reconciler.ex

Lines changed: 191 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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

test/scorecard_reconciler_test.exs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ defmodule Hypatia.ScorecardReconcilerTest do
5757
test "unknown rule -> open_escalate (never silently dropped)" do
5858
assert {:open_escalate, nil, _} = R.classify(alert("SomeBrandNewRuleID"))
5959
end
60+
61+
test "BranchProtection -> fix_settings (#265, not escalate, not code fix)" do
62+
{action, code, why} = R.classify(alert("BranchProtectionID"))
63+
assert action == :fix_settings
64+
assert code == nil
65+
assert why =~ "settings API"
66+
end
67+
68+
test "CodeReview -> fix_settings (#265)" do
69+
assert {:fix_settings, nil, _} = R.classify(alert("CodeReviewID"))
70+
end
71+
72+
test "fix_settings is distinct from :fix and :open_escalate" do
73+
assert {:fix_settings, _, _} = R.classify(alert("BranchProtectionID"))
74+
assert {:fix, _, _} = R.classify(alert("SASTID"))
75+
assert {:open_escalate, _, _} = R.classify(alert("SomeBrandNewRuleID"))
76+
end
6077
end
6178

6279
describe "fingerprint/3 — stable across line drift" do

0 commit comments

Comments
 (0)