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
4 changes: 3 additions & 1 deletion lib/rules/research_extensions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -626,9 +626,11 @@ defmodule Hypatia.Rules.ResearchExtensions do
# is decided before the EXIT trap runs, and a failing handler command
# under `set -e` (e.g. `kill` of an already-exited PID) would itself
# turn a PASSING step red, so the `|| true` is what keeps it honest.
# Only the quoted handler is dropped, so a command after it on the
# same line (`trap '…' EXIT; cargo test || true`) is still judged.
has_or_true? =
stripped_body
|> String.replace(~r/^[ \t]*trap[ \t].*$/m, "")
|> String.replace(~r/\btrap[ \t]+(?:'[^'\n]*'|"[^"\n]*")/, "trap")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle escaped quotes inside trap handlers

For a double-quoted handler containing escaped quotes, such as trap "kill \"$PID\" 2>/dev/null || true" EXIT, the pattern stops at the first escaped " and leaves the handler's || true in the scanned text. A normally failing test step with only this cleanup trap is therefore reported as swallowing its exit; the handler matcher needs to consume shell escapes while still preserving commands after the closing quote.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #950. The double-quoted handler matcher now consumes backslash escapes, and a test covers trap "kill \"$PID\" 2>/dev/null || true" EXIT.


Generated by Claude Code

|> then(&Regex.match?(~r/\|\|\s*true\b/, &1))

# A step carrying `hypatia:ignore RE005 -- <reason>` (or the
Expand Down
31 changes: 28 additions & 3 deletions lib/rules/workflow_hardening.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1259,10 +1259,33 @@ defmodule Hypatia.Rules.WorkflowHardening do

@doc """
Return true when a write operation's failure is masked, so a denied write
cannot turn the job red.
cannot turn the job red: a shell mask on the write itself, or a STEP-level
`continue-on-error: true` in text that performs a write. The step flag is
judged per job body, so it may belong to a sibling step. A job-level
`continue-on-error` is not counted: it is a deliberate, visible job setting
(standards' github-backup-mirror-reusable.yml documents and surfaces it).
"""
def masked_write?(content) when is_binary(content) do
Enum.any?(@write_masks, &Regex.match?(&1, content))
Enum.any?(@write_masks, &Regex.match?(&1, content)) or
(step_continue_on_error?(content) and performs_contents_write?(content))
Comment on lines +1269 to +1270

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Tie continue-on-error to the writing step

When any non-writing sibling step has continue-on-error: true, this job-wide conjunction also treats an unmasked git push or gh release create in another step as masked. WH013 then emits the unmask-credentialed-write or grant-contents-write-to-writing-job-and-unmask recipe even though removing the sibling flag cannot affect the write; associate the flag with the step that actually performs the write.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #950. continue-on-error now counts only on the step that performs the write. A test checks that a sibling step's flag leaves an unmasked credentialed release unreported.


Generated by Claude Code

Comment on lines +1269 to +1270

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report continue-on-error in the WH013 remediation

When continue-on-error: true is the only mask on the writing step, this new branch routes to findings whose reason says the mask is 2>/dev/null, || true, or || echo and whose fix only tells users to remove those shell constructs. The report therefore describes syntax that is absent and does not tell the user to remove the actual step flag; include this mechanism in both WH013 messages and fixes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #950. Both masked WH013 findings now name the step's continue-on-error: true in their reason and fix, and the test asserts both strings.


Generated by Claude Code

end

# `continue-on-error: true` indented under a `steps:` key, i.e. on a step.
defp step_continue_on_error?(content) do
lines = String.split(content, "\n")

case Enum.find(lines, &Regex.match?(~r/^[ \t]*steps:[ \t]*$/, &1)) do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept trailing comments on steps keys

In valid workflows using steps: # publication steps, this exact-line match never finds the steps block, so a credentialed write under a literal step-level continue-on-error: true is not reported as masked at all; for token writes it also selects the less accurate starvation-only recipe. Allow a trailing YAML comment here, as the job extractor already does for jobs:.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #950 (this PR has merged). The steps: matcher now accepts a trailing comment, and there's a test for it.


Generated by Claude Code

nil ->
false

steps_line ->
steps_indent = indent_of(steps_line)

Enum.any?(lines, fn line ->
Regex.match?(~r/^[ \t]*(?:-[ \t]+)?continue-on-error:[ \t]*true\b/, line) and
indent_of(line) > steps_indent
end)
end
end

# ─── Internals ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -1399,7 +1422,9 @@ defmodule Hypatia.Rules.WorkflowHardening do

_ ->
cond do
line == "" or indent_of(line) >= jobs_indent + 1 ->
# A comment between jobs (` # Publish`) does not end `jobs:`.
line == "" or indent_of(line) >= jobs_indent + 1 or
String.starts_with?(String.trim_leading(line), "#") ->
{acc, true, jobs_indent, append_line(current, line)}
Comment on lines +1425 to 1428

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep inter-job comments out of the preceding job body

A comment at the jobs indentation now keeps extraction active but is also appended to the current job. Because WH013 scans job bodies as raw text, a separator such as # The publisher used to git push directly makes the preceding non-writing job appear to perform a write and can produce a spurious permission-starvation finding. Ignore such comments for the current body while retaining the in-jobs parser state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #950. A comment between jobs keeps the parser in jobs: but no longer joins the job above. A test checks that # … git push origin main after a read-only job gives no finding.


Generated by Claude Code


true ->
Expand Down
6 changes: 4 additions & 2 deletions scripts/open-data-pr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
#
# Usage: open-data-pr.sh <branch-suffix> <title> <path>...
#
# Commits <path>... on a new branch `inbox-steward/<run_id>-<suffix>`, pushes
# Commits <path>... on a new branch `inbox-steward/<run_id>-<attempt>-<suffix>`, pushes
# it, opens a PR against main and queues a squash auto-merge. Used by
# inbox-steward-intake.yml instead of `git push origin HEAD` to main, which
# bypassed review and signing and was masked so a refusal stayed green.
Expand All @@ -29,7 +29,9 @@ if git diff --cached --quiet; then
exit 0
fi

branch="inbox-steward/${GITHUB_RUN_ID}-${suffix}"
# The attempt number keeps a re-run from colliding with a branch an earlier
# attempt already pushed (same run id, different history).
branch="inbox-steward/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}-${suffix}"
git switch -c "$branch"
git commit -m "$title"
git push origin "HEAD:refs/heads/${branch}"
Expand Down
15 changes: 15 additions & 0 deletions test/research_extensions_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,21 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do
File.rm_rf!(repo)
end

test "a swallow after a trap on the same line is still reported" do
repo =
create_repo_with_workflow("""
jobs:
x:
steps:
- name: run tests
run: |
trap 'kill "$PID" 2>/dev/null || true' EXIT; cargo test || true
""")

assert [%{rule: "RE005"}] = ResearchExtensions.re005_test_swallows_exit(repo)
File.rm_rf!(repo)
end

test "ignores non-test steps with continue-on-error" do
repo =
create_repo_with_workflow("""
Expand Down
64 changes: 64 additions & 0 deletions test/workflow_hardening_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,70 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do
File.rm_rf!(repo)
end

test "a comment between jobs does not hide the later jobs" do
repo =
create_repo_with_workflow("""
name: CI
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: mix test
# Publish
publish:
runs-on: ubuntu-latest
steps:
- run: git push origin HEAD
""")

assert [%{severity: :high}] = WorkflowHardening.wh013_permission_starved_write(repo)
File.rm_rf!(repo)
end

test "continue-on-error on a credentialed write counts as masked" do
repo =
create_repo_with_workflow("""
name: Release
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- continue-on-error: true
env:
GH_TOKEN: ${{ secrets.RELEASE_PAT }}
run: gh release create v1 dist/*
""")

assert [%{fix_recipe: "unmask-credentialed-write"}] =
WorkflowHardening.wh013_permission_starved_write(repo)

File.rm_rf!(repo)
end

test "a job-level continue-on-error is a deliberate setting, not a mask" do
repo =
create_repo_with_workflow("""
name: Backup
permissions:
contents: read
jobs:
mirror:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- env:
GH_TOKEN: ${{ secrets.BACKUP_PAT }}
run: git push backup HEAD:refs/heads/main && gh pr create --repo o/b --title x --body y
""")

assert [] = WorkflowHardening.wh013_permission_starved_write(repo)
File.rm_rf!(repo)
end

test "a masked credentialed gh release is reported" do
repo =
create_repo_with_workflow("""
Expand Down
Loading