-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaction.yml
More file actions
330 lines (311 loc) · 16.6 KB
/
Copy pathaction.yml
File metadata and controls
330 lines (311 loc) · 16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
name: bulwark
description: Install bulwark, run scan/coverage, post a PR summary, and report to Semgrep AppSec Platform / Codecov
inputs:
version:
description: bulwark version to install (omit or set to "latest" for the latest release)
default: latest
dir:
description: >
directory to scan (passed to bulwark's --dir). This stays an input and
cannot move into .bulwark.yml: that file lives AT the scan root, so
bulwark has to be told the root before it can read its own config.
default: "."
run-scan:
description: >
run `bulwark scan`. On pull_request events this scopes Semgrep to the PR's own diff, which
requires the calling workflow's checkout step to use `fetch-depth: 0` (bulwark resolves the
merge-base with origin/main to do so).
default: "true"
run-coverage:
description: >
run `bulwark coverage`. Requires the calling workflow's checkout step to use
`fetch-depth: 0` (bulwark needs full history to resolve the PR's base commit and to check
out historical commits for baseline computation), and `contents: write` permission to cache
baselines on the bulwark-state branch — a failure to cache is non-fatal (best-effort), but a
failure to even diff coverage is not.
default: "true"
# Removed in favour of .bulwark.yml at the scan root: tests-mode, go-report,
# rust-report, rust-lcov-report. Who produces coverage is a property of how
# a repo's pipeline is built — the same answer for every workflow that calls
# this action — so it belongs in the repo's own config file, not restated at
# each call site. The replacements are:
#
# coverage:
# source: report # was tests-mode: skip ("run" is still the default)
# go:
# report: coverage.out
# rust:
# report: coverage/llvm-cov.json
# lcov: coverage/lcov.info
#
# Each report key also accepts a mapping of unit dir to path for a repo with
# more than one Go module or Rust crate, which is what the newline-separated
# "<dir>=<path>" input encoding was straining to express. Most repos need
# neither: the conventional per-unit paths are searched without configuration.
semgrep-app-token:
description: >
Semgrep AppSec Platform token. When set, bulwark's Semgrep check runs `semgrep ci` instead
of `semgrep scan` — Semgrep's own diff-aware CI mode, which also uploads results to the
platform dashboard. Omit to keep Semgrep local-only (today's default behavior).
required: false
codecov-token:
description: >
Codecov upload token. When set, uploads whatever coverage report(s) and JUnit test-result
file(s) bulwark found/produced to Codecov for its dashboard/history/PR-comment —
non-blocking, purely informational; the actual pass/fail coverage gate is bulwark coverage
itself, not Codecov.
required: false
github-token:
description: token used to post/update the PR summary comment (needs pull-requests:write)
default: ${{ github.token }}
runs:
using: composite
steps:
- name: Install bulwark
shell: bash
env:
VERSION: ${{ inputs.version }}
run: |
# install.sh is always fetched from the latest release; BULWARK_VERSION
# pins which bulwark binary it installs. install.sh itself verifies the
# downloaded binary's SHA-256 against the release's checksums.txt
# before installing it. install.sh is fetched to a file and executed
# as a separate step (not piped straight from curl into sh) so the
# full script is downloaded atomically before anything runs.
if [[ "${VERSION}" != "latest" ]]; then
export BULWARK_VERSION="${VERSION}"
fi
export BULWARK_INSTALL_DIR="${RUNNER_TEMP}/bulwark-bin"
curl -fsSL -o "${RUNNER_TEMP}/bulwark-install.sh" https://github.com/wardnet/bulwark/releases/latest/download/install.sh
sh "${RUNNER_TEMP}/bulwark-install.sh"
echo "${BULWARK_INSTALL_DIR}" >> "$GITHUB_PATH"
- name: bulwark scan
if: inputs.run-scan == 'true'
id: scan
shell: bash
env:
SEMGREP_APP_TOKEN: ${{ inputs.semgrep-app-token }}
DIR: ${{ inputs.dir }}
# On a PR, scope Semgrep to what the PR introduces. With a token that's
# already what `semgrep ci` does and bulwark ignores this; without one
# (GitHub withholds secrets from Dependabot events, so every Dependabot
# PR lands here) it's what stops the token-less `semgrep scan` fallback
# from blocking on the repo's pre-existing findings — findings the PR
# never touched, and that no token-bearing run has ever reported.
# Requires the caller's checkout to have full history (fetch-depth: 0);
# bulwark errors rather than silently widening the scan back out.
DIFF_BASE: ${{ github.event_name == 'pull_request' && 'auto' || '' }}
run: |
set +e
args=(scan --dir "$DIR")
if [[ -n "$DIFF_BASE" ]]; then args+=(--diff-base "$DIFF_BASE"); fi
bulwark "${args[@]}" 2>&1 | tee "${RUNNER_TEMP}/bulwark-scan-output.txt"
echo "exit-code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: bulwark coverage
if: inputs.run-coverage == 'true'
id: coverage
shell: bash
env:
DIR: ${{ inputs.dir }}
run: |
set +e
# No coverage-production flags here by design. bulwark reads
# .bulwark.yml from "$DIR" itself — who produces coverage
# (coverage.source) and where its reports live (coverage.go.report,
# coverage.rust.{report,lcov}) all come from there. The action used to
# re-encode those as inputs and splice them back into flags, which
# meant a repo's coverage wiring was split across two files and the
# multi-crate case could only be expressed as newline-separated
# "<dir>=<path>" strings.
bulwark coverage --dir "$DIR" 2>&1 | tee "${RUNNER_TEMP}/bulwark-coverage-output.txt"
echo "exit-code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: Upload coverage to Codecov
if: inputs.run-coverage == 'true' && inputs.codecov-token != ''
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ inputs.codecov-token }}
report_type: coverage
fail_ci_if_error: false
- name: Upload test results to Codecov
if: inputs.run-coverage == 'true' && inputs.codecov-token != ''
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ inputs.codecov-token }}
report_type: test_results
fail_ci_if_error: false
- name: Upload bulwark output artifact
# Capture the full scan/coverage logs as a run artifact so the PR comment
# can link to them instead of inlining thousands of lines. A comment that
# large is unreadable and also exceeds GitHub's 65536-char limit, which
# hard-fails the sticky-comment step. `always()` so the logs are captured
# even when a check failed — that's exactly when the full log matters.
if: always() && (inputs.run-scan == 'true' || inputs.run-coverage == 'true')
id: output-artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
# Job-scoped name so two jobs in the same run don't collide (v4+ errors
# on a duplicate artifact name within a run).
name: bulwark-output-${{ github.job }}
path: |
${{ runner.temp }}/bulwark-scan-output.txt
${{ runner.temp }}/bulwark-coverage-output.txt
if-no-files-found: ignore
retention-days: 14
- name: Build PR summary
if: github.event_name == 'pull_request' && (inputs.run-scan == 'true' || inputs.run-coverage == 'true')
id: summary
shell: bash
env:
RUN_SCAN: ${{ inputs.run-scan }}
RUN_COVERAGE: ${{ inputs.run-coverage }}
SCAN_EXIT_CODE: ${{ steps.scan.outputs.exit-code }}
COVERAGE_EXIT_CODE: ${{ steps.coverage.outputs.exit-code }}
ARTIFACT_URL: ${{ steps.output-artifact.outputs.artifact-url }}
run: |
set +e
summary="${RUNNER_TEMP}/bulwark-pr-summary.md"
scan_out="${RUNNER_TEMP}/bulwark-scan-output.txt"
coverage_out="${RUNNER_TEMP}/bulwark-coverage-output.txt"
# Best-effort finding counts scraped from each scanner's own summary
# output, for a one-line PR comment header — informational only, the
# actual pass/fail gate is each command's exit code, not these counts.
gosec_count() { grep -oE 'Issues[[:space:]]*:[[:space:]]*[0-9]+' "$1" | grep -oE '[0-9]+' | tail -1; }
govulncheck_count() { grep -c '^Vulnerability #' "$1"; }
# In CI mode (semgrep ci, diff-aware — used when semgrep-app-token is set), Semgrep
# prints two different finding counts: "Current version has N findings." (the whole
# repo, unrelated to this PR) and " • Findings: N (M blocking)" (this PR's diff — the
# number that actually determines pass/fail). Only the latter is meaningful here; local
# `semgrep scan` (no token) has no blocking concept, so falls back to its own
# "Ran ... rules on ... files: N findings." total for both fields.
semgrep_count() {
local line total blocking
line=$(grep -oE 'Findings:[[:space:]]*[0-9]+[[:space:]]*\([0-9]+ blocking\)' "$1" | tail -1)
if [[ -n "$line" ]]; then
read -r total blocking <<< "$(echo "$line" | grep -oE '[0-9]+' | tr '\n' ' ')"
else
total=$(grep -oE '[0-9]+ findings\.' "$1" | tail -1 | grep -oE '[0-9]+')
blocking="$total"
fi
echo "${total:-0} ${blocking:-0}"
}
# tool_result reads a tool's own [PASS]/[FAIL] line (bulwark scan's own report()
# output) — the ground truth for whether that specific tool failed, since a nonzero
# SCAN_EXIT_CODE only says "at least one check failed", not which one.
# The optional "<dir>: " prefix matches how rust.crateLabel and
# golang.moduleLabel qualify a tool name once more than one crate or
# module is discovered ("sdk/wardnet-go: gosec"). Without it the
# anchored pattern silently matches nothing in exactly the multi-module
# repos that need attribution, leaving the comment unable to say which
# tool failed. FAIL anywhere wins: "FAIL" sorts before "PASS", so
# `sort | head -1` reports FAIL if any crate or module failed; taking
# the last line instead would let a passing module mask a failing one.
tool_result() { grep -oE "^\[(PASS|FAIL)\] (.*: )?$2\$" "$1" | sed -E 's/^\[([A-Z]+)\].*/\1/' | sort | head -1; }
# Max bytes of raw tool output to inline per section. GitHub caps a PR
# comment at 65536 chars, and the full logs (multi-thousand-line cargo /
# compile output) blow past that on their own — so the complete logs go
# to the run artifact instead. On failure we still inline the TAIL of the
# output, where the tool's error and summary surface, so the actual error
# is always visible in the comment without opening the artifact.
EXCERPT_CAP=20000
# Strip ANSI colour codes so the inlined excerpt is readable as plain
# text (and a touch smaller); the artifact keeps the raw log.
strip_ansi() { sed -E 's/\x1b\[[0-9;]*[A-Za-z]//g'; }
# Inline a bounded excerpt of a failing tool's output. No-op on success:
# a passing multi-thousand-line log helps no one and risks the size cap.
emit_error_output() {
local file="$1" code="$2" size
[[ "$code" == "0" ]] && return 0
[[ -f "$file" ]] || return 0
size=$(wc -c < "$file" | tr -d ' ')
echo
echo "<details><summary>error output</summary>"
echo
echo '```'
if (( size > EXCERPT_CAP )); then
echo "… truncated to the last ${EXCERPT_CAP} bytes — full log in the run artifact linked below …"
tail -c "$EXCERPT_CAP" "$file" | strip_ansi
else
strip_ansi < "$file"
fi
echo '```'
echo
echo "</details>"
}
{
# The logo is referenced by absolute raw URL, not a repo-relative path: this comment is
# posted into the *consuming* repo's PR, where `assets/...` would resolve against that
# repo and 404. Pinned to bulwark's default branch rather than a release tag so the
# image keeps resolving for consumers still pinned to an older bulwark version.
echo '## <img src="https://raw.githubusercontent.com/wardnet/bulwark/main/assets/bulwark-logo.png" alt="" width="20" align="top"> bulwark'
echo
if [[ "$RUN_SCAN" == "true" ]]; then
if [[ "$SCAN_EXIT_CODE" == "0" ]]; then
echo "- ✅ **scan** — no findings"
else
parts=()
if [[ "$(tool_result "$scan_out" gosec)" == "FAIL" ]]; then
n=$(gosec_count "$scan_out"); [[ -n "$n" && "$n" != "0" ]] && parts+=("${n} gosec")
fi
if [[ "$(tool_result "$scan_out" govulncheck)" == "FAIL" ]]; then
n=$(govulncheck_count "$scan_out"); [[ -n "$n" && "$n" != "0" ]] && parts+=("${n} govulncheck")
fi
if [[ "$(tool_result "$scan_out" semgrep)" == "FAIL" ]]; then
read -r n _ <<< "$(semgrep_count "$scan_out")"
[[ -n "$n" && "$n" != "0" ]] && parts+=("${n} semgrep")
fi
if [[ "${#parts[@]}" -gt 0 ]]; then
detail=$(IFS=,; echo "${parts[*]}" | sed 's/,/, /g')
echo "- ❌ **scan** — ${detail} findings"
else
echo "- ❌ **scan** — failed, see error output below"
fi
# The error itself is always surfaced inline on failure, per the
# reporting contract — not just linked away in the artifact.
emit_error_output "$scan_out" "$SCAN_EXIT_CODE"
fi
echo
fi
if [[ "$RUN_COVERAGE" == "true" ]]; then
cov_icon="✅"
[[ "$COVERAGE_EXIT_CODE" != "0" ]] && cov_icon="❌"
# Not line-anchored: bulwark's own output has run the cached-baseline
# JSON blob and the "[PASS] go: ..." summary together with no
# newline between them (e.g. "}[PASS] go: 56.9% (baseline 56.9%)"),
# so a `^`-anchored match silently finds nothing on a real run.
# The tag itself is deliberately generic ([A-Z]+, not an enumerated
# PASS|FAIL|... list): bulwark's gate output is the only bracketed
# text on coverage stdout, and enumerating tags silently dropped
# [UNMEASURED] lines from the comment when that tag was added.
cov_detail=$(grep -oE '\[[A-Z]+\][[:space:]]+.*' "$coverage_out" | sed -E 's/^\[[A-Z]+\][[:space:]]+//' | paste -sd ';' - | sed 's/;/; /g')
[[ -z "$cov_detail" ]] && cov_detail="see error output below"
echo "- ${cov_icon} **coverage** — ${cov_detail}"
emit_error_output "$coverage_out" "$COVERAGE_EXIT_CODE"
echo
fi
if [[ -n "${ARTIFACT_URL}" ]]; then
echo "📦 [Full bulwark output](${ARTIFACT_URL}) — complete scan and coverage logs."
fi
} > "${summary}"
# Final safety net: never exceed GitHub's 65536-char comment limit, even
# if two bounded excerpts plus headers somehow approach it. Truncating
# here still leaves the full logs available in the artifact.
limit=65000
if (( $(wc -c < "$summary" | tr -d ' ') > limit )); then
head -c "$limit" "$summary" > "${summary}.tmp"
printf '\n\n… comment truncated to fit GitHub'"'"'s 65536-char limit — see the run artifact for full output …\n' >> "${summary}.tmp"
mv "${summary}.tmp" "$summary"
fi
echo "summary-path=${summary}" >> "$GITHUB_OUTPUT"
- name: Post PR summary
if: github.event_name == 'pull_request' && (inputs.run-scan == 'true' || inputs.run-coverage == 'true')
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
GITHUB_TOKEN: ${{ inputs.github-token }}
header: bulwark
path: ${{ steps.summary.outputs.summary-path }}
- name: Fail if any check failed
if: (inputs.run-scan == 'true' && steps.scan.outputs.exit-code != '0') || (inputs.run-coverage == 'true' && steps.coverage.outputs.exit-code != '0')
shell: bash
run: |
echo "bulwark scan or coverage failed — see the steps above for details." >&2
exit 1