-
Notifications
You must be signed in to change notification settings - Fork 0
685 lines (667 loc) · 47.5 KB
/
Copy pathsecurity.yml
File metadata and controls
685 lines (667 loc) · 47.5 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
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
name: Security
# Security gate: Python SAST (bandit), Python dependency audit (pip-audit), npm dependency audit
# (npm-audit, for the ide/ extension), secret scan (gitleaks), and project SAST rules (semgrep).
# All four are BLOCKING — their first CI run was clean (bandit: 0; gitleaks: 0; semgrep: 0 across
# 5 rules; pip-audit: no known CVEs), so each now fails the build on a regression rather than
# merging unnoticed. To temporarily downgrade one, add `continue-on-error: true` back to its job.
#
# READ THAT LAST SENTENCE AS A TRAP, not a procedure. Every job here except `sbom` and `trivy` is a
# REQUIRED context, and GitHub reports a continue-on-error job as SUCCESS — so that one line leaves
# branch protection green while the scanner's findings are discarded, and with auto-merge armed and
# zero required approvals the PR merges unread. `tests/test_security_posture.py` now REFUSES it (and
# `|| true`, `--exit-zero`, and a skippable job-level `if:`) for any job in the required set. To take
# a gate off the merge path, remove its context from branch protection AND
# `.github/required-contexts.txt` — deliberately, in a reviewed diff. Never neuter it in place.
# This repo is PUBLIC, so CodeQL runs free and IS a required context (codeql.yml) — it is not gated
# behind GitHub Advanced Security. This comment previously said "this private repo", written when it
# was; CodeQL reporting on every PR is itself the disproof. gitleaks below is retained deliberately:
# it scans on the same run as the other gates rather than relying on native push protection.
# See docs/SECURITY.md.
#
# THE TRIGGER SET IS THE `on:` BLOCK BELOW AND NOTHING ELSE. Each arm carries its own reason there,
# so this header states none of them and must not start. A header paragraph that ALSO describes the
# triggers is a second definition, free to drift from the first — and one did: for months a paragraph
# here denied a trigger the `on:` block declared ten lines beneath it, at length and with costings.
# It was DELETED rather than corrected, because correcting it would have left the second definition
# in place to drift again. The cost of that drift is not to CI, which behaved as the `on:` block says;
# it is that the rest of this header is load-bearing (the continue-on-error trap above), and a reader
# who finds one paragraph of it demonstrably false has no way to tell which of the others still hold.
# tests/test_security_posture.py refuses the return of a header claim that denies a declared trigger.
on:
pull_request:
# Post-merge re-scan (main only). A fork PR is scanned STRUCTURAL-ONLY by design -- the secret is
# unavailable to it -- so without this arm no fully-loaded scan ever sees fork-contributed content.
# Scoped to main so branch pushes do not double-run alongside their own PR.
push:
branches: [main]
schedule:
# Daily, so a CVE freshly disclosed against an UNCHANGED pinned dep (no push/PR to trigger a scan)
# is caught within ~24h instead of up to 7 days — a weekly cadence can't meet a 72h-class remediation
# clock. pip-audit/npm-audit here are a second detector independent of GitHub's advisory-review lag.
- cron: "0 6 * * *"
workflow_dispatch:
inputs:
released_line_audit_tag:
description: "released-line-audit: audit THIS release tag instead of the selected latest. Leave blank for normal runs; used to re-run the positive control against an older tag."
required: false
type: string
concurrency:
# Per-ref for PR events so a rapid re-push supersedes its own in-flight scan (ci.yml's pattern —
# auto-merge tolerates cancelled contexts on superseded SHAs; it gates on the latest head SHA).
# schedule/dispatch runs get a unique group (run_id): never superseded, never cancelled.
group: security-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
pip-audit:
name: pip-audit (dependency vulnerabilities)
runs-on: ubuntu-latest
# BLOCKING: the lockfile audit is clean. A newly-disclosed CVE in a pinned dep will now turn CI
# red (the intended forcing function) — bump the lockfile, or `pip-audit --ignore-vuln <ID>` to
# accept a triaged advisory.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Check the lockfile is in sync with pyproject (DEP-1)
run: |
# uv is PINNED: it is the resolver that produces every lockfile this job then audits, so an
# unpinned upgrade lets a new resolver silently change the exported set mid-PR and reds the
# `git diff --exit-code` below for a reason unrelated to the change. Bump deliberately.
python -m pip install --upgrade pip "uv==0.12.0"
# Fails if pyproject changed without re-running `uv lock` (drift guard).
uv lock --check
# Re-export to the same path and fail if it differs from what's committed (keeps the
# hashed requirements.lock in sync with uv.lock; same -o name = deterministic header).
uv export --all-extras --no-emit-project --format requirements.txt -o requirements.lock
# The container image installs from SCOPED, per-profile locks (not the all-extras lock above,
# which drags PySide6/dev/etc. into a slim runtime image) — keep them in sync too. The -o paths
# match the header recorded in each committed file, so re-export is byte-identical when in sync.
uv export --no-emit-project --no-dev --format requirements.txt -o docker/locks/requirements-core.lock
uv export --no-emit-project --no-dev --extra sqlserver --format requirements.txt -o docker/locks/requirements-sqlserver.lock
# CI's own installs pass `--constraint constraints.lock` (see ci.yml): a HASHLESS export of this
# same lock, so a fresh `uv pip install -e ".[extras]"` cannot drift to a newer upstream
# release. Re-export it here so it can never fall out of step with uv.lock.
uv export --all-extras --no-emit-project --no-hashes --format requirements.txt -o constraints.lock
# The CI TOOLCHAIN locks (ADR 0034 §3): PEP 735 `[dependency-groups]`, so the scanners and the
# quality tools flow through uv.lock and are consumed HASH-PINNED. Non-default groups, so they
# stay out of the four exports above (and out of the SBOM / image locks / audited runtime).
uv export --only-group ci-scanners --format requirements.txt -o ci/locks/ci-scanners.lock
uv export --only-group ci-quality --format requirements.txt -o ci/locks/ci-quality.lock
git diff --exit-code -- requirements.lock docker/locks/requirements-core.lock docker/locks/requirements-sqlserver.lock constraints.lock ci/locks/ci-scanners.lock ci/locks/ci-quality.lock
- name: Install from the hashed lockfile (DEP-1)
run: |
# --require-hashes enforces a hash for every requirement (the lockfile carries them): a
# reproducible, tamper-evident install. Exercises the lockfile as an actual install path
# instead of only auditing it, so a lockfile that doesn't resolve/install is caught (low-26).
python -m venv /tmp/lockcheck
# No unpinned pip bootstrap: --require-hashes resolves nothing (tests/test_ci_venv_pinning.py).
/tmp/lockcheck/bin/pip install --require-hashes -r requirements.lock
- name: Audit the locked dependencies (DEP-1)
run: |
# HASH-PINNED from the lock, not merely `==`-pinned: a version pin does not satisfy Scorecard
# PinnedDependenciesID, and the transitive closure floated regardless. Rationale for each pin
# lives in pyproject.toml's [dependency-groups]. THIS step's `--upgrade pip` bootstrap is gone
# rather than pinned — the lock hash-pins `pip` itself (a pip-audit → pip-api dependency).
# Scoped deliberately, because the unqualified claim was FALSE for this job: the DEP-1 step
# above still runs `pip install --upgrade pip "uv=="` in this same interpreter two steps
# earlier, and that unverified pip does the load-bearing work (the six exports + the diff
# gate); the line below then DOWNGRADES pip to the locked version after the fact. So this
# job's posture improves for the AUDITOR, not for the exporter. The `uv` bootstrap is circular
# by construction (ADR 0034 section 3's residuals) and is why `pip` stays registered in
# SECURITY_YML_ACCEPTED_UNPINNED. The claim holds unqualified in the bandit job and zizmor.yml.
python -m pip install --require-hashes -r ci/locks/ci-scanners.lock
# Audit the committed, pinned set — reproducible (vs auditing a fresh latest-resolve).
pip-audit -r requirements.lock --desc
# Audit the TOOLCHAIN locks too. NOT bookkeeping: hash-pinning makes the toolchain STICKY, so
# without this a CVE in a pinned scanner is invisible to every gate — the "pinned, stale,
# unpatched is worse than floating" failure ADR 0034 section 3 names. `--ignore-vuln <ID>` is
# the escape hatch for a triaged advisory, exactly as for the lockfile audit above.
#
# CONSEQUENCE, stated rather than discovered later: this is a REQUIRED context, so a CVE in
# mutmut's or diff-cover's closure reds the merge gate over an ADVISORY tool. That is not a new
# posture — requirements.lock is exported `--all-extras`, so ruff, mypy and pytest already do
# exactly this — but it does add 40 distributions to the blocking set (23 from ci-scanners, 17
# from ci-quality; measured). Zero advisories at the time of writing. If the owner would rather
# the advisory half not block, move the ci-quality line alone to its own step.
pip-audit -r ci/locks/ci-scanners.lock --desc
pip-audit -r ci/locks/ci-quality.lock --desc
# ANTI-SLOPSQUAT. Deliberately a STEP in this already-REQUIRED job rather than a new context: it
# blocks today with no branch-protection change, the same reasoning as the ledger-gate backstop
# in ci.yml. (A hard-failing job that is NOT a required context does not stop auto-merge — it
# only looks like it does.)
#
# This answers the question pip-audit above structurally cannot. pip-audit asks "does this pinned
# version have a known CVE"; a freshly registered hallucinated name has no advisory, so it
# resolves, locks, hashes and installs through every DEP-1 control clean. This project's
# dependencies are chosen by an AI, and docs/Secure_AI_Development_Standards.md has carried the
# check as the "highest-priority deferred gate" while verify-before-add stayed "enforced only by
# the human remembering".
#
# Stdlib-only (no install). Fails CLOSED: if PyPI is unreachable it exits 2 rather than passing,
# and it exits 2 if it examined zero distributions, so a schema change cannot read as a clean
# sweep. Its known blind spot — a real package that is not the INTENDED one — is documented in
# the script and pinned by tests/test_new_dependency_check.py.
- name: Verify every declared dependency is a real, established distribution
run: python scripts/security/new_dependency_check.py
npm-audit:
name: npm-audit (ide dependency vulnerabilities)
runs-on: ubuntu-latest
# BLOCKING: the ide/ npm tree (the VS Code extension's build-time toolchain) audits clean today, so a
# newly-disclosed advisory in a pinned npm dep now turns CI red — the same forcing function as
# pip-audit, extended to the extension's supply chain (previously only pip + github-actions were
# watched). `overrides` in ide/package.json is where a triaged transitive advisory gets pinned out.
defaults:
run:
working-directory: ide
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
- name: Audit the locked npm dependencies (install-free)
# --package-lock-only audits straight from the committed package-lock.json (no install needed) —
# fast and reproducible. Default level fails on ANY severity, matching pip-audit's strict posture.
run: npm audit --package-lock-only
released-line-audit:
name: released-line-audit (latest release's pinned runtime)
runs-on: ubuntu-latest
# WHAT THIS ANSWERS, and why the `pip-audit` job above does not. That job audits the CHECKED-OUT
# tree, so on the daily cron it audits `main`. "Is what we would ship next current" is the right
# question and it is a DIFFERENT question from "does the version we already shipped carry a known
# advisory". The two answers diverge from the moment a fix lands on main until a release carries
# it, and nothing watched that window.
#
# MEASURED 2026-08-06, stated in the conditional because there are ZERO deployments (CLAUDE.md
# section 0). v0.3.2 was tagged 2026-07-28 pinning cryptography 49.0.0. GHSA-g6cj-pr64-35w5 /
# CVE-2026-69247 / PYSEC-2026-3552 (fixed in 50.0.0) published 2026-08-03. The required `pip-audit`
# gate above caught it on main THE SAME DAY and the pin was bumped -- that gate worked, and this job
# is NOT a second detector for it. What did not exist was anything noticing that days later no
# release carries the fix, so an operator scanning the published v0.3.2 SBOM WOULD see an advisory
# our shipped VEX says nothing about (security/vex/messagefoundry.openvex.json has no statements).
#
# SCOPE = the latest released line only. docs/SUPPORT-POLICY.md -- only the latest released version
# is supported pre-1.0 and there is no back-port. A finding against an older tag has no supported
# remedy, so scanning one would manufacture unactionable work.
#
# ADVISORY BY PLACEMENT, NOT BY continue-on-error -- the dast.yml posture recorded in
# .github/required-contexts.txt. It goes RED on a finding. It is schedule/dispatch-only, so it can
# never report on a PR and must never become a required context (the required-but-absent trap).
# tests/test_security_posture.py pins both halves of that.
#
# NO VEX IS APPLIED HERE, deliberately. Applying our maintained OpenVEX would let a `fixed` or
# `not_affected` statement written on main suppress the finding against the ALREADY-SHIPPED
# artifact -- security/vex/README.md's own worked example names the product with no version
# qualifier, so a statement applies to every release forever. That would turn this gate green at
# the exact moment the assessment was written and before any release carried the fix. To accept a
# triaged advisory, add `--ignore-vuln <ID>` below with the reason in a comment: explicit,
# greppable and per-advisory, the same escape hatch the pip-audit job documents.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Nothing in this job pushes or uploads the tree, so the persisted git credential has no work
# to do. Fixed at the source rather than leaning on this file's artipacked entry in
# .github/zizmor.yml, which that config's own header asks for.
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Install pip-audit (hash-pinned, from the CI toolchain lock)
# Same install as the pip-audit job above: no new tool, no new pin, no second vulnerability
# database, nothing that can drift out of step with an operator-facing command.
run: python -m pip install --require-hashes -r ci/locks/ci-scanners.lock
- name: Audit the latest released line's pinned core runtime
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
# Hoisted, never interpolated into the script body (zizmor: template injection). A dispatch
# input is untrusted text and is shape-checked before it reaches `gh`.
TAG_OVERRIDE: ${{ inputs.released_line_audit_tag }}
run: |
set -euo pipefail
# WHICH TAG. Deliberately NOT `/releases/latest`: that endpoint excludes pre-releases, and
# release.yml publishes pre-release `-*` tags to PRODUCTION PyPI -- so its default answers a
# narrower question than the one asked. The web console is separately versioned under a
# `webconsole-v*` namespace and ships no engine runtime lock, so it is excluded by shape.
# Enumerate, filter, and PRINT the rule applied plus every candidate considered.
if [ -n "${TAG_OVERRIDE:-}" ]; then
case "$TAG_OVERRIDE" in
v[0-9]*.[0-9]*.[0-9]*) targets="$TAG_OVERRIDE" ;;
*) echo "::error::refusing tag override '$TAG_OVERRIDE' -- engine tags are vX.Y.Z[-suffix]" >&2 ; exit 2 ;;
esac
echo "selection rule: OPERATOR OVERRIDE via workflow_dispatch input"
else
releases="$(gh release list --limit 100 --json tagName,isDraft,isPrerelease,publishedAt)"
echo "--- candidates considered ---"
printf '%s' "$releases" | jq -r '.[] | "\(.publishedAt) \(.tagName) draft=\(.isDraft) prerelease=\(.isPrerelease)"'
echo "selection rule: newest non-draft engine tag, PLUS the newest pre-release when it is newer than that"
targets="$(printf '%s' "$releases" | jq -r '
[ .[]
| select(.isDraft | not)
| select(.tagName | test("^v[0-9]+\\.[0-9]+\\.[0-9]+(-.+)?$")) ]
| sort_by(.publishedAt) | reverse
| ( (map(select(.isPrerelease | not)) | .[0:1]) + (.[0:1] | map(select(.isPrerelease))) )
| map(.tagName) | unique | .[]')"
fi
if [ -z "$targets" ]; then
echo "::error::no published engine release matched the selection rule -- this gate examined NOTHING. Failing closed." >&2
exit 2
fi
examined=0
for tag in $targets; do
echo "=== $tag ==="
# Read the lock AT THE TAG. This is the exact file release.yml installs into a clean venv to
# generate the shipped CycloneDX SBOM, so it is the same component population the published
# messagefoundry-sbom.cdx.json inventories -- not a fresh resolve of main.
gh api "repos/$GH_REPO/contents/docker/locks/requirements-core.lock?ref=$tag" \
-H "Accept: application/vnd.github.raw" > "core-$tag.lock"
pinned="$(awk '/^[^#[:space:]-]/ && /==/ {n++} END {print n+0}' "core-$tag.lock")"
echo "$tag: docker/locks/requirements-core.lock carries $pinned pinned requirements"
# A run that examined nothing must never read as clean. 41 at the time of writing; this
# floor is a collapse detector, not a pin.
if [ "$pinned" -lt 25 ]; then
echo "::error::$tag: read only $pinned pinned requirements -- the audit input is missing or truncated. Failing closed." >&2
exit 2
fi
pip-audit -r "core-$tag.lock" --desc
echo "$tag: CLEAN -- $pinned pinned requirements of the CORE RUNTIME closure, no known advisory"
examined=$((examined + 1))
done
echo "released-line-audit: examined $examined release(s):"
printf '%s\n' "$targets"
echo "SCOPE: the CORE RUNTIME closure only (docker/locks/requirements-core.lock), which is"
echo "what the shipped SBOM inventories. Extras (webauthn, dicom, sqlserver) and the CI"
echo "toolchain are covered against main by the pip-audit job, not here. A wheel adopter"
echo "resolves against pyproject.toml's FLOORS, so a finding here is a statement about the"
echo "published SBOM's inventory, not about every install."
- name: What to do about a finding
if: failure()
run: |
set -euo pipefail
echo "The latest released line pins a component with a known advisory. The order matters."
echo
echo "1. TRIAGE FIRST. .github/SECURITY.md -- the clock starts at UPSTREAM-FIX availability,"
echo " not at this run. Priority is KEV first, then EPSS >= 0.7, with CVSS only as a"
echo " tiebreaker, weighing reachability. Pick the row from that file's SLA table. A HIGH"
echo " CVSS that is unreachable lands on the LAST row, not the High row."
echo
echo "2. THE REMEDY IS A RELEASE. docs/SUPPORT-POLICY.md -- there is no back-port, and"
echo " adopting a fix is a one-line pin bump, so a patch release on the latest line is the"
echo " only vendor action that changes an adopter's state."
echo
echo "3. IF IT IS UNREACHABLE, record the assessment as an OpenVEX statement in"
echo " security/vex/messagefoundry.openvex.json (security/vex/README.md) and bump the"
echo " document version. It ships with the NEXT release: release.yml copies that file from"
echo " the working tree at tag time, so the VEX commit must PRECEDE the tag."
echo
echo "4. NEVER edit the shipped artifact. The released SBOM and VEX are Sigstore-signed and"
echo " SLSA-attested at tag time. A post-tag assessment reaches consumers via the next"
echo " release, never by amending an old one."
echo
echo "5. NO UPSTREAM FIX YET? .github/SECURITY.md -- apply a documented compensating control"
echo " and track to the fix. To silence a triaged advisory here, add '--ignore-vuln <ID>'"
echo " to the pip-audit call in the previous step, with the reason."
sbom:
name: SBOMs (CycloneDX, multi-ecosystem)
runs-on: ubuntu-latest
# Advisory (non-blocking): generate machine-readable, LICENSE-complete CycloneDX SBOMs for the two code
# artifacts whose dependency trees pip-audit/npm-audit already watch — the Python engine (from the
# hash-locked core runtime lock) and the VS Code extension (from its npm lockfile) — score their quality,
# and retain them, so "are we exposed to CVE-X? under what license?" is answerable from a recorded bill of
# materials rather than a fresh resolve (ASVS 15.1.2). The container image's SBOM is emitted by the `trivy`
# job below (which builds the image). Full rationale: docs/SUPPLY-CHAIN.md + ADR 0149. Each step still
# fails if its SBOM can't be produced (a lock that won't render is caught), but continue-on-error keeps
# the job off the merge path.
continue-on-error: true
# Daily cron + on-demand only (CI cost): the SBOMs derive from COMMITTED lockfiles, so a per-PR build adds
# no information over a daily one — a freshness question, not a merge gate. Advisory (continue-on-error)
# and NOT a required check, so this gate can never wedge a PR.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
- name: Generate the Python engine SBOM (license-complete, hash-locked core runtime)
run: |
# environment mode reads each installed dist's METADATA so component LICENSES populate — the
# `requirements <lockfile>` parser has no metadata and emits license-less components. The core lock
# (docker/locks/requirements-core.lock) is the honest runtime closure `pip install messagefoundry`
# pulls; the all-extras requirements.lock stays covered by the pip-audit job above. cyclonedx-bom
# ~=7.3.1 → lxml 6.x (cp314 wheels) so the 3.14 runner doesn't source-build lxml. ADR 0149.
# PINNED and BYTE-IDENTICAL to release.yml's SBOM install (enforced by
# tests/test_ci_venv_pinning.py). ~=7.3.1 rather than ~=7.3 so a 7.4 cannot change the JSON
# shape sbom_finalize.py parses. This job is ADR 0034's pre-tag dry-run for the release SBOM
# step, so the two must stay the SAME command — if they drift, dispatching this one proves
# nothing about the release.
python -m pip install "pip==26.1.2" "cyclonedx-bom~=7.3.1"
# No unpinned pip bootstrap: --require-hashes resolves nothing (tests/test_ci_venv_pinning.py).
python -m venv /tmp/sbomenv
/tmp/sbomenv/bin/pip install --require-hashes -r docker/locks/requirements-core.lock
python -m cyclonedx_py environment /tmp/sbomenv/bin/python \
--pyproject pyproject.toml --mc-type application \
--output-format JSON --output-file sbom-python.cdx.json
# Declare the SBOM lifecycle (CISA "Build" type) + backfill the hatchling-dynamic root version.
python scripts/security/sbom_finalize.py sbom-python.cdx.json \
--phase build --set-version-from messagefoundry/__init__.py
- name: Generate the VS Code extension SBOM (npm, install-free)
# if: always() so a Python-SBOM failure above doesn't skip this independent generator.
if: always()
working-directory: ide
run: |
# --package-lock-only reads package-lock.json directly (no `npm install`), matching the npm-audit
# job's install-free posture. The extension has NO runtime npm dependencies (esbuild bundles the
# payload), so we capture the FULL tree — the build toolchain (esbuild/typescript/…) is the npm
# supply chain worth inventorying, and this mirrors npm-audit's full-tree scan. Tool version pinned.
npx --yes @cyclonedx/cyclonedx-npm@6.0.0 --package-lock-only \
--output-format JSON --output-file ../sbom-ide.cdx.json
python ../scripts/security/sbom_finalize.py ../sbom-ide.cdx.json --phase build
- name: Score SBOM quality (sbomqs — advisory)
# continue-on-error (matching the release.yml twin): sbomqs is advisory, so a download/checksum/parse
# hiccup here must never skip the SBOM-artifact upload below (the actual deliverable).
continue-on-error: true
run: |
# sbomqs grades an SBOM 0-10 across structural/semantic/quality completeness and prints the NTIA
# minimum-elements breakdown, so an SBOM-completeness regression is visible. Pinned + checksum-
# verified against the release's own goreleaser checksums.txt. ADR 0149.
VER=2.0.11
asset="sbomqs_${VER}_Linux_x86_64.tar.gz"
base="https://github.com/interlynk-io/sbomqs/releases/download/v${VER}"
curl -sSfL "${base}/${asset}" -o "${asset}"
curl -sSfL "${base}/checksums.txt" -o sbomqs-checksums.txt
grep " ${asset}$" sbomqs-checksums.txt | sha256sum -c -
tar -xzf "${asset}" sbomqs
sudo install -m 0755 sbomqs /usr/local/bin/sbomqs
sbomqs score -b sbom-python.cdx.json
sbomqs score -b sbom-ide.cdx.json
sbomqs compliance --ntia sbom-python.cdx.json || true
- name: Upload the SBOM artifacts
# if: always() + if-no-files-found: ignore so whichever SBOM(s) generated are retained even if a
# sibling generator failed. SHA-pinned for supply-chain integrity, matching ci.yml's upload pin.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: sbom-cyclonedx
path: |
sbom-python.cdx.json
sbom-ide.cdx.json
if-no-files-found: ignore
trivy:
name: trivy (container image vulnerabilities)
runs-on: ubuntu-latest
# ADVISORY for its first cycle (continue-on-error): scan the built engine image for OS-package and
# Python CVEs — the supply-chain layer pip-audit (lockfile only) and bandit/semgrep (source only)
# never see: the Debian base, system libs, and (on the -sqlserver variant) the msodbcsql18/unixODBC
# apt layer. Once a baseline run confirms it's clean, DELETE `continue-on-error` to make it BLOCKING
# like the other gates here. There's now a real docker/Dockerfile, so the image is a live surface.
continue-on-error: true
# Daily cron + on-demand only (CI cost): the scan INPUT (Dockerfile + base image + locks) changes
# rarely while trivy's vuln DB changes daily — the cron is the natural cadence, and ci.yml's
# docker-smoke still covers image buildability on merges. Advisory and NOT a required check, so
# this gate can never wedge a PR. If trivy is later promoted to BLOCKING (deleting
# continue-on-error per the note above), REMOVE this gate too so it runs pre-merge again.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build the slim engine image
# The slim default the docker-smoke leg (ci.yml) and the Dockerfile's `docker build` docs produce.
run: docker build -f docker/Dockerfile -t messagefoundry:scan .
- name: Install Trivy (pinned)
run: |
# Pin the release; bump deliberately (same posture as the gitleaks job). Verify the tag exists
# at https://github.com/aquasecurity/trivy/releases if this step 404s.
VER=0.71.2
curl -sSfL "https://github.com/aquasecurity/trivy/releases/download/v${VER}/trivy_${VER}_Linux-64bit.tar.gz" \
| tar -xz -C /tmp trivy
sudo install -m 0755 /tmp/trivy /usr/local/bin/trivy
trivy --version
- name: Generate the container-image SBOM (CycloneDX — OS + Python layers)
# The image SBOM is the "container contents" manifest NTIA's Consumer Playbook expects for a
# containerized artifact: the Debian base + system libs AND the installed Python runtime, none of
# which the lockfile/npm SBOMs capture. Retained so an operator can inventory the running image.
# RUN BEFORE the vuln scan on purpose: the scan's `--exit-code 1` fails its step on a fixable CVE,
# which (default `if: success()`) would SKIP any SBOM step placed after it — so the inventory would
# vanish exactly on the vulnerable runs that need it. ubuntu-latest ships python3; sbom_finalize is
# stdlib-only. ADR 0149.
run: |
trivy image --no-progress --format cyclonedx --output sbom-image.cdx.json messagefoundry:scan
python3 scripts/security/sbom_finalize.py sbom-image.cdx.json --phase build
- name: Upload the image SBOM
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: sbom-container-image
path: sbom-image.cdx.json
- name: Scan the image (fixable HIGH/CRITICAL fail the build)
# --ignore-unfixed keeps the gate ACTIONABLE: a Debian CVE with no upstream fix can't be
# remediated by us and shouldn't redden CI; only patchable HIGH/CRITICAL fail. --scanners vuln
# keeps this to CVEs (secrets stay gitleaks' job). --vex applies our maintained OpenVEX so a
# vulnerability we've assessed as not_affected/fixed is suppressed here too — the SAME file
# operators feed their scanners (--show-suppressed logs what was suppressed and why). Extend to
# `--target runtime-sqlserver` (more OS surface) once green. Trivy auto-pulls its vuln DB from ghcr.io.
run: |
trivy image --no-progress --scanners vuln \
--vex security/vex/messagefoundry.openvex.json --show-suppressed \
--severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
messagefoundry:scan
- name: Lint the Dockerfile for misconfig (informational)
# IaC checks (non-root USER, pinned base, secrets in layers). Informational only — never fails.
if: always()
run: trivy config --no-progress --severity HIGH,CRITICAL docker/Dockerfile || true
bandit:
name: bandit (Python SAST)
runs-on: ubuntu-latest
# BLOCKING: the baseline is clean, so any NEW insecure pattern (that isn't a reviewed per-line
# `# nosec`) fails the build instead of merging unnoticed.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Scan source for insecure patterns
run: |
# HASH-PINNED from the lock (ADR 0034 §3); bandit's version is the findings baseline of a
# blocking gate — the `# nosec`-parsing incident behind that pin is recorded beside the pin
# itself, in pyproject.toml's [dependency-groups].ci-scanners. Bump there, then re-export.
python -m pip install --require-hashes -r ci/locks/ci-scanners.lock
# Skips are reviewed-safe project idioms. The subprocess sites (B603/B607) are annotated
# per-line with `# nosec` at the call instead of a wholesale skip, so a NEW unreviewed
# subprocess call is still flagged (review low-27):
# B101 assert — type-narrowing invariants, not security checks
# B404 import subprocess — the calls themselves are per-line nosec'd (icacls/sc/ruff/mypy)
# B311 random — synthetic-data generators only; tokens use `secrets`
# B110 try/except/pass — best-effort resource cleanup
# B608 hardcoded SQL — f-strings interpolate only literal column names; values are bound
# params (verified — see security review STORE-4)
# `tee/` is in-tree vendored SOUP (a standalone relay) — scanned to the SAME bar as the
# engine. Its one urllib GET (tee/mefor_api.py) is per-line `# nosec B310`: the URL scheme is
# fixed by operator config (default localhost), never message-derived.
#
# SCOPE = the whole repo minus the excludes below, which mirrors the pre-commit bandit hook
# (.pre-commit-config.yaml) EXACTLY. It was `-r messagefoundry tee` while the hook scanned
# everything except tests/harness/samples — so scripts/ (security tooling, subprocess-heavy)
# was gated locally and by nothing in CI. Touching a file there failed the commit on findings
# no CI run could report. Widening cost nothing: the repo is already clean at this bar.
# tests/harness/samples — intentional non-production idioms (asserts, synthetic-data RNG)
# packaging/messagefoundry-webconsole/tests — the same, one directory deeper: 20 B105/B106
# "hardcoded password" hits that are all literal test credentials
# ide/ — TypeScript; no Python to scan
# docs/benchmarks/results — archived measurement artifacts, not maintained source
# tests/test_lint_scope_parity.py fails if this and the hook drift apart again.
bandit -r . --skip B101,B110,B311,B404,B608 \
--exclude ./tests,./harness,./samples,./ide,./docs/benchmarks/results,./packaging/messagefoundry-webconsole/tests,./.venv,./node_modules
gitleaks:
name: gitleaks (secret scan)
runs-on: ubuntu-latest
# BLOCKING: full-history scan came back clean; a newly-introduced secret now fails the build.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # full history so the scan also covers earlier commits, not just the tip
- name: Install gitleaks (pinned + checksum-verified)
run: |
# Pin the release; bump deliberately. Verify the tag exists if this step 404s.
#
# VERIFIED, not just version-pinned. This was `curl ... | tar -xz` straight into a pipe: a
# tag pin says WHICH artifact to fetch, not that the bytes received are that artifact, and
# this runs inside a REQUIRED gate. The sbomqs step in this same file already had the answer
# (download the release's own checksums file and sha256sum -c it), so this was unfinished
# scope rather than an accepted risk. Mirroring that shape exactly.
VER=8.18.4
asset="gitleaks_${VER}_linux_x64.tar.gz"
base="https://github.com/gitleaks/gitleaks/releases/download/v${VER}"
curl -sSfL "${base}/${asset}" -o "$asset"
curl -sSfL "${base}/gitleaks_${VER}_checksums.txt" -o gitleaks_checksums.txt
# Keep the asset's canonical name: sha256sum -c verifies BY the filename in the line, so a
# renamed download would silently verify nothing.
grep " ${asset}$" gitleaks_checksums.txt | sha256sum -c -
tar -xzf "$asset" gitleaks
sudo install -m 0755 gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: Scan repository for secrets
run: gitleaks detect --config .gitleaks.toml --redact --verbose --no-banner
semgrep:
name: semgrep (project SAST rules)
runs-on: ubuntu-latest
# BLOCKING: the first run was 0 findings; a new match for any rule now fails the build.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Run the MessageFoundry rules
run: |
# PINNED to an exact version, not `~=1.90`. A compatible-release range silently adopts every
# new 1.x, and a new semgrep release can add rules or change taint propagation — which reds a
# green PR for a reason that has nothing to do with its diff. Same posture as bandit==1.9.4
# above and zizmor==1.5.2: bump deliberately, in a PR that also clears any new findings.
# (1.172.0 is what `~=1.90` resolved to on 2026-07-29, so this pin is a no-op today.)
python -m pip install --upgrade pip "semgrep==1.172.0"
# --error: any finding fails the step — and the job, now that this gate is blocking.
#
# SCOPE = the whole repo minus the excludes below, which is the bandit exclude set in this
# same file EXACTLY. It was the allow-list `messagefoundry tee`, which never covered
# scripts/ (the security tooling itself), messagefoundry_webconsole/ (the operator UI) or
# docker/ — 59 tracked .py files that the sibling bandit gate already scans, held to none of
# this project's own dangerous-sink rules. An allow-list cannot be kept in step with "the
# project" by hand; `.` minus explicit excludes cannot go stale when a package is added.
# `tee/` (in-tree vendored SOUP) stays in scope for the reason it always was: the standalone
# relay is held to the SAME bar as the engine. Widening cost nothing — clean at this bar.
# tests/harness/samples — intentional non-production idioms. This exclude is here for
# PARITY WITH BANDIT — which is what the parity test enforces —
# NOT as a prediction about findings. semgrep's own default
# .semgrepignore already excludes tests/: that is the recorded
# fact scripts/ci/assert_semgrep_handler_taint.py:41 depends on,
# and the reason that step copies its fixture to a temp dir to
# get it analysed at all. So this flag is explicit and probably
# redundant, stated that way on purpose — a gate's scope must be
# readable from its own argv, never inherited from a tool default
# a version bump can change. (The only two sinks in the whole
# tree that match these rules do sit under tests/: an `eval`
# taint fixture and a `pickle.loads` sandbox test.)
# packaging/messagefoundry-webconsole/tests — the same, one directory deeper
# ide/ — TypeScript; no Python to scan
# docs/benchmarks/results — archived measurement artifacts, not maintained source
# The `./` prefix bandit uses is DROPPED on purpose: semgrep matches --exclude as a GLOB,
# not a path, so `./tests` matches nothing and the flag would be inert — copying bandit's
# string byte-for-byte yields an exclude list that excludes nothing. (Whether tests/ would
# then actually be SCANNED is the separate question the default .semgrepignore above
# answers; the point is that this gate's scope must not rest on that answer.)
# Whether those globs are root-anchored or match a bare directory NAME at any depth is not
# settled here, and is not load-bearing — measured over the WHOLE tracked tree, which is
# the population this scan now covers: the only nested directory matching any of these
# names is packaging/messagefoundry-webconsole/tests, excluded explicitly on both sides
# anyway (2026-08-04).
# tests/test_lint_scope_parity.py fails if this scope and bandit's drift apart again. It
# also asserts, separately, that no --exclude here carries the `./` prefix (its normalised
# set comparison would otherwise call `./tests` and `tests` identical), that no `--include`
# re-narrows the scan behind the positional `.`, and that `--error` survives.
semgrep --config .semgrep --error --metrics off \
--exclude tests --exclude harness --exclude samples --exclude ide \
--exclude docs/benchmarks/results --exclude packaging/messagefoundry-webconsole/tests \
--exclude .venv --exclude node_modules \
.
- name: Handler-config taint rules — validate, regression gate, scan samples (ADR 0144 Inc 3)
run: |
# semgrep is already installed by the step above (same job). This is why the packaged rules
# need NO project dependency to validate — the CI-installed semgrep is the authority.
RULES=messagefoundry/security/semgrep/handler-security.yml
semgrep --validate --config "$RULES" # rule-syntax gate
# Behavioural gate: the two recovered false-negatives (inter-statement taint + aliased-import)
# and the other rules fire exactly as annotated; the `# ok` cases stay clean. A --json assert
# (not `semgrep --test`, which crashes on Windows path-pairing) so it is deterministic.
python scripts/ci/assert_semgrep_handler_taint.py
semgrep --config "$RULES" --error --metrics off samples/config # shipped samples stay clean
# zizmor (GitHub Actions static analysis) moved to its own paths-filtered workflow
# (.github/workflows/zizmor.yml): it lints .github/** only, so running it on every PR was pure
# cost — it now runs when the workflow surface changes, plus the same daily cron + dispatch.
crypto-inventory:
name: crypto-inventory (ASVS 11.1.3 discovery gate)
runs-on: ubuntu-latest
# BLOCKING: enumerate every crypto call site (hashlib/secrets/hmac/ssl/argon2/cryptography) and diff
# against the maintained inventory in scripts/security/crypto_inventory_check.py (machine-readable
# companion to docs/ASVS-L2-PHASE0-CHANGES.md §4). A new/moved/undocumented crypto usage —
# or a stale inventory entry — fails the build, so the inventory can't silently drift (WP-L3-02).
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Diff crypto call sites against the inventory
# Stdlib-only (no install). Exits non-zero — failing the build — on any drift.
run: python scripts/security/crypto_inventory_check.py
forbidden-content:
name: forbidden-content (customer/PHI leak guard)
runs-on: ubuntu-latest
# BLOCKING (required context): scan the WHOLE tracked tree for customer/PHI-adjacent strings --
# partner/site names, the real production estate, routable host IPs, internal worktree slugs and
# absolute home paths. These are not *secrets* (gitleaks will not flag them) but must never land on
# this public repo. The committed scanner ships only STRUCTURAL detectors plus a synthetic
# .example; the real token list is NEVER committed -- it arrives from the MEFOR_FORBIDDEN_TOKENS
# secret here, and locally from a git-ignored scripts/security/scan-tokens.local.txt.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Scan the tracked tree for forbidden content
# Stdlib-only scanner (no install).
#
# MEFOR_MIN_DETECTORS is what makes "fail-closed" mean anything. Requiring a token SOURCE only
# proves one arrived: a partially-mangled secret still loaded as few as 1 of 21 detectors and
# passed with a green tick, and losing just the final line silently disabled every site-code
# detector. The floor is PER-SECTION, not a bare total -- a total is a SUM, so growth in a cheap
# section masks collapse in an expensive one (names 7->1 alongside estate 13->19 still totals 21).
# It is a FLOOR: adding tokens needs no CI change, losing them fails the build.
#
# zizmor: the secret is never interpolated into this run: body. It arrives as the step-level env
# var (an opaque value, not an Actions-expression sink).
env:
MEFOR_FORBIDDEN_TOKENS: ${{ secrets.MEFOR_FORBIDDEN_TOKENS }}
# Whether this run CANNOT legitimately see the secret. Empty on pushes and same-repo PRs.
IS_FORK_PR: ${{ github.event.pull_request.head.repo.fork }}
run: |
if [ -n "$MEFOR_FORBIDDEN_TOKENS" ]; then
# Deliberately NOT written to scripts/security/scan-tokens.local.txt: the scanner resolves
# the env var first, so that file would never be read -- it would only drop the full real
# token list into the job workspace for every later step to see.
export MEFOR_REQUIRE_TOKENS=1
export MEFOR_MIN_DETECTORS=names=7,estate=13,site_prefixes=1
echo "token list loaded from the MEFOR_FORBIDDEN_TOKENS secret (fail-closed, per-section floor)."
elif [ "$IS_FORK_PR" = "true" ]; then
echo "fork PR -- the secret is unavailable BY DESIGN; structural-only scan."
else
# Degrading here would be SILENT and PERMANENT: a renamed, deleted, environment-scoped or
# rotated secret takes the fork branch forever, scanning nothing while the REQUIRED context
# stays green. Absent-on-a-non-fork is a broken gate, not a mode.
echo "::error::MEFOR_FORBIDDEN_TOKENS is absent on a non-fork run. The customer-leak gate cannot run. Check the repository secret (repo-scoped, not environment-scoped)." >&2
exit 2
fi
python scripts/security/scan_forbidden.py --path .