-
Notifications
You must be signed in to change notification settings - Fork 0
382 lines (352 loc) · 18.4 KB
/
Copy pathvulnerability-scan.yml
File metadata and controls
382 lines (352 loc) · 18.4 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
name: "CI: Vulnerability Scan"
on:
workflow_call:
inputs:
activate_plugins:
type: string
default: ''
auto_update:
type: boolean
default: true
description: 'Create a PR to update vulnerable plugins'
secrets:
GOOGLE_CHAT_FAUCET_WEBHOOK:
required: true
NPM_FONTAWESOME_AUTH_TOKEN:
required: true
PACKAGIST_GITHUB_TOKEN:
required: true
YOAST_LICENSE_TOKEN:
required: false
VULN_BOT_APP_ID:
required: false
description: 'GitHub App ID for creating PRs (needs Contents:write + Pull requests:write)'
VULN_BOT_PRIVATE_KEY:
required: false
description: 'GitHub App private key (PEM)'
# Serialize scans per repo so a manual workflow_dispatch can't race the nightly
# cron: two concurrent runs could both see "no open PR" and open duplicates, or
# both force-push the refresh branch. cancel-in-progress: false lets the running
# scan finish (it may be mid-PR) rather than truncating a push.
concurrency:
group: vuln-scan-${{ github.repository }}
cancel-in-progress: false
jobs:
# Gate the nightly cron on the repo's `maintenance` org custom property.
# Sites we host but deliberately do not patch (`hosted-only`) and dead ones
# (`none`, or untagged) were burning a runner every night and — worse —
# firing a Google Chat alert nobody was ever going to action, which is how a
# security channel becomes background noise.
#
# Only `schedule` is gated. A manual workflow_dispatch always scans: if a
# human asked for the scan, the maintenance tier is not the question they are
# asking. The property is still read and logged on manual runs, so a dispatch
# is also how you verify this job can see the property at all.
guard:
name: Guard
runs-on: ubuntu-latest
outputs:
should_scan: ${{ steps.check.outputs.should_scan }}
steps:
- name: Check maintenance tier
id: check
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
REPO: ${{ github.repository }}
run: |
# Fail OPEN on API error, fail CLOSED on an explicit/absent value.
# An untagged repo is a deliberate, visible state someone can fix; a
# 5xx is not. Never let a transient API failure silently skip a
# security scan.
if TIER=$(gh api "repos/$REPO/properties/values" \
--jq '.[] | select(.property_name == "maintenance") | .value' \
2> /tmp/gh_err.txt); then
echo "maintenance=${TIER:-<unset>}"
else
echo "::warning::Could not read the maintenance property — scanning anyway."
cat /tmp/gh_err.txt >&2 || true
echo "should_scan=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$EVENT_NAME" != "schedule" ]; then
echo "Triggered by '$EVENT_NAME', not the cron — scanning regardless of tier."
echo "should_scan=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Tiers that receive dependency patches. Keep in sync with the
# `maintenance` property's allowed values; anything else — including
# hosted-only, none and an unset property — skips the nightly scan.
case "$TIER" in
monthly | bi-monthly | quarterly | critical-only)
echo "should_scan=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "::notice::maintenance='${TIER:-<unset>}' — skipping the scheduled scan."
echo "should_scan=false" >> "$GITHUB_OUTPUT"
;;
esac
scan:
name: Scan
needs: guard
if: needs.guard.outputs.should_scan == 'true'
runs-on: ubuntu-latest
steps:
# No in-job sleep to spread load: runner time spent sleeping is billed,
# and it was ~38% of this workflow's ~80s runtime across every repo, every
# night. Spread the scans with distinct cron minutes in the calling repos
# instead — scheduling costs nothing. See README for the current offsets.
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Deliberately not generoi/github-actions/setup: this job audits the lock
# file and never touches vendor/ or node_modules. The shared action would
# install Node and restore and save three caches for nothing — 9s of setup
# and 23s of cache saving, measured in suomentyokalu run 30515814093, on a
# job whose actual work is a 2s audit.
- name: Read PHP version from composer.json to env
run: echo "PHP_VERSION=$(jq -r '.config.platform.php' composer.json)" >> "$GITHUB_ENV"
- name: Setup PHP
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0
with:
php-version: ${{ env.PHP_VERSION }}
tools: composer:2.9.8
coverage: none
env:
COMPOSER_TOKEN: ${{ secrets.PACKAGIST_GITHUB_TOKEN }}
# Still needed: --locked resolves nothing, but composer loads metadata from
# every configured repository, and the private ones 401 without credentials.
#
# Guarded in the shell, not with `if:` — secrets.* is not available in step
# if: expressions inside a reusable workflow (see 2702182).
- name: Authenticate with Yoast Premium Composer repository
env:
YOAST_TOKEN: ${{ secrets.YOAST_LICENSE_TOKEN }}
run: |
[ -n "$YOAST_TOKEN" ] || exit 0
composer config -g http-basic.my.yoast.com token "$YOAST_TOKEN"
- name: Add WordPress security advisories repo (global, project composer.json untouched)
run: |
# Use Composer's global config so the project's composer.json stays clean.
# Global repositories merge with project-level ones, so `composer audit`
# picks up wpsecadv advisories without modifying the repo's tracked files
# (which would otherwise leak into the auto-update PR).
composer config -g repositories.wpsecadv composer https://repo-wpsecadv.typist.tech --no-interaction
- name: Run vulnerability scan
id: scan
run: |
# --locked audits composer.lock directly instead of an installed
# vendor/, so this job needs no composer install at all. Same advisory
# data, same JSON shape; the lock is what the auto-update PR edits
# anyway.
#
# Exit codes: 0 = clean, non-zero = vulns found OR tool error. Capture
# stderr separately and treat missing/invalid JSON as a tool failure —
# never let an environmental error report "clean".
set +e
composer audit --locked --format=json > /tmp/audit.json 2> /tmp/audit.err
AUDIT_EXIT=$?
set -e
if ! jq -e '.advisories' /tmp/audit.json >/dev/null 2>&1; then
echo "::error::composer audit failed (exit $AUDIT_EXIT) — output is not valid audit JSON"
echo "::group::audit stderr"
cat /tmp/audit.err || true
echo "::endgroup::"
echo "::group::audit stdout"
cat /tmp/audit.json || true
echo "::endgroup::"
exit 1
fi
# Extract every vulnerable package from the audit JSON and hand the
# whole list to composer-update. We don't try to predict which can
# be auto-fixed: composer-update tries `composer update -W` then
# `composer require -W`, no-ops on packages it can't move, and only
# opens a PR when the lock actually changed (see composer-update@v2
# for the version-diff guard).
#
# Anything that survives `composer audit` is by definition something
# nobody has accepted yet. Per-project accepted exposures (truly
# unfixed CVEs like WP core SSRF, or major-bump-blocked deps like
# twig/twig 2.x under timber 1.x) live in `config.audit.ignore` in
# each project's composer.json — keyed by CVE/GHSA/advisoryId, with
# the reason documented next to the constraint that caused it.
#
# composer audit JSON schema (Composer 2.x):
# { "advisories": { "<package>": [ {packageName, title, cve, severity, affectedVersions} ] } }
# Schema is not version-pinned. The `jq -e '.advisories'` guard above is
# the safety net if the structure changes in a future Composer release.
jq -r '
.advisories | to_entries[] |
.value[] |
{
package: .packageName,
title: (
# Strip copyright boilerplate that wpsecadv/Wordfence advisories
# append after "### Copyright ...". Also cap at 200 chars.
.title
| split("###")[0]
| sub("\\s+$"; "")
| if length > 200 then .[:200] + "…" else . end
),
cve: (.cve // "no CVE"),
severity: (.severity // "unknown"),
affected: .affectedVersions
} | @json
' /tmp/audit.json 2>/dev/null | jq -s 'unique_by(.package)' > /tmp/parsed_vulns.json || echo "[]" > /tmp/parsed_vulns.json
COUNT=$(jq 'length' /tmp/parsed_vulns.json)
if [ "$COUNT" -gt 0 ]; then
echo "Vulnerable packages found:"
jq -r '.[] | "\(.package) — \(.title) (\(.cve))"' /tmp/parsed_vulns.json | tee /tmp/vulnerabilities.txt
PACKAGES=$(jq -r '.[] | .package' /tmp/parsed_vulns.json | tr '\n' ' ' | sed 's/ $//')
echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
# Build a rich PR body (markdown table + run link) for composer-update.
# The HTML comment marker lets composer-update dedup compare package
# sets across runs and only post a "new vulns" comment when the set
# differs from the open PR — preventing daily duplicate comments.
#
# No Fix column upfront: composer-update@v2 appends a Version changes
# table to the body after running, which is the authoritative diff.
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
VULN_SET=$(jq -r '.[] | .package' /tmp/parsed_vulns.json | sort -u | tr '\n' ',' | sed 's/,$//')
{
echo "<!-- vuln-update-set: $VULN_SET -->"
echo "## Vulnerability Fix"
echo ""
echo "Auto-generated by [scan run #${{ github.run_number }}]($RUN_URL)."
echo ""
echo "| Package | Severity | CVE | Affected |"
echo "|---|---|---|---|"
# `affected` is an OR-list of version ranges. A raw `|` is a column
# separator in GitHub markdown tables — even inside a code span — so
# dumping it whole breaks the row. Composer joins ranges with `||`
# (its OR operator), so splitting on a single `|` yields empty segments
# between the two pipes; wrapping an empty string in backticks produces
# `` (an adjacent-backtick code-span delimiter) which mis-pairs every
# backtick downstream and leaks literal <br> into the cell. So trim each
# segment and drop the empties, then render each surviving range as its
# own code span joined by <br>: valid table, one range per line.
jq -r '.[] | "| `\(.package)` | \(.severity) | \(.cve) | \(.affected | split("|") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0)) | map("`\(.)`") | join("<br>")) |"' /tmp/parsed_vulns.json
echo ""
echo "_Packages without a reachable safe version produce no entry in the **Version changes** table below — triage manually, or add to \`config.audit.ignore\` in composer.json if accepted (with a reason)._"
} > /tmp/pr_body.md
# Multiline output for downstream composer-update step
DELIM=$(openssl rand -hex 8)
{
echo "pr_body<<$DELIM"
cat /tmp/pr_body.md
echo "$DELIM"
} >> "$GITHUB_OUTPUT"
# Expose the raw vulns JSON for composer-update@v2's
# vulns_json input. The action derives a tight ~min_safe
# constraint per package from each record's `affected`
# range — preventing composer from jumping to the latest
# minor when only a patch update is needed to fix the CVE.
DELIM=$(openssl rand -hex 8)
{
echo "vulns_json<<$DELIM"
cat /tmp/parsed_vulns.json
echo "$DELIM"
} >> "$GITHUB_OUTPUT"
exit 1
else
echo "No vulnerabilities found"
exit 0
fi
# The audit itself runs off composer.lock, but composer-update's
# compute-min-safe-constraints.php loads composer/semver from the consuming
# project's vendor/autoload.php — "needs composer install first", as it says
# itself. So install here, and only here: on a clean scan (about half of
# nightly runs) the job still finishes in ~12s having installed nothing.
#
# The download cache is restored alongside, because without it this install
# fetches every package over the network.
- name: Get Composer Cache Directory
if: failure() && inputs.auto_update && steps.scan.outputs.packages != ''
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- name: Composer download cache
if: failure() && inputs.auto_update && steps.scan.outputs.packages != ''
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-php${{ env.PHP_VERSION }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-php${{ env.PHP_VERSION }}-
- name: Install dependencies for the auto-update
if: failure() && inputs.auto_update && steps.scan.outputs.packages != ''
run: composer install --no-interaction --no-scripts
- name: Generate GitHub App token
# Mint a short-lived token from the GitHub App for push + PR access.
# continue-on-error: if app credentials aren't configured the step
# fails gracefully and the fallback to github.token kicks in.
if: failure() && inputs.auto_update && steps.scan.outputs.packages != ''
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
continue-on-error: true
with:
app-id: ${{ secrets.VULN_BOT_APP_ID }}
private-key: ${{ secrets.VULN_BOT_PRIVATE_KEY }}
- name: Update vulnerable packages
# Passes every vulnerable package to composer-update@v2. The action
# tries `composer update -W`, falls back to `composer require -W`,
# and only opens a PR when the lock actually changed. Packages with
# no reachable safe version (truly unpatched upstream, major-bump
# blocked) no-op silently — they still appear in the Chat alert below.
if: failure() && inputs.auto_update && steps.scan.outputs.packages != ''
id: update
uses: generoi/github-actions/composer-update@v2
with:
packages: ${{ steps.scan.outputs.packages }}
vulns_json: ${{ steps.scan.outputs.vulns_json }}
token: ${{ steps.app-token.outputs.token || github.token }}
pr_title: "Update vulnerable packages"
pr_body: ${{ steps.scan.outputs.pr_body }}
pr_label: "vuln-update"
branch_prefix: "fix/vuln-update"
- name: Google Chat Notification
# Runs last so pr_url from composer-update (new or refreshed existing PR)
# can be included as a "View PR" button when available.
#
# Suppressed when composer-update reported `skipped` — i.e. an open PR
# already covers exactly these findings and nothing changed. Without
# this the scan's exit-1-on-any-vuln would re-alert every night for a
# vuln that's already sitting in a PR awaiting review. Any other outcome
# (created / refreshed / none, or auto-update disabled → empty) still
# alerts, so genuinely new or unfixable findings are never silenced.
if: failure() && steps.update.outputs.outcome != 'skipped'
env:
GOOGLE_CHAT_FAUCET_WEBHOOK: ${{ secrets.GOOGLE_CHAT_FAUCET_WEBHOOK }}
PR_URL: ${{ steps.update.outputs.pr_url }}
run: |
REPO_NAME="${{ github.event.repository.name }}"
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
TRIGGER="${{ github.event_name == 'schedule' && 'Scheduled run' || format('Run by {0}', github.actor) }}"
# Build one widget per vulnerability
WIDGETS=$(cat /tmp/vulnerabilities.txt | jq -R '{decoratedText: {startIcon: {knownIcon: "DESCRIPTION"}, text: .}}' | jq -s '.')
# Buttons: repo + run always, PR only when composer-update opened or
# reused one. Empty PR_URL (auto-update disabled, no fix available,
# or update failed before PR creation) drops the PR button.
BUTTONS=$(jq -n \
--arg repo_url "$REPO_URL" \
--arg run_url "$RUN_URL" \
--arg pr_url "$PR_URL" \
'[
{text: "View repository", onClick: {openLink: {url: $repo_url}}},
{text: "View run", onClick: {openLink: {url: $run_url}}}
] + (if $pr_url != "" then [{text: "View PR", onClick: {openLink: {url: $pr_url}}}] else [] end)')
PAYLOAD=$(jq -n \
--arg title "⚠️ $REPO_NAME: Vulnerability" \
--arg subtitle "Scan failed · $TRIGGER" \
--argjson widgets "$WIDGETS" \
--argjson buttons "$BUTTONS" \
'{cardsV2: [{cardId: "vuln", card: {
header: {title: $title, subtitle: $subtitle},
sections: [
{header: "Vulnerable packages", widgets: $widgets},
{widgets: [{buttonList: {buttons: $buttons}}]}
]
}}]}')
curl -sS -X POST \
-H "Content-Type: application/json; charset=UTF-8" \
-d "$PAYLOAD" \
"$GOOGLE_CHAT_FAUCET_WEBHOOK"