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
52 changes: 52 additions & 0 deletions .github/scripts/render-zizmor-sarif-guard.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use strict";

const fs = require("node:fs");
const path = require("node:path");

const scriptsDirectory = __dirname;
const sourcePath = path.join(scriptsDirectory, "zizmor-sarif-guard.sh");
const workflowPath = path.join(
scriptsDirectory,
"..",
"workflows",
"zizmor.yml",
);
const startMarker =
" # BEGIN GENERATED ZIZMOR SARIF GUARD - DO NOT EDIT";
const endMarker = " # END GENERATED ZIZMOR SARIF GUARD";

function bundledScript(source) {
return [
"# BEGIN GENERATED ZIZMOR SARIF GUARD - DO NOT EDIT",
"# Source: .github/scripts/zizmor-sarif-guard.sh",
...source.trimEnd().split(/\r?\n/u),
"# END GENERATED ZIZMOR SARIF GUARD",
]
.map((line) => (line.length === 0 ? "" : ` ${line}`))
.join("\n");
}

function render(workflow, source) {
const start = workflow.indexOf(startMarker);
const end = workflow.indexOf(endMarker);
if (start < 0 || end < start) {
throw new Error("zizmor.yml is missing generated guard markers");
}
return `${workflow.slice(0, start)}${bundledScript(source)}${workflow.slice(end + endMarker.length)}`;
}

const current = fs.readFileSync(workflowPath, "utf8");
const source = fs.readFileSync(sourcePath, "utf8");
const expected = render(current, source);
if (process.argv.includes("--check")) {
if (current !== expected) {
process.stderr.write(
"zizmor.yml is out of sync; run node .github/scripts/render-zizmor-sarif-guard.cjs\n",
);
process.exitCode = 1;
}
} else {
fs.writeFileSync(workflowPath, expected, "utf8");
}

module.exports = Object.freeze({ bundledScript, render });
45 changes: 37 additions & 8 deletions .github/scripts/zizmor-native.test.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use strict";

const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
Expand Down Expand Up @@ -48,13 +49,15 @@ test("native zizmor preserves the reusable interface and read-only boundary", ()
assert.equal(inputDefault("sha256"), pinnedSha256);
assert.equal(inputDefault("online-audits"), "true");
assert.equal(inputDefault("persona"), "regular");
assert.equal(inputDefault("fail-on-severity"), "never");
assert.equal(inputDefault("fail-on-findings"), "false");

for (const existingInput of [
"paths",
"version",
"online-audits",
"persona",
"fail-on-severity",
"fail-on-findings",
]) {
assert.match(workflow, new RegExp(`^ {6}${existingInput}:$`, "mu"));
Expand Down Expand Up @@ -115,28 +118,54 @@ test("native zizmor limits token exposure and fails closed outside findings", ()
assert.ok(unsetToken >= 0 && unsetToken < download);
assert.ok(download < verifiedExecution);

assert.match(step, /--format=github/u);
assert.match(step, /--format=sarif/u);
assert.doesNotMatch(step, /--format=github/u);
assert.match(step, /--cache-dir=\$work_dir\/cache/u);
assert.match(step, /args\+=\(--no-online-audits\)/u);
// zizmor writes SARIF to stdout (no --output flag); the report is redirected
// to a file for the guard to validate.
assert.match(
step,
/GH_TOKEN="\$token" "\$binary" "\$\{args\[@\]\}" -- "\$\{targets\[@\]\}"/u,
/GH_TOKEN="\$token" "\$binary" "\$\{args\[@\]\}" -- "\$\{targets\[@\]\}" >"\$sarif"/u,
);
assert.doesNotMatch(step, /"\$binary"[^\n]*--output/u);
assert.doesNotMatch(
step,
/^\s*"\$binary" "\$\{args\[@\]\}" -- "\$\{targets\[@\]\}"$/mu,
);
assert.doesNotMatch(step, /continue-on-error/u);
assert.match(step, /^ {12}11\|12\|13\|14\)$/mu);
assert.doesNotMatch(step, /11\|12\|13\|14/u);

// fail-on-severity wins above 'never'; fail-on-findings is the legacy alias
// that resolves to the 'low' threshold.
assert.match(step, /if \[\[ "\$FAIL_ON_SEVERITY" != never \]\]; then/u);
assert.match(step, /effective_severity=\$FAIL_ON_SEVERITY/u);
assert.match(
step,
/if \[\[ "\$FAIL_ON_FINDINGS" == false \]\]; then[\s\S]*?exit 0/u,
/elif \[\[ "\$FAIL_ON_FINDINGS" == true \]\]; then\s*\n\s*effective_severity=low/u,
);
assert.match(
step,
/zizmor failed before completing a valid advisory audit \(exit \$status\)/u,
assert.match(step, /ZIZMOR_EXIT_CODE=\$status/u);
assert.match(step, /ZIZMOR_SARIF=\$sarif/u);
assert.match(step, /ZIZMOR_VERSION=\$resolved_version/u);
assert.match(step, /FAIL_ON_SEVERITY=\$effective_severity/u);
});

test("zizmor result handling is generated from the tested SARIF guard", () => {
const renderer = path.join(__dirname, "render-zizmor-sarif-guard.cjs");
const result = spawnSync(process.execPath, [renderer, "--check"], {
encoding: "utf8",
});
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);

assert.match(workflow, /# BEGIN GENERATED ZIZMOR SARIF GUARD - DO NOT EDIT/u);
assert.match(workflow, /# END GENERATED ZIZMOR SARIF GUARD/u);
assert.equal(
workflow.match(/Source: \.github\/scripts\/zizmor-sarif-guard\.sh/gu)
?.length,
1,
);
assert.match(step, /exit "\$status"/u);
assert.match(workflow, /in SARIF mode; results are not trusted/u);
assert.match(workflow, /at or above severity \$FAIL_ON_SEVERITY/u);
});

test("documentation removes only the retired zizmor Docker exception", () => {
Expand Down
215 changes: 215 additions & 0 deletions .github/scripts/zizmor-sarif-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# shellcheck shell=bash
set -euo pipefail

: "${ZIZMOR_EXIT_CODE:?ZIZMOR_EXIT_CODE is required}"
: "${ZIZMOR_SARIF:?ZIZMOR_SARIF is required}"
: "${ZIZMOR_VERSION:?ZIZMOR_VERSION is required}"
: "${FAIL_ON_SEVERITY:?FAIL_ON_SEVERITY is required}"

case "$FAIL_ON_SEVERITY" in
never | low | medium | high) ;;
*)
echo '::error::fail-on-severity must resolve to never, low, medium, or high.'
exit 2
;;
esac

if [[ ! "$ZIZMOR_EXIT_CODE" =~ ^[0-9]+$ ]]; then
echo '::error::zizmor did not report a numeric exit code.'
exit 2
fi
# zizmor exits 0 in SARIF mode regardless of findings, so any nonzero code is an
# infrastructure failure (download, argument, or collection error), not a
# finding signal; fail closed rather than trust a partial result.
if ((ZIZMOR_EXIT_CODE != 0)); then
echo "::error::zizmor exited $ZIZMOR_EXIT_CODE in SARIF mode; results are not trusted."
exit 2
fi
if [[ ! -f "$ZIZMOR_SARIF" || -L "$ZIZMOR_SARIF" ]]; then
echo '::error::zizmor SARIF output is missing or is not a regular file.'
exit 2
fi

# zizmor's SARIF driver reports the plain X.Y.Z semanticVersion (no leading v),
# so strip any caller-supplied prefix before pinning provenance.
expected_version="${ZIZMOR_VERSION#v}"
if ! jq -e --arg version "$expected_version" '
type == "object" and
.version == "2.1.0" and
(.runs | type == "array" and length == 1) and
(.runs[0].tool.driver | type == "object") and
.runs[0].tool.driver.name == "zizmor" and
.runs[0].tool.driver.semanticVersion == $version and
(.runs[0].results | type == "array") and
all(.runs[0].results[];
type == "object" and
(.ruleId | type == "string" and length > 0) and
(.level == "error" or .level == "warning" or .level == "note") and
(.message.text | type == "string" and length > 0)
)
' "$ZIZMOR_SARIF" >/dev/null 2>&1; then
echo '::error::zizmor SARIF is malformed or violates the pinned provenance schema.'
exit 2
fi

annotate_findings() {
local level kind file line message normalized_file uri_safe uri_base64 message_safe message_base64
escape_property() {
local v="$1"
v=${v//'%'/'%25'}
v=${v//$'\r'/'%0D'}
v=${v//$'\n'/'%0A'}
v=${v//':'/'%3A'}
v=${v//','/'%2C'}
printf '%s' "$v"
}
escape_data() {
local v="$1"
v=${v//'%'/'%25'}
v=${v//$'\r'/'%0D'}
v=${v//$'\n'/'%0A'}
printf '%s' "$v"
}
decode_base64_field() {
local encoded="$1" target="$2" decoded status
decoded="$(
set +e
jq -jnr --arg encoded "$encoded" '$encoded | @base64d' 2>/dev/null
status=$?
printf '\036'
exit "$status"
)" || return 1
decoded="${decoded%$'\036'}"
printf -v "$target" '%s' "$decoded"
}
normalize_sarif_uri() {
local uri="$1" encoded decoded='' remainder prefix hex byte
local workspace candidate resolved relative

[[ -n "$uri" && -n "${GITHUB_WORKSPACE:-}" ]] || return 1
workspace="$(realpath -e -- "$GITHUB_WORKSPACE" 2>/dev/null)" || return 1
[[ -d "$workspace" ]] || return 1

if [[ "$uri" == file:///* ]]; then
encoded="${uri#file://}"
elif [[ "$uri" == file://* || "$uri" =~ ^[A-Za-z][A-Za-z0-9+.-]*: ]]; then
return 1
elif [[ "$uri" == /* ]]; then
return 1
else
encoded="$uri"
fi
[[ -n "$encoded" && "$encoded" != *'?'* && "$encoded" != *'#'* ]] || return 1
[[ "${encoded,,}" != *'%00'* ]] || return 1

remainder="$encoded"
while [[ "$remainder" == *%* ]]; do
prefix="${remainder%%\%*}"
remainder="${remainder#*\%}"
[[ "$remainder" =~ ^([0-9A-Fa-f]{2}) ]] || return 1
hex="${BASH_REMATCH[1]}"
if ((16#$hex < 32 || 16#$hex == 127)); then
return 1
fi
printf -v byte '%b' "\\x$hex"
decoded+="$prefix$byte"
remainder="${remainder:2}"
done
decoded+="$remainder"
[[ -n "$decoded" && "$decoded" != *\\* ]] || return 1
if printf '%s' "$decoded" | LC_ALL=C grep -q '[[:cntrl:]]'; then
return 1
fi

if [[ "$uri" == file:///* ]]; then
candidate="$decoded"
else
candidate="$workspace/$decoded"
fi
resolved="$(realpath -e -- "$candidate" 2>/dev/null)" || return 1
[[ "$resolved" == "$workspace" || "$resolved" == "$workspace/"* ]] || return 1
if [[ "$resolved" == "$workspace" ]]; then
relative='.'
else
relative="${resolved#"$workspace/"}"
fi
printf '%s' "$relative"
}
while IFS='|' read -r level uri_safe uri_base64 line message_safe message_base64; do
message_base64="${message_base64%$'\r'}"
case "$level" in
error) kind=error ;;
warning) kind=warning ;;
note) kind=notice ;;
*) kind=error ;;
esac
if [[ "$message_safe" == true ]]; then
# shellcheck disable=SC2310 # decoder status selects a safe fallback explicitly.
if ! decode_base64_field "$message_base64" message; then
message='zizmor finding'
fi
else
message='zizmor finding'
fi
message="$(escape_data "$message")"
# shellcheck disable=SC2310 # normalization returns status; fallible body commands are checked.
if [[ "$uri_safe" == true ]] && decode_base64_field "$uri_base64" file && normalized_file="$(normalize_sarif_uri "$file")"; then
file="$(escape_property "$normalized_file")"
[[ "$line" =~ ^[1-9][0-9]*$ ]] || line=1
line="$(escape_property "$line")"
echo "::$kind file=$file,line=$line::$message"
else
echo "::$kind::$message"
fi
done < <(jq -r '
[.runs[0].results[]][:50][]
| (.locations[0].physicalLocation.artifactLocation.uri // "") as $uri
| (.message.text // "zizmor finding") as $message
| [
.level,
(if ($uri | type) == "string" then ($uri | explode | all(. >= 32 and . != 127)) else false end),
($uri | if type == "string" then @base64 else "" end),
(.locations[0].physicalLocation.region.startLine // 1 | if type == "number" and . >= 1 and . == floor then tostring else "1" end),
(if ($message | type) == "string" then ($message | explode | all((. >= 32 or . == 9 or . == 10 or . == 13) and . != 127)) else false end),
($message | if type == "string" then @base64 else ("zizmor finding" | @base64) end)
]
| join("|")
' "$ZIZMOR_SARIF")
}

annotate_findings

# Emit annotations for every finding above, then gate on severity. The blocking
# level set maps FAIL_ON_SEVERITY to SARIF result levels (high->error,
# medium->warning, low|informational->note); never blocks on nothing.
case "$FAIL_ON_SEVERITY" in
never) blocking_levels='[]' ;;
low) blocking_levels='["error","warning","note"]' ;;
medium) blocking_levels='["error","warning"]' ;;
high) blocking_levels='["error"]' ;;
*)
echo '::error::fail-on-severity must resolve to never, low, medium, or high.'
exit 2
;;
esac

total_count="$(jq '.runs[0].results | length' "$ZIZMOR_SARIF")"
blocking_count="$(jq --argjson levels "$blocking_levels" '
[.runs[0].results[] | select(.level as $l | $levels | index($l))] | length
' "$ZIZMOR_SARIF")"

if ((blocking_count > 0)); then
ids="$(jq -r --argjson levels "$blocking_levels" '
[.runs[0].results[] | select(.level as $l | $levels | index($l)) | .ruleId] | unique | join(", ")
' "$ZIZMOR_SARIF")"
echo "::error::zizmor reported $blocking_count finding(s) at or above severity $FAIL_ON_SEVERITY: $ids"
exit 1
fi

if ((total_count == 0)); then
echo 'zizmor completed with no findings.'
elif [[ "$FAIL_ON_SEVERITY" == never ]]; then
echo "::notice::zizmor reported $total_count finding(s); advisory mode keeps this lane successful."
else
echo "::notice::zizmor reported $total_count finding(s); none at or above severity $FAIL_ON_SEVERITY."
fi
Loading
Loading