-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaction.yml
More file actions
633 lines (598 loc) · 27.8 KB
/
action.yml
File metadata and controls
633 lines (598 loc) · 27.8 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
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Blackout Secure Code Scanning Kit
# Copyright © 2025-2026 Blackout Secure
# Licensed under Apache License 2.0
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
name: 'Blackout Secure Code Scanning Kit'
# MP010: keep <= 125 chars; full feature list lives in README.md.
description: >-
Run secret/code scanners and posture audits, then upload a unified SARIF log to GitHub Advanced Security.
author: 'Blackout Secure'
branding:
color: 'blue'
icon: 'shield'
# Composite Action orchestration:
#
# detect → walks the repo and prints a one-line ecosystem summary.
# posture → REST-API audit (GHAS toggles, workflow perms, branch
# protection, required reviews, CODEOWNERS). Emits SARIF.
# actionlint, gitleaks, shellcheck — always-on / conditional scanners,
# each producing its own SARIF.
# merge → folds every scanner's SARIF + the posture SARIF into
# one upload-ready artefact.
# upload → posts the merged SARIF to GitHub Advanced Security so
# findings land on the repo Security tab.
#
# Per-repo customization lives in `.bos-scan.yml` (auto-discovered at
# the repo root). The inputs below are the small set of operator-level
# overrides for CI; everything else (rule severities, branch
# expectations, scanner exclusions) belongs in the YAML config.
inputs:
# ----- Identification -----
owner:
description: 'GitHub owner of the repo being scanned. Defaults to the workflow context.'
required: false
default: ''
repo:
description: 'GitHub repo name being scanned. Defaults to the workflow context.'
required: false
default: ''
config:
description: 'Path to `.bos-scan.yml`. Defaults to auto-discovery at the repo root.'
required: false
default: ''
# ----- Authentication -----
#
# Recommended caller pattern (prefers SCANNING_PAT when the org has set it,
# otherwise falls back to the workflow's GITHUB_TOKEN automatically):
#
# - uses: blackoutsecure/bos-code-scanning-kit@v1
# with:
# github_token: ${{ secrets.SCANNING_PAT || secrets.GITHUB_TOKEN }}
#
# Implementation note: action manifest scalars (defaults AND descriptions)
# cannot contain GitHub expression syntax — the legacy runner manifest
# parser evaluates such expressions at load time and rejects unknown
# contexts (e.g. `github.token` resolves only in workflow scope, not action
# manifest scope). That is why this input's default is the empty string and
# the `github.token` fallback is applied at the step level instead.
github_token:
description: >-
Token used by the posture audit (PS001 code scanning, PS002 secret
scanning, PS003 Dependabot alerts, PS020-PS025 branch protection).
Leave empty to fall back to the workflow's built-in GITHUB_TOKEN,
which is enough for PS001 only. PS002/PS003/PS020-PS025 require a
PAT with admin reach — by org convention stored as a secret named
`SCANNING_PAT`. See the kit README § 'SCANNING_PAT — advanced
posture credentials' for the classic / fine-grained tick checklist
and the recommended caller pattern.
required: false
default: ''
# ----- Scope gates (off → skip an entire stage) -----
enable_posture:
description: '`true` to run the posture audit step.'
required: false
default: 'true'
enable_scanners:
description: '`true` to run the bundled scanners (actionlint / gitleaks / shellcheck).'
required: false
default: 'true'
enable_upload:
description: '`true` to upload the merged SARIF to GitHub Advanced Security.'
required: false
default: 'true'
# ----- Failure policy -----
fail_on:
description: >-
`fail` (default) — exit non-zero if posture has any FAIL findings
or any scanner reports a result. `never` — collect findings but
always exit 0 (useful for first-time rollouts).
required: false
default: 'fail'
# ----- Tuning -----
http_timeout:
description: >-
Per-request HTTP timeout (seconds) for the posture audit's GitHub
REST calls. Default `20`. Each probe is independent, so the
practical upper bound on a posture run is roughly
`http_timeout` * number-of-probes (~10 on a baseline scan).
Bump on self-hosted runners with slow egress, or to ride out
brief GitHub API latency spikes that otherwise surface as
`PS*** error: HTTP 502` rows. Bare integer string; no unit.
required: false
default: '20'
# ----- Output -----
sarif_output:
description: 'Path for the merged SARIF artefact.'
required: false
default: 'bos-scan.sarif'
outputs:
sarif_path:
description: 'Path to the merged SARIF file produced by the run.'
value: ${{ steps.merge.outputs.sarif_path }}
posture_failures:
description: 'Number of FAIL findings from the posture audit.'
value: ${{ steps.posture.outputs.failures }}
outcome:
description: >-
Severity-tier verdict for the run: `success` (no findings at any
level), `warn` (only warning/note-level findings — nothing the
enforcement policy would block on), or `failure` (at least one
error-level finding from the posture audit or any scanner).
Reflects severity only — it does NOT change based on `fail_on`,
so callers can gate pipelines on the verdict independently of
whether the kit step itself exited non-zero.
value: ${{ steps.summary.outputs.outcome }}
runs:
using: composite
steps:
# ----- 0. Install the kit itself so `bos-scan` is on PATH ---------
# SHA pin per Marketplace SC002 hygiene; bump via Dependabot.
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install bos-code-scanning-kit
shell: bash
run: |
set -euo pipefail
python3 -m pip install --quiet --upgrade pip
python3 -m pip install --quiet "${GITHUB_ACTION_PATH}"
# ----- 1. Ecosystem detection ---------------------------------------
- id: detect
name: Detect ecosystems
shell: bash
run: |
set -euo pipefail
bos-scan detect --root "${GITHUB_WORKSPACE}"
# ----- 2. Posture audit (Python) ------------------------------------
- id: posture
name: Posture audit (GHAS / workflows / branches / CODEOWNERS)
if: inputs.enable_posture == 'true'
shell: bash
env:
GH_OWNER: ${{ inputs.owner || github.repository_owner }}
GH_REPO: ${{ inputs.repo || github.event.repository.name }}
GH_CFG: ${{ inputs.config }}
GH_TOKEN: ${{ inputs.github_token || github.token }}
FAIL_ON: ${{ inputs.fail_on }}
HTTP_TIMEOUT: ${{ inputs.http_timeout }}
run: |
set -euo pipefail
cfg_flag=()
[[ -n "${GH_CFG}" ]] && cfg_flag=(--config "${GH_CFG}")
# Always write the SARIF even on failure so the merge step
# has it for upload.
set +e
bos-scan posture \
--owner "${GH_OWNER}" \
--repo "${GH_REPO}" \
--token "${GH_TOKEN}" \
--root "${GITHUB_WORKSPACE}" \
--sarif "${GITHUB_WORKSPACE}/bos-scan-posture.sarif" \
--skips-json "${GITHUB_WORKSPACE}/bos-scan-posture-skips.json" \
--fail-on "${FAIL_ON}" \
--http-timeout "${HTTP_TIMEOUT}" \
"${cfg_flag[@]}"
ec=$?
set -e
# Record failure count for the workflow output. The CLI's own
# exit code already encodes this; the count is informational.
# JSON-parse the SARIF so whitespace/quote-style in the file
# cannot skew the count (the prior `grep -c '"level": "error"'`
# was brittle against compact-formatted SARIF).
if [[ -f "${GITHUB_WORKSPACE}/bos-scan-posture.sarif" ]]; then
fails=$(python3 - <<'PY' "${GITHUB_WORKSPACE}/bos-scan-posture.sarif"
import json, sys
try:
d = json.load(open(sys.argv[1]))
except Exception:
print(0); sys.exit(0)
print(sum(1 for r in d.get("runs", [])
for x in r.get("results", [])
if x.get("level") == "error"))
PY
)
else
fails=0
fi
echo "failures=${fails}" >> "${GITHUB_OUTPUT}"
# Defer hard failure to the merge step so SARIF still uploads.
# We record the exit code as a step output instead.
echo "exit_code=${ec}" >> "${GITHUB_OUTPUT}"
# ----- 3a. actionlint (workflow YAML) -------------------------------
- id: actionlint
name: actionlint (GitHub workflow YAML)
if: inputs.enable_scanners == 'true' && hashFiles('.github/workflows/*.yml', '.github/workflows/*.yaml') != ''
shell: bash
run: |
set -euo pipefail
# Install actionlint (single static binary; ~1MB).
# Branch on RUNNER_ARCH so this works on both x64 and ARM64
# GitHub-hosted runners.
AL_VERSION=1.7.1
case "${RUNNER_ARCH:-X64}" in
X64) al_arch=amd64 ;;
ARM64) al_arch=arm64 ;;
*)
echo "::warning::unsupported RUNNER_ARCH=${RUNNER_ARCH}; defaulting to amd64"
al_arch=amd64
;;
esac
AL_TARBALL="actionlint_${AL_VERSION}_linux_${al_arch}.tar.gz"
curl -sSLo /tmp/actionlint.tar.gz \
"https://github.com/rhysd/actionlint/releases/download/v${AL_VERSION}/${AL_TARBALL}"
tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint
# Run with SARIF output (actionlint has built-in SARIF since v1.7).
# `|| true` because we collect findings even on non-zero so the
# merge step always runs.
/tmp/actionlint -format '{{sarif .}}' \
> "${GITHUB_WORKSPACE}/bos-scan-actionlint.sarif" 2>/dev/null || true
# If the template isn't supported in this version, fall back to
# a minimal empty SARIF so the merge step doesn't break.
if ! head -c1 "${GITHUB_WORKSPACE}/bos-scan-actionlint.sarif" | grep -q '{'; then
printf '{"version":"2.1.0","runs":[{"tool":{"driver":{"name":"actionlint"}}, "results":[]}]}\n' \
> "${GITHUB_WORKSPACE}/bos-scan-actionlint.sarif"
fi
# ----- 3b. gitleaks (secrets) ---------------------------------------
- id: gitleaks
name: gitleaks (secret scan)
if: inputs.enable_scanners == 'true'
shell: bash
run: |
set -euo pipefail
GL_VERSION=8.21.2
case "${RUNNER_ARCH:-X64}" in
X64) gl_arch=x64 ;;
ARM64) gl_arch=arm64 ;;
*)
echo "::warning::unsupported RUNNER_ARCH=${RUNNER_ARCH}; defaulting to x64"
gl_arch=x64
;;
esac
curl -sSLo /tmp/gitleaks.tar.gz \
"https://github.com/gitleaks/gitleaks/releases/download/v${GL_VERSION}/gitleaks_${GL_VERSION}_linux_${gl_arch}.tar.gz"
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
chmod +x /tmp/gitleaks
# `detect` over the working tree — no `--no-git` so historical
# blobs are scanned where available.
/tmp/gitleaks detect \
--source "${GITHUB_WORKSPACE}" \
--report-format sarif \
--report-path "${GITHUB_WORKSPACE}/bos-scan-gitleaks.sarif" \
--no-banner --exit-code 0 || true
# Guarantee a SARIF file exists.
if [[ ! -s "${GITHUB_WORKSPACE}/bos-scan-gitleaks.sarif" ]]; then
printf '{"version":"2.1.0","runs":[{"tool":{"driver":{"name":"gitleaks"}}, "results":[]}]}\n' \
> "${GITHUB_WORKSPACE}/bos-scan-gitleaks.sarif"
fi
# ----- 3c. shellcheck (only if shell scripts detected) --------------
- id: shellcheck
name: shellcheck (shell scripts)
if: inputs.enable_scanners == 'true' && hashFiles('**/*.sh', '**/*.bash') != ''
shell: bash
run: |
set -euo pipefail
# Pinned static binary — avoids `sudo apt-get` (which fails on
# minimal / self-hosted / non-Debian runners) and matches the
# pinning strategy used for actionlint and gitleaks above.
SC_VERSION=0.10.0
case "${RUNNER_ARCH:-X64}" in
X64) sc_arch=x86_64 ;;
ARM64) sc_arch=aarch64 ;;
*)
echo "::warning::unsupported RUNNER_ARCH=${RUNNER_ARCH}; defaulting to x86_64"
sc_arch=x86_64
;;
esac
curl -sSLo /tmp/shellcheck.tar.xz \
"https://github.com/koalaman/shellcheck/releases/download/v${SC_VERSION}/shellcheck-v${SC_VERSION}.linux.${sc_arch}.tar.xz"
# Extract via Python's stdlib `lzma` + `tarfile` rather than
# `tar -xJf`. The shellcheck release tarballs are only published
# in `.tar.xz` form, and minimal / self-hosted runners (e.g.
# our own `docker-github-runner` image) do not ship the
# `xz` binary by default — `tar -xJf` then fails with
# `xz: Cannot exec: No such file or directory` (exit 2).
# CPython always bundles `lzma`, so this works anywhere
# `python3` is on PATH — which the SARIF-conversion block
# further down already requires. Honors the explicit
# "avoids `sudo apt-get` ... fails on minimal / self-hosted
# / non-Debian runners" design intent stated above.
python3 - <<'PY'
import tarfile
with tarfile.open("/tmp/shellcheck.tar.xz", "r:xz") as tf:
try:
# PEP 706 (Python 3.12+) — opt into the strict `data`
# filter explicitly. Avoids the DeprecationWarning on
# 3.12/3.13 and the hard error on 3.14+ when no filter
# is supplied to `extractall`.
tf.extractall("/tmp", filter="data")
except TypeError:
# Python <3.12 has no `filter` kwarg. Vendor binary
# release from a pinned tag — safe to extract as-is.
tf.extractall("/tmp")
PY
SHELLCHECK="/tmp/shellcheck-v${SC_VERSION}/shellcheck"
chmod +x "${SHELLCHECK}"
# Collect findings as JSON, then convert to SARIF via a tiny
# Python helper (keeps us free of extra dependencies).
cd "${GITHUB_WORKSPACE}"
find . -type f \( -name '*.sh' -o -name '*.bash' \) \
-not -path './.git/*' \
-not -path './node_modules/*' \
-not -path './vendor/*' \
-print0 \
| xargs -0 -r "${SHELLCHECK}" -f json 2>/dev/null > /tmp/shellcheck.json || true
python3 - <<'PY'
import json
import pathlib
src = pathlib.Path("/tmp/shellcheck.json")
try:
findings = json.loads(src.read_text() or "[]")
except Exception:
findings = []
level_map = {"error": "error", "warning": "warning",
"info": "note", "style": "note"}
results = []
rules = {}
for f in findings:
rid = f"SC{f.get('code', 0)}"
rules.setdefault(rid, f.get("message", rid))
results.append({
"ruleId": rid,
"level": level_map.get(f.get("level", "warning"), "warning"),
"message": {"text": f.get("message", "")},
"locations": [{
"physicalLocation": {
"artifactLocation": {"uri": f.get("file", "")},
"region": {
"startLine": f.get("line", 1),
"startColumn": f.get("column", 1),
},
}
}],
})
sarif = {
"version": "2.1.0",
"runs": [{
"tool": {"driver": {
"name": "shellcheck",
"rules": [{"id": rid, "name": rid,
"shortDescription": {"text": txt}}
for rid, txt in sorted(rules.items())],
}},
"results": results,
}],
}
pathlib.Path("bos-scan-shellcheck.sarif").write_text(
json.dumps(sarif, indent=2) + "\n")
PY
# ----- 4. Merge every available SARIF into one upload artefact ------
- id: merge
name: Merge SARIF
if: always()
shell: bash
env:
OUTPUT: ${{ inputs.sarif_output }}
run: |
set -euo pipefail
cd "${GITHUB_WORKSPACE}"
inputs=()
for f in bos-scan-actionlint.sarif bos-scan-gitleaks.sarif bos-scan-shellcheck.sarif; do
if [[ -s "${f}" ]]; then
inputs+=(--input "${f}")
fi
done
posture_flag=()
if [[ -s bos-scan-posture.sarif ]]; then
posture_flag=(--posture bos-scan-posture.sarif)
fi
bos-scan sarif \
"${inputs[@]}" \
"${posture_flag[@]}" \
--output "${OUTPUT}"
echo "sarif_path=${OUTPUT}" >> "${GITHUB_OUTPUT}"
# ----- 5. Upload to GitHub Advanced Security ------------------------
# SHA pin per Marketplace SC002 hygiene; bump via Dependabot.
- name: Upload SARIF to GitHub Advanced Security
if: always() && inputs.enable_upload == 'true'
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
sarif_file: ${{ steps.merge.outputs.sarif_path }}
category: bos-code-scanning-kit
# ----- 6. Build job summary + compute outcome ----------------------
# Severity-aware verdict for the run, surfaced both as a
# GitHub Actions step-summary table (renders on the run page) AND
# as the `outcome` step output (consumed by the caller workflow to
# gate downstream jobs without re-parsing SARIF). Tiers:
# success → 0 findings of any level
# warn → only warning/note-level findings
# failure → at least one error-level finding
# The enforcement step that follows uses this output instead of
# re-counting SARIF results, so summary table + exit code can never
# disagree.
- id: summary
name: Summary + outcome
if: always()
shell: bash
env:
ENABLE_POSTURE: ${{ inputs.enable_posture }}
ENABLE_SCANNERS: ${{ inputs.enable_scanners }}
ENABLE_UPLOAD: ${{ inputs.enable_upload }}
FAIL_ON: ${{ inputs.fail_on }}
SARIF_PATH: ${{ steps.merge.outputs.sarif_path }}
run: |
set -euo pipefail
cd "${GITHUB_WORKSPACE}"
python3 - <<'PY'
import json, os, pathlib
scanners = [
("Posture audit", "bos-scan-posture.sarif", os.environ.get("ENABLE_POSTURE") == "true"),
("actionlint", "bos-scan-actionlint.sarif", os.environ.get("ENABLE_SCANNERS") == "true"),
("gitleaks", "bos-scan-gitleaks.sarif", os.environ.get("ENABLE_SCANNERS") == "true"),
("shellcheck", "bos-scan-shellcheck.sarif", os.environ.get("ENABLE_SCANNERS") == "true"),
]
def counts(path):
e = w = n = 0
try:
d = json.load(open(path))
except Exception:
return None
for run in d.get("runs", []):
for r in run.get("results", []):
lv = r.get("level", "warning")
if lv == "error": e += 1
elif lv == "warning": w += 1
else: n += 1
return (e, w, n)
# Posture skips sidecar — SARIF drops `skip` findings (rightly,
# they're not actionable in the Security tab), so without this
# file the consolidated summary cannot tell that probes ran in
# indeterminate mode. Each entry: {rule_id, message, location}.
skips = []
skips_path = pathlib.Path("bos-scan-posture-skips.json")
if skips_path.exists() and skips_path.stat().st_size > 0:
try:
skips = json.loads(skips_path.read_text()).get("skips", []) or []
except Exception:
skips = []
rows = []
any_err = any_find = False
for label, name, enabled in scanners:
p = pathlib.Path(name)
if not enabled:
rows.append((label, "skipped", "stage disabled"))
continue
if not p.exists() or p.stat().st_size == 0:
rows.append((label, "skipped", "no SARIF produced"))
continue
c = counts(p)
if c is None:
rows.append((label, "warn", "SARIF unreadable"))
continue
e, w, n = c
if e:
rows.append((label, "failure", f"{e} error / {w} warn / {n} note"))
any_err = True
any_find = True
elif w + n:
rows.append((label, "warn", f"{w} warn / {n} note"))
any_find = True
elif label == "Posture audit" and skips:
# Posture ran cleanly on the rules it could evaluate,
# but one or more probes were skipped (typically a
# token-scope limit). Surface as `incomplete` rather
# than `success` so the verdict line stays honest.
rows.append((label, "incomplete",
f"{len(skips)} probe(s) indeterminate — see below"))
else:
rows.append((label, "success", "no findings"))
# Outcome rollup. Skips never produce errors, but they DO
# demote a would-be `success` to `warn` so the top-line badge
# doesn't lie. They never override an existing `failure`/`warn`.
if any_err: outcome = "failure"
elif any_find: outcome = "warn"
elif skips: outcome = "warn"
else: outcome = "success"
badge = {"success": "success", "warn": "warn", "failure": "failure"}[outcome]
fail_on = os.environ.get("FAIL_ON", "fail")
if outcome == "failure" and fail_on != "never":
verdict = ("Pipeline will fail. Override via `fail_on: never` on the "
"kit (advisory mode) or `security_scan_blocks_release: false` "
"on the launchpad (run scan but do not gate release).")
elif outcome == "failure":
verdict = "Error-level findings present, but `fail_on: never` — pipeline continues."
elif outcome == "warn" and any_find:
verdict = "Non-blocking findings present — pipeline continues."
elif outcome == "warn" and skips:
verdict = (f"No findings, but {len(skips)} probe(s) could not be "
"evaluated (see 'Indeterminate / skipped probes' below). "
"Provide an elevated PAT to upgrade them to pass/fail.")
else:
verdict = "No findings detected."
# ─── Console echo ────────────────────────────────────────────
# Mirror the per-stage table + outcome to the runner log too.
# GitHub Actions renders ANSI sequences verbatim, so colour the
# outcome badge and per-stage result for at-a-glance triage.
sev_color = {
"success": "\033[32m", # green
"warn": "\033[33m", # yellow
"failure": "\033[31;1m", # bold red
"skipped": "\033[90m", # grey — stage disabled / no SARIF
"incomplete": "\033[33m", # yellow — ran but indeterminate probes
}
reset = "\033[0m"
bold = "\033[1m"
label_w = max(len(label) for label, _, _ in rows)
res_w = max(len(res) for _, res, _ in rows)
print()
print(f"{bold}━━ Blackout Secure Code Scanning Kit — outcome: "
f"{sev_color.get(outcome, '')}{outcome}{reset}{bold} ━━{reset}")
for label, res, detail in rows:
colour = sev_color.get(res, "")
print(f" {label.ljust(label_w)} "
f"{colour}{res.ljust(res_w)}{reset} {detail}")
if skips:
print()
print(f" {bold}indeterminate probes{reset} "
f"({len(skips)} — token-scope limitations):")
for s in skips:
rid = s.get("rule_id", "?")
msg = s.get("message", "")
print(f" • {rid}: {msg}")
print(f" {bold}verdict{reset}: {verdict}")
print()
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as fh:
fh.write(f"## Blackout Secure Code Scanning Kit — {badge}\n\n")
fh.write("| Stage | Result | Findings |\n")
fh.write("|---|---|---|\n")
for label, result, detail in rows:
fh.write(f"| {label} | {result} | {detail} |\n")
fh.write(f"\n**Outcome:** `{outcome}` — {verdict}\n")
if skips:
fh.write("\n### Indeterminate / skipped probes\n\n")
fh.write(
"These checks ran but could not reach a `pass` or "
"`fail` verdict — typically the GitHub token lacks "
"the scope to query an admin-gated endpoint. They "
"are shown here (not as failures) so reviewers know "
"what was *not* audited.\n\n"
)
fh.write("| Rule | Reason | Location |\n")
fh.write("|---|---|---|\n")
for s in skips:
rid = s.get("rule_id", "?")
msg = (s.get("message") or "").replace("|", "\\|")
loc = (s.get("location") or "—").replace("|", "\\|")
fh.write(f"| `{rid}` | {msg} | {loc} |\n")
fh.write(
"\n_To upgrade these to real pass/fail rows, "
"configure `SCANNING_PAT` (classic `repo`, or "
"fine-grained with Administration + Security "
"events + Metadata read) and pass it via the "
"`github_token:` input. See the kit README § "
"'SCANNING_PAT — advanced posture credentials'._\n"
)
if os.environ.get("ENABLE_UPLOAD") == "true" and os.environ.get("SARIF_PATH"):
fh.write("\nMerged SARIF uploaded to the repo Security tab "
"under category `bos-code-scanning-kit`.\n")
out = os.environ.get("GITHUB_OUTPUT")
if out:
with open(out, "a", encoding="utf-8") as fh:
fh.write(f"outcome={outcome}\n")
PY
# ----- 7. Enforce failure policy (deferred from steps 2 + 3) -------
# Hard-fails the action when the severity-tier `outcome` is
# `failure` AND `fail_on` is not `never`. Deferring to the end of
# the run keeps SARIF upload (step 5) and the job-summary table
# (step 6) on the happy path even when the job is destined to fail,
# so reviewers always have the merged findings + outcome table on
# the run page.
- name: Enforce failure policy
if: always() && inputs.fail_on != 'never' && steps.summary.outputs.outcome == 'failure'
shell: bash
run: |
echo "::error::bos-code-scanning-kit outcome=failure — at least one error-level finding. See the job summary for the per-stage breakdown, and the repo Security tab (category 'bos-code-scanning-kit') for details. Override with 'fail_on: never' (kit advisory mode) or 'security_scan_blocks_release: false' (launchpad)."
exit 1