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
113 changes: 80 additions & 33 deletions lib/hypatia/workflow_yaml.ex
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ defmodule Hypatia.WorkflowYaml do
# Nested anchors can multiply copies exponentially (the "billion laughs"
# file), so expansion stops after this many copied lines per workflow.
@alias_budget 5_000

@doc """
Expand YAML anchors and aliases in numbered lines (`{line, line_number}`),
as GitHub does, within what jobs read: the top-level `jobs:` and `env:`
sections. There each `*name` is replaced in place by the node anchored
`&name` (the nearest one above, wherever it is defined), a `<<:` merge adds
the anchored entries the mapping does not set itself, and every line an
Comment on lines +77 to +80

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 Document the special handling of flow aliases

When an alias appears inside a flow collection, the implementation takes the value == nil branch and returns the original alias-containing line followed by copied anchor lines rather than replacing *name in place. The new public contract therefore misdescribes the returned representation for inputs such as env: [*common]; either document this append-only flow behavior, as the existing internal comment does, or actually replace the alias token.

Useful? React with 👍 / 👎.

Comment on lines +79 to +80

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 Remove the claim that GitHub expands merge keys

When callers pass <<: *defaults, this reader expands the merge even though GitHub Actions rejects YAML merge keys: an Actions runner collaborator explicitly confirmed they are unsupported, while GitHub's documentation only describes whole-node aliases (runner issue, GitHub documentation). Describing the merge behavior as what GitHub does can cause consumers to treat an invalid workflow as executable; document it as a reader extension or stop expanding merge keys.

Useful? React with 👍 / 👎.

alias brings carries the alias's line number. An alias in any other
top-level section (`on:`, `permissions:`, ...) is returned unchanged.
Anchors and aliases count only where a YAML node starts, never inside
quoted scalars or block-scalar text. Expansion stops
after #{@alias_budget} copied lines, so hostile input costs bounded work.
Comment on lines +83 to +85

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 Enforce the documented copied-line budget

When the first alias references an anchor containing more than 5,000 block lines, anchored_block/3 subtracts the entire block length before invoking the private expander, but that expander still returns every line when its budget is non-positive. Thus a single alias can copy an arbitrarily large block despite this contract promising expansion stops after 5,000 copied lines; cap the copied block itself rather than only suppressing further nested aliases.

Useful? React with 👍 / 👎.

"""
def expand_aliases(lines) do
codes = yaml_codes(lines)

Expand Down Expand Up @@ -292,10 +305,12 @@ defmodule Hypatia.WorkflowYaml do
end)
end

# The column of a step's own properties: just past `- ` on its opening line,
# or the indent of the first property under a bare `-`. A `-` followed only
# by a comment (`- # publish`) is a bare `-`: YAML ignores the comment, so
# the step's keys start on the next line.
@doc """
The column of a step's own properties: just past `- ` on its opening line,
or the indent of the first property under a bare `-`. A `-` followed only
by a comment (`- # publish`) is a bare `-`: YAML ignores the comment, so
the step's keys start on the next line.
"""
def step_key_column([{first, _} | rest]) do
case Regex.run(~r/^(\s*-\s+)[^\s#]/, first) do
[_, prefix] ->
Expand All @@ -310,7 +325,9 @@ defmodule Hypatia.WorkflowYaml do

def step_key_column([]), do: 0

# The job's steps, each as its own physical lines.
@doc """
The job's steps, each as its own physical lines.
"""
def job_steps(job_lines) do
with field_indent when is_integer(field_indent) <- job_field_indent(job_lines),
step_lines when step_lines != [] <- lines_in_steps(job_lines, field_indent),
Expand All @@ -336,6 +353,14 @@ defmodule Hypatia.WorkflowYaml do
end
end

@doc """
The flow-collection depth (`[`/`{` opened minus `]`/`}` closed) of `text`
before byte offset `position`; 0 outside any flow collection.
Comment thread
hyperpolymath marked this conversation as resolved.
Resolved

Every bracket character counts, including one inside a quoted scalar or a
comment, so pass text with those already blanked (as the reader's own
YAML codes are); on raw YAML the depth can be wrong.
"""
def nesting_at(text, position) do
text
|> binary_part(0, position)
Expand All @@ -347,6 +372,11 @@ defmodule Hypatia.WorkflowYaml do
end)
end

@doc """
True when the job (as `workflow_job_lines/1` gives it, header line first)
sets `field` as one of its own keys, at the job's field column, and not as
a deeper key of a step or a nested mapping.
"""
def direct_job_field?(job_lines, field) do
case job_field_indent(job_lines) do
nil ->
Expand All @@ -359,13 +389,19 @@ defmodule Hypatia.WorkflowYaml do
end
end

# A line that opens the mapping key `key`: YAML reads `env:`, `"env":` and
# `'env':` as the same key, and so does GitHub.
@doc """
A line that opens the mapping key `key`: YAML reads `env:`, `"env":` and
`'env':` as the same key, and so does GitHub.
"""
def key_line?(line, key) do
key = Regex.escape(key)
Regex.match?(~r/^\s*(?:#{key}|"#{key}"|'#{key}')\s*:(?:\s|$)/, line)
end

@doc """
The column of a job's own keys: the shallowest indent among its non-blank
lines after the header. nil for a job with no body.
"""
def job_field_indent([_header | job_lines]) do
job_lines
|> Enum.map(&elem(&1, 0))
Expand All @@ -376,9 +412,11 @@ defmodule Hypatia.WorkflowYaml do

def job_field_indent([]), do: nil

# The lines of the job's `steps:` value. Its items may sit deeper than the
# key or, as an indentless sequence YAML also allows, at the key's own
# column: `steps:` then `- run: …` directly beneath it.
@doc """
The lines of the job's `steps:` value. Its items may sit deeper than the
key or, as an indentless sequence YAML also allows, at the key's own
column: `steps:` then `- run: …` directly beneath it.
"""
def lines_in_steps(job_lines, field_indent) do
case Enum.find_index(job_lines, fn {line, _} ->
indent_of(line) == field_indent and key_line?(line, "steps")
Expand All @@ -396,8 +434,10 @@ defmodule Hypatia.WorkflowYaml do
end
end

# The column of the step items: the shallowest `-` in the steps value, which
# is the key's own column for an indentless sequence.
@doc """
The column of the step items: the shallowest `-` in the steps value, which
is the key's own column for an indentless sequence.
"""
def step_indent(step_lines, field_indent) do
step_lines
|> Enum.filter(fn {line, _} ->
Expand All @@ -409,11 +449,13 @@ defmodule Hypatia.WorkflowYaml do

defp sequence_item?(line), do: Regex.match?(~r/^\s*-(?:\s|$)/, line)

# A line without its YAML comment: a `#` at the start or after whitespace,
# outside a quoted scalar. A quote opens a scalar only where one can start
# (after whitespace or a flow indicator), so the apostrophe in `it's` does not.
# With `blank_quoted`, each quoted scalar's text becomes spaces of the same
# byte length, so what remains is structure at its original offsets.
@doc """
A line without its YAML comment: a `#` at the start or after whitespace,
outside a quoted scalar. A quote opens a scalar only where one can start
(after whitespace or a flow indicator), so the apostrophe in `it's` does not.
Comment on lines +452 to +455

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 quotes after flow-mapping colons

For compact JSON-style YAML such as {"k":"x # y"}, the value's opening quote follows :, but the implementation only recognizes quotes after whitespace, {, [, or ,. It consequently treats the # inside the quoted value as a comment and returns the truncated string {"k":"x, contrary to this newly public contract; recognize : as a valid quoted-scalar start or document that compact flow mappings are unsupported.

Useful? React with 👍 / 👎.

With `blank_quoted`, each quoted scalar's text becomes spaces of the same
byte length, so what remains is structure at its original offsets.
"""
def strip_yaml_comment(line, blank_quoted \\ false) do
text = fn c -> if blank_quoted, do: String.duplicate(" ", byte_size(c)), else: c end

Expand Down Expand Up @@ -453,8 +495,10 @@ defmodule Hypatia.WorkflowYaml do
# properties (an anchor `&all_jobs`, a tag `!!map`) before its block value.
@jobs_key ~r/^(?:jobs|"jobs"|'jobs')\s*:\s*(?:(?:&[A-Za-z0-9_-]+|![^\s#]*)\s*)*(?:#.*)?$/

# Follow block-style jobs by indentation, retaining physical source lines.
# As with the other research rules, this is a local static text analysis.
@doc """
Follow block-style jobs by indentation, retaining physical source lines.
As with the other research rules, this is a local static text analysis.
"""
def workflow_job_lines(lines) do
{_in_jobs, _indent, groups} =
Enum.reduce(lines, {false, nil, []}, fn {line, _} = entry, {in_jobs, indent, groups} ->
Expand Down Expand Up @@ -489,22 +533,24 @@ defmodule Hypatia.WorkflowYaml do
groups |> Enum.reverse() |> Enum.map(&Enum.reverse/1)
end

# Read a workflow for the line- and indent-based rules. A KYAML workflow
# (YAML-POLICY Y-3: the estate's base form, `yq -o kyaml`) is rewritten to
# the equivalent block layout one line for one line, so physical line
# numbers in findings still point at the source. Block YAML is returned
# byte-identical.
@doc false
@doc """
Read a workflow for the line- and indent-based rules. A KYAML workflow
(YAML-POLICY Y-3: the estate's base form, `yq -o kyaml`) is rewritten to
the equivalent block layout one line for one line, so physical line
numbers in findings still point at the source. Block YAML is returned
byte-identical.
Comment on lines +537 to +541

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 Preserve quoted scalar semantics during KYAML conversion

When KYAML contains a string such as FLAG: "true",, unquote_scalar/1 emits FLAG: true, changing the value from a string to a boolean; similarly, SCRIPT: "echo # literal", becomes SCRIPT: echo # literal, exposing part of the value as a YAML comment. The result is therefore not an equivalent block layout as the public contract promises, and downstream line-based rules can inspect different content from GitHub; retain quotes unless removing them is YAML-semantics-preserving.

Useful? React with 👍 / 👎.

"""
def read_workflow(path), do: path |> File.read!() |> flow_to_block()

# Rewrite a KYAML document to block layout, preserving the line count:
# the root `{` and every closer become blank lines, `key: {`/`key: [`
# lose their opener, the first key of a `{` sequence item and each scalar
# sequence item gain `- `, trailing commas go, and plain double-quoted
# scalars are unquoted. Anything whose first significant line is not a
# bare `{` is not KYAML and passes through untouched — block YAML's own
# `on: [push]` and `permissions: {}` are never rewritten.
@doc false
@doc """
Rewrite a KYAML document to block layout, preserving the line count:
the root `{` and every closer become blank lines, `key: {`/`key: [`
lose their opener, the first key of a `{` sequence item and each scalar
sequence item gain `- `, trailing commas go, and plain double-quoted
scalars are unquoted. Anything whose first significant line is not a
bare `{` is not KYAML and passes through untouched — block YAML's own
`on: [push]` and `permissions: {}` are never rewritten.
"""
def flow_to_block(content) when is_binary(content) do
lines = String.split(content, "\n")

Expand Down Expand Up @@ -581,6 +627,7 @@ defmodule Hypatia.WorkflowYaml do
Regex.replace(~r/^((?:[^\s"':]+:\s+)?)"([^"\\]*)"$/, body, "\\1\\2")
end

@doc "The number of leading whitespace characters of `line`."
def indent_of(line) do
case Regex.run(~r/^(\s*)/, line) do
[_, ws] -> String.length(ws)
Expand Down
9 changes: 7 additions & 2 deletions test/workflow_hardening_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do
@tmp_dir System.tmp_dir!()

defp create_repo_with_workflow(yaml_content, filename \\ "test.yml") do
repo = Path.join(@tmp_dir, "wh_test_#{System.unique_integer([:positive])}")
repo = Path.join(@tmp_dir, "wh_test_#{System.pid()}_#{System.unique_integer([:positive])}")
# The OS pid keeps two concurrent VMs apart; unique_integer restarts with
# each VM, so a reused pid can meet what an earlier run left. Clear it:
# a workflow from a crashed run would otherwise be scanned with this one.
File.rm_rf!(repo)
Comment thread
hyperpolymath marked this conversation as resolved.
Resolved
wf = Path.join([repo, ".github", "workflows"])
File.mkdir_p!(wf)
File.write!(Path.join(wf, filename), yaml_content)
Expand Down Expand Up @@ -549,7 +553,8 @@ defmodule Hypatia.Rules.WorkflowHardeningTest do
end

test "returns [] on a repo without .github/workflows/" do
repo = Path.join(@tmp_dir, "wh_empty_#{System.unique_integer([:positive])}")
repo = Path.join(@tmp_dir, "wh_empty_#{System.pid()}_#{System.unique_integer([:positive])}")
File.rm_rf!(repo)
File.mkdir_p!(repo)
assert WorkflowHardening.scan(repo).total == 0
File.rm_rf!(repo)
Expand Down
Loading