-
Notifications
You must be signed in to change notification settings - Fork 0
486 lines (461 loc) · 19.6 KB
/
Copy pathsecurity.yml
File metadata and controls
486 lines (461 loc) · 19.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
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
# security.yml -- Static analysis + dependency + container CVE scans.
#
# Runs on every PR (only the cheap checks):
# 1. semgrep -- static analysis on app/src + scripts (~2 min, always).
#
# 2. osv-scanner -- npm deps vs the OSV.dev CVE database.
# 3. npm audit -- npm deps vs GitHub's advisory database. Both run
# because they disagree: npm audit caught
# GHSA-ggr8-5vv4-36mx and osv-scanner did not.
# 4. semgrep-upload -- publishes semgrep's SARIF to the Security tab.
#
# Runs weekly on main (Monday 06:00 UTC):
# 5. trivy -- Docker image OS-level CVEs. Was running on every
# PR; the 2026-05-09 audit found this was the
# single largest minute-burner (~5 min/PR for low
# added value when neither Dockerfile nor
# lockfile changed).
# 6. osv-scanner -- catches newly-disclosed CVEs in unchanged deps.
#
# CodeQL is intentionally NOT in this file. The repo has GitHub-managed
# default-setup CodeQL running on its own schedule (separate Actions
# pool, free for default-setup). That's the canonical source of truth
# for the Security tab. The previous manual CodeQL job here was a pure
# duplicate -- removed 2026-05-09 to cut ~5 min/PR. If you ever need to
# customize CodeQL queries, re-add as a scheduled-only job, not per-PR.
#
# Cloud surfaces:
# - Inline PR diff annotations (workflow commands)
# - Workflow run summary tables
# - SARIF + text artifacts uploaded per run
# - Semgrep findings in the Security tab (code scanning is free for
# public repos; this repo is public)
# No GitHub Advanced Security or Semgrep Cloud needed.
name: Security
on:
# 2026-05-13: dropped `push: branches: [main]` trigger to halve
# Actions minute spend. PRs are still gated by Semgrep; the weekly
# schedule still catches newly-disclosed CVEs in unchanged code.
# Manual `workflow_dispatch` retained for on-demand sweeps.
# Runs on every PR to main (no path filter) so Semgrep + OSV can be REQUIRED
# status checks without a path-filtered PR leaving them perpetually "waiting".
pull_request:
branches: [main]
schedule:
# Monday 06:00 UTC weekly sweep. Catches newly-disclosed CVEs in
# unchanged code/deps/image even when no PR touches them.
- cron: "0 6 * * 1"
workflow_dispatch:
permissions:
contents: read
pull-requests: write # needed to post inline annotations on PRs
jobs:
semgrep:
name: Semgrep static analysis
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v7
# Registry rule packs tuned for this stack. p/typescript + p/react
# cover the UI; p/nextjs flags Next.js-specific API auth gaps;
# p/nodejs covers server patterns; p/owasp-top-ten is the
# catch-all security baseline; p/secrets catches committed keys.
- name: Run Semgrep (fail on ERROR severity only)
run: |
set +e
# Single scan that produces JSON, SARIF, and text together so we
# can derive PR annotations from the JSON without re-scanning.
semgrep scan \
--config p/typescript \
--config p/react \
--config p/nextjs \
--config p/nodejs \
--config p/owasp-top-ten \
--config p/secrets \
--severity ERROR \
--error \
--json --output=semgrep.json \
app/src scripts
RC=$?
# Convert to SARIF and human-readable text from the same scan
# (cheap re-parses, no second analysis).
semgrep scan \
--config p/typescript \
--config p/react \
--config p/nextjs \
--config p/nodejs \
--config p/owasp-top-ten \
--config p/secrets \
--severity ERROR \
--sarif --output=semgrep.sarif \
app/src scripts > /dev/null 2>&1 || true
semgrep scan \
--config p/typescript \
--config p/react \
--config p/nextjs \
--config p/nodejs \
--config p/owasp-top-ten \
--config p/secrets \
--severity ERROR \
--text --output=semgrep.txt \
app/src scripts > /dev/null 2>&1 || true
if [ "$RC" != "0" ]; then
echo "SEMGREP_FAILED=1" >> $GITHUB_ENV
fi
exit 0
# Emit GitHub workflow command annotations from the JSON. Each
# finding becomes a `::error file=...,line=...,col=...::<message>`
# line, which GitHub renders as inline annotations on the PR diff
# AND in the workflow run summary. No Cloud needed.
- name: Annotate PR diff with findings
if: always()
run: |
if [ ! -f semgrep.json ]; then
echo "No semgrep.json produced; skipping annotations."
exit 0
fi
python3 - <<'PY'
import json, os, sys
with open("semgrep.json") as f:
data = json.load(f)
results = data.get("results", [])
if not results:
print("Semgrep: no findings.")
sys.exit(0)
print(f"::group::Semgrep findings ({len(results)})")
for r in results:
path = r.get("path", "")
start = r.get("start", {})
line = start.get("line", 1)
col = start.get("col", 1)
rule = r.get("check_id", "semgrep").split(".")[-1]
# GitHub clamps the message to a single line; collapse newlines.
msg = r.get("extra", {}).get("message", "").replace("\n", " ").strip()
# Severity: WARNING for INFO/WARNING, ERROR for ERROR. The
# workflow already filtered to --severity ERROR so always emit ::error::.
print(f"::error file={path},line={line},col={col},title=Semgrep {rule}::{msg}")
print("::endgroup::")
# Also write a markdown summary block visible at the top of the
# workflow run page.
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_path:
with open(summary_path, "a") as out:
out.write(f"## Semgrep findings: {len(results)} ERROR-severity\n\n")
out.write("| Severity | Rule | File | Line | Message |\n")
out.write("|---|---|---|---|---|\n")
for r in results[:50]:
path = r.get("path", "")
line = r.get("start", {}).get("line", 1)
rule = r.get("check_id", "").split(".")[-1]
msg = r.get("extra", {}).get("message", "").replace("|", "\\|").replace("\n", " ").strip()[:120]
out.write(f"| ERROR | `{rule}` | `{path}` | {line} | {msg} |\n")
if len(results) > 50:
out.write(f"\n_… and {len(results) - 50} more (download the `semgrep-findings` artifact for the full list)._\n")
PY
- name: Upload findings as artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: semgrep-findings
path: |
semgrep.json
semgrep.sarif
semgrep.txt
- name: Fail job if ERROR-severity findings
if: env.SEMGREP_FAILED == '1'
run: |
echo "Semgrep found ERROR-severity issues. See annotations on the PR diff,"
echo "the run summary at the top of this page, or download the"
echo "'semgrep-findings' artifact for the full SARIF + text report."
exit 1
# Publishes the SARIF that the semgrep job already produces to GitHub code
# scanning, so findings land in the Security tab with per-finding triage,
# dismissal-with-reason, and history -- instead of vanishing with the run log.
#
# This is a SEPARATE job on purpose. The semgrep job runs inside the
# `semgrep/semgrep` container, which is Python-based and has no Node; the
# upload-sarif action is a Node action and would execute inside that
# container. Downloading the artifact onto a plain runner sidesteps that.
#
# `if: always()` because the semgrep job exits 1 when it finds anything --
# which is exactly when there is something worth uploading.
semgrep-upload:
name: Publish Semgrep results to code scanning
runs-on: ubuntu-latest
needs: semgrep
if: always() && needs.semgrep.result != 'skipped'
permissions:
contents: read
security-events: write
steps:
- name: Download findings from the semgrep job
uses: actions/download-artifact@v7
with:
name: semgrep-findings
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: semgrep.sarif
category: semgrep
# npm audit reads GitHub's own advisory database. It runs ALONGSIDE
# osv-scanner rather than instead of it because the two disagree: during the
# #103 audit, npm audit reported GHSA-ggr8-5vv4-36mx and osv-scanner did not.
# Two scanners with different sources means a blind spot each; running both
# is cheap insurance.
#
# Scoped to production dependencies (`--omit=dev`): a devDependency advisory
# does not ship to users, and failing PRs on those trains people to ignore
# the gate.
npm-audit:
name: npm advisory audit
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: app/package-lock.json
- name: Audit production dependencies
working-directory: app
run: |
set +e
npm audit --omit=dev --audit-level=high --json > audit.json
RC=$?
set -e
python3 - <<'PY'
import json, os, pathlib
data = json.loads(pathlib.Path("audit.json").read_text() or "{}")
vulns = data.get("vulnerabilities", {})
counts = data.get("metadata", {}).get("vulnerabilities", {})
summary = os.environ.get("GITHUB_STEP_SUMMARY")
lines = ["## npm advisory audit\n"]
if not vulns:
lines.append("No production advisories at **high** or above.\n")
else:
lines.append("| Package | Severity | Advisory |\n|---|---|---|\n")
for name, v in sorted(vulns.items()):
urls = [x.get("url", "") for x in v.get("via", []) if isinstance(x, dict)]
lines.append(f"| `{name}` | {v.get('severity','?')} | {urls[0] if urls else ''} |\n")
lines.append(f"\nTotals: `{counts}`\n")
if summary:
with open(summary, "a") as out:
out.writelines(lines)
print("".join(lines))
PY
# npm audit exits non-zero only when something at or above
# --audit-level is found, which is exactly the failure we want.
exit $RC
# OSV scans npm dependencies against the OSV.dev CVE database. Runs on
# every PR (the `on:` block has no path filter, so this can be a REQUIRED
# check) and on the weekly schedule, which catches newly-disclosed CVEs in
# unchanged deps.
osv-scanner:
name: Dependency CVE scan
runs-on: ubuntu-latest
# Only execute when we actually want to scan: lockfile changed OR
# weekly sweep OR manual trigger. The workflow-level paths filter
# already stops most non-relevant PR runs from triggering this job;
# this `if` is the belt to that suspenders.
if: |
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
contains(github.event.head_commit.message, '[run-osv]') ||
github.event_name == 'pull_request' ||
github.event_name == 'push'
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
- name: Install osv-scanner
run: |
curl -sSL -o /usr/local/bin/osv-scanner \
https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
chmod +x /usr/local/bin/osv-scanner
osv-scanner --version
- name: Run osv-scanner
id: scan
run: |
set +e
osv-scanner \
--config=osv-scanner.toml \
--lockfile=app/package-lock.json \
--format=json \
--output=osv.json
RC=$?
osv-scanner \
--config=osv-scanner.toml \
--lockfile=app/package-lock.json \
--format=sarif \
--output=osv.sarif > /dev/null 2>&1 || true
osv-scanner \
--config=osv-scanner.toml \
--lockfile=app/package-lock.json \
--format=table \
> osv.txt 2>&1 || true
echo "rc=$RC" >> $GITHUB_OUTPUT
exit 0
- name: Summarize CVE findings
if: always()
run: |
if [ ! -f osv.json ]; then
echo "No osv.json produced; skipping summary."
exit 0
fi
python3 - <<'PY'
import json, os, sys
try:
with open("osv.json") as f:
data = json.load(f)
except Exception:
print("Could not parse osv.json")
sys.exit(0)
results = data.get("results", [])
findings = []
for project in results:
for pkg in project.get("packages", []):
pkg_info = pkg.get("package", {})
for vuln in pkg.get("vulnerabilities", []):
findings.append({
"name": pkg_info.get("name", "?"),
"version": pkg_info.get("version", "?"),
"id": vuln.get("id", "?"),
"summary": vuln.get("summary", "")[:120].replace("\n", " "),
})
if not findings:
print("osv-scanner: no CVEs.")
sys.exit(0)
print(f"::error title=Dependency CVEs::osv-scanner found {len(findings)} CVEs across pinned dependencies.")
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_path:
with open(summary_path, "a") as out:
out.write(f"## Dependency CVEs: {len(findings)}\n\n")
out.write("| Package | Version | CVE | Summary |\n")
out.write("|---|---|---|---|\n")
for f in findings[:50]:
out.write(f"| `{f['name']}` | `{f['version']}` | `{f['id']}` | {f['summary'].replace('|', '\\|')} |\n")
if len(findings) > 50:
out.write(f"\n_… and {len(findings) - 50} more (download the `osv-findings` artifact for the full list)._\n")
PY
- name: Upload findings as artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: osv-findings
path: |
osv.json
osv.sarif
osv.txt
- name: Fail job if CVEs found
if: steps.scan.outputs.rc != '0'
run: |
echo "osv-scanner found vulnerabilities. See run summary at top of this page,"
echo "or download the 'osv-findings' artifact for the full table."
cat osv.txt
exit 1
# Trivy scans the production Docker image for OS-level CVEs (Alpine
# packages, libssl, libxml2, glibc). Heavy: ~5 min including image
# build. Runs ONLY on schedule + manual trigger -- previously fired
# on every PR (the 2026-05-09 audit found this was the single largest
# minute-burner with low added value when neither Dockerfile nor
# lockfile changed).
trivy:
name: Docker image CVE scan (weekly)
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- name: Build production image
run: |
docker build -f app/Dockerfile -t furniture-configurator:ci-scan ./app
docker images furniture-configurator:ci-scan
- name: Run Trivy
id: scan
run: |
set +e
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PWD:/repo" \
aquasec/trivy:latest image \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--format json \
--output /repo/trivy.json \
furniture-configurator:ci-scan
RC=$?
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PWD:/repo" \
aquasec/trivy:latest image \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--format table \
--output /repo/trivy.txt \
furniture-configurator:ci-scan > /dev/null 2>&1 || true
echo "rc=$RC" >> $GITHUB_OUTPUT
exit 0
- name: Summarize Trivy findings
if: always()
run: |
if [ ! -f trivy.json ]; then
echo "No trivy.json produced; skipping summary."
exit 0
fi
python3 - <<'PY'
import json, os, sys
try:
with open("trivy.json") as f:
data = json.load(f)
except Exception:
print("Could not parse trivy.json")
sys.exit(0)
findings = []
for result in data.get("Results", []):
target = result.get("Target", "?")
for vuln in result.get("Vulnerabilities", []) or []:
findings.append({
"target": target,
"id": vuln.get("VulnerabilityID", "?"),
"pkg": vuln.get("PkgName", "?"),
"installed": vuln.get("InstalledVersion", "?"),
"fixed": vuln.get("FixedVersion", "—"),
"severity": vuln.get("Severity", "?"),
"title": (vuln.get("Title") or vuln.get("Description") or "")[:120].replace("\n", " "),
})
if not findings:
print("Trivy: no HIGH/CRITICAL fixable CVEs in the production image.")
sys.exit(0)
print(f"::warning title=Docker image CVEs::Trivy found {len(findings)} HIGH/CRITICAL fixable CVEs in the production image.")
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_path:
with open(summary_path, "a") as out:
out.write(f"## Docker image CVEs (Trivy): {len(findings)}\n\n")
out.write("| Severity | CVE | Package | Installed | Fixed In | Title |\n")
out.write("|---|---|---|---|---|---|\n")
for f in findings[:50]:
title = f["title"].replace("|", "\\|")
out.write(f"| {f['severity']} | `{f['id']}` | `{f['pkg']}` | `{f['installed']}` | `{f['fixed']}` | {title} |\n")
if len(findings) > 50:
out.write(f"\n_… and {len(findings) - 50} more (download `trivy-findings` artifact for the full list)._\n")
PY
- name: Upload findings as artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: trivy-findings
path: |
trivy.json
trivy.txt
- name: Fail job if HIGH/CRITICAL CVEs found
if: steps.scan.outputs.rc != '0'
run: |
echo "Trivy found HIGH/CRITICAL fixable CVEs in the production image."
echo "See run summary at top of this page or download 'trivy-findings'."
cat trivy.txt 2>/dev/null || true
exit 1