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
19 changes: 19 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ https://semver.org/[Semantic Versioning].

=== Added

==== `CicdRules` rule `scorecard_wrapper_missing_job_permissions` (2026-05-30, #390)

New forward-detection rule for a silent-CI-failure class: a
`.github/workflows/scorecard.yml` that *calls* the standards
`scorecard-reusable.yml` but omits `security-events: write` on the calling
job. Called-workflow permissions are capped by the caller, so
`ossf/scorecard-action` cannot upload its SARIF and every scheduled
Scorecard run fails with `startup_failure` — no logs. Estate baseline
2026-05-30: 37 affected wrappers (35 unique + 2 inert nested-monorepo
copies). Prior art: `julia-professional-registry#19`, `absolute-zero#68`.

Surfaced through the facade as
`Hypatia.Rules.scan_scorecard_wrapper_permissions/2`; the pure predicate
`CicdRules.check_scorecard_wrapper_permissions/2` and an
`opts[:path_allow_prefixes]` carve-out (for bespoke inline scorecard
workflows) are covered by `test/rules/cicd_rules_scorecard_wrapper_test.exs`
for both sensitivity (positive + nested copy) and specificity (perm present,
no-reusable, carve-out).

==== `WorkflowAudit` rules WF014–WF017 (2026-05-30, PRs #393 + #396)

Four new forward-detection rules surfacing patterns root-fixed in
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- feat(rules): CicdRules `scorecard_wrapper_missing_job_permissions` — flag scorecard.yml wrappers that call the standards reusable but omit `security-events: write` on the calling job (#390)
- feat(rules): AffineScript hand-port pitfalls — HANDLE-as-fn-name + OCaml float ops (#332)
- feat(rules): wire 4 new rule modules through the facade (#326)
- feat(rules): ResearchExtensions (RE001-RE010) — 10 rules from Snyk/StepSecurity/Endor/academic literature (#325)
Expand Down
96 changes: 96 additions & 0 deletions lib/rules/cicd_rules.ex
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,102 @@ defmodule Hypatia.Rules.CicdRules do
end
end

# ---------------------------------------------------------------------------
# Scorecard Wrapper Permissions (#390)
# ---------------------------------------------------------------------------
#
# A `.github/workflows/scorecard.yml` that *calls* the standards
# `scorecard-reusable.yml` MUST grant `security-events: write` (and
# `id-token: write`) on the calling job. Called-workflow permissions are
# CAPPED by the caller, so a wrapper that omits the grant leaves
# `ossf/scorecard-action` unable to upload its SARIF — every scheduled
# Scorecard run then fails with `startup_failure` and *no logs*. That
# silent-CI-failure shape is exactly what Hypatia exists to catch.
#
# Estate baseline 2026-05-30: 37 affected wrappers (35 unique + 2 inert
# nested-monorepo copies). Prior art: julia-professional-registry#19,
# absolute-zero#68 (memory: feedback_scorecard_wrapper_caller_permissions).

@scorecard_wrapper_path ".github/workflows/scorecard.yml"
@scorecard_reusable_marker "scorecard-reusable.yml"
@scorecard_required_perm ~r/security-events:\s*write/
@scorecard_missing_perm_reason "scorecard.yml calls the standards scorecard-reusable.yml but does not grant `security-events: write` on the calling job; called-workflow permissions are capped by the caller, so ossf/scorecard-action cannot upload its SARIF and the scheduled Scorecard run fails with `startup_failure` (silent CI failure, no logs)."
@scorecard_missing_perm_fix "Grant the calling job `security-events: write` (and `id-token: write`); the reusable re-asserts them, but the caller caps them:\n permissions:\n security-events: write\n id-token: write"

@doc """
Scan `repo_path` for scorecard wrappers that call the standards reusable
but omit the required `security-events: write` job permission (#390).

A finding is emitted for each `.github/workflows/scorecard.yml` (repo-root
*or* nested monorepo copy) that, in the same file:

* references `scorecard-reusable.yml` (i.e. uses the reusable), AND
* does NOT grant `security-events: write`.

Inline scorecard workflows that do not call the reusable are ignored by
construction (the first condition fails). `opts[:path_allow_prefixes]` is a
list of substrings; any wrapper whose relative path contains one is skipped
— an explicit carve-out for bespoke scorecard workflows that manage their
own permissions shape.

Returns `[%{rule:, severity:, file:, reason:, fix:}]`.
"""
def scan_scorecard_wrapper_permissions(repo_path, opts \\ []) do
allow_prefixes = Keyword.get(opts, :path_allow_prefixes, [])

Path.wildcard("#{repo_path}/**/*", match_dot: true)
|> Enum.reject(&File.dir?/1)
|> Enum.map(&Path.relative_to(&1, repo_path))
|> Enum.filter(fn rel ->
not String.starts_with?(rel, ".git/") and scorecard_wrapper_path?(rel)
end)
|> Enum.reject(fn rel -> Enum.any?(allow_prefixes, &String.contains?(rel, &1)) end)
|> Enum.flat_map(fn rel ->
case File.read(Path.join(repo_path, rel)) do
{:ok, content} ->
case check_scorecard_wrapper_permissions(rel, content) do
{:fail, finding} -> [finding]
:ok -> []
end

{:error, _} ->
[]
end
end)
end

@doc """
Pure predicate behind `scan_scorecard_wrapper_permissions/2`.

Given a scorecard wrapper's relative `path` and its `content`, returns
`{:fail, finding}` when the file calls the standards reusable but does not
grant `security-events: write`, or `:ok` otherwise.
"""
def check_scorecard_wrapper_permissions(path, content) do
uses_reusable? = String.contains?(content, @scorecard_reusable_marker)
grants_perm? = Regex.match?(@scorecard_required_perm, content)

if uses_reusable? and not grants_perm? do
finding = %{
rule: :scorecard_wrapper_missing_job_permissions,
severity: :high,
file: path,
reason: @scorecard_missing_perm_reason,
fix: @scorecard_missing_perm_fix
}

{:fail, finding}
else
:ok
end
end

# True when `rel` is a scorecard wrapper workflow — the repo-root copy or
# any nested monorepo copy (`pkg/.github/workflows/scorecard.yml`).
defp scorecard_wrapper_path?(rel) do
rel == @scorecard_wrapper_path or String.ends_with?(rel, "/" <> @scorecard_wrapper_path)
end

# ---------------------------------------------------------------------------
# CI/CD Waste Detection
# ---------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions lib/rules/rules.ex
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,12 @@ defmodule Hypatia.Rules do
"""
defdelegate detect_waste(repo_info), to: CicdRules

@doc """
Scan for scorecard wrappers that call the standards reusable but omit the
required `security-events: write` job permission (#390).
"""
defdelegate scan_scorecard_wrapper_permissions(repo_path, opts \\ []), to: CicdRules

@doc """
Run baseline-health checks (BH001-BH007): missing required_status_checks
on main, deferred-migration TODOs in dep manifests, persistent >24h red
Expand Down
128 changes: 128 additions & 0 deletions test/rules/cicd_rules_scorecard_wrapper_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# SPDX-License-Identifier: MPL-2.0

defmodule Hypatia.Rules.CicdRules.ScorecardWrapperTest do
use ExUnit.Case, async: true

alias Hypatia.Rules.CicdRules

# #390 — a `.github/workflows/scorecard.yml` that CALLS the standards
# `scorecard-reusable.yml` must grant `security-events: write` on the
# calling job, or the scheduled Scorecard run fails with `startup_failure`
# (no logs). Detection: uses "scorecard-reusable.yml" AND NOT
# "security-events: write". Sensitivity + specificity both covered.

@wf_path ".github/workflows/scorecard.yml"

@reusable_no_perm """
# SPDX-License-Identifier: MPL-2.0
name: Scorecard
on:
schedule:
- cron: "0 2 * * 1"
permissions: read-all
jobs:
analysis:
uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@abc1234
"""

@reusable_with_perm """
# SPDX-License-Identifier: MPL-2.0
name: Scorecard
on:
schedule:
- cron: "0 2 * * 1"
permissions: read-all
jobs:
analysis:
permissions:
security-events: write
id-token: write
uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@abc1234
"""

@inline_no_reusable """
# SPDX-License-Identifier: MPL-2.0
name: Scorecard
on:
schedule:
- cron: "0 2 * * 1"
permissions: read-all
jobs:
analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ossf/scorecard-action@v2
"""

setup do
dir = Path.join(System.tmp_dir!(), "hyp-scw-#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(dir)
on_exit(fn -> File.rm_rf!(dir) end)
{:ok, dir: dir}
end

defp write_scorecard(dir, body, sub \\ "") do
rel = if sub == "", do: @wf_path, else: Path.join(sub, @wf_path)
path = Path.join(dir, rel)
File.mkdir_p!(Path.dirname(path))
File.write!(path, body)
path
end

describe "scan_scorecard_wrapper_permissions/2 — sensitivity" do
test "fires when wrapper uses reusable but lacks the perm", %{dir: dir} do
write_scorecard(dir, @reusable_no_perm)
assert [finding] = CicdRules.scan_scorecard_wrapper_permissions(dir)
assert finding.rule == :scorecard_wrapper_missing_job_permissions
assert finding.severity == :high
assert finding.file == @wf_path
assert finding.fix =~ "security-events: write"
end

test "fires on a nested monorepo copy", %{dir: dir} do
write_scorecard(dir, @reusable_no_perm, "packages/api")
assert [finding] = CicdRules.scan_scorecard_wrapper_permissions(dir)
assert finding.file == "packages/api/" <> @wf_path
end
end

describe "scan_scorecard_wrapper_permissions/2 — specificity" do
test "silent when wrapper grants the perm", %{dir: dir} do
write_scorecard(dir, @reusable_with_perm)
assert CicdRules.scan_scorecard_wrapper_permissions(dir) == []
end

test "silent for inline scorecard not using the reusable", %{dir: dir} do
write_scorecard(dir, @inline_no_reusable)
assert CicdRules.scan_scorecard_wrapper_permissions(dir) == []
end

test "path_allow_prefixes carve-out skips the wrapper", %{dir: dir} do
write_scorecard(dir, @reusable_no_perm, "vendor/upstream")

findings =
CicdRules.scan_scorecard_wrapper_permissions(dir, path_allow_prefixes: ["vendor/"])

assert findings == []
end
end

describe "check_scorecard_wrapper_permissions/2 — pure predicate" do
test "fail when reusable present and perm absent" do
result = CicdRules.check_scorecard_wrapper_permissions(@wf_path, @reusable_no_perm)
assert {:fail, finding} = result
assert finding.rule == :scorecard_wrapper_missing_job_permissions
assert finding.reason =~ "startup_failure"
end

test "ok when perm present with irregular spacing" do
body = String.replace(@reusable_no_perm, "uses:", "security-events: write\n uses:")
assert :ok = CicdRules.check_scorecard_wrapper_permissions(@wf_path, body)
end

test "ok when reusable not called" do
assert :ok = CicdRules.check_scorecard_wrapper_permissions(@wf_path, @inline_no_reusable)
end
end
end
Loading