-
Notifications
You must be signed in to change notification settings - Fork 0
456 lines (422 loc) · 21.6 KB
/
Copy pathci.yml
File metadata and controls
456 lines (422 loc) · 21.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
# CI workflow - builds, tests, and scans for vulnerabilities
# Runs on pushes to main and all pull requests
name: CI
on:
push:
branches: [ "main" ]
pull_request: # This will run on ALL pull requests regardless of source/target branch
permissions:
contents: read
# Within one run the jobs fan out across every available runner. Across runs, push
# builds on main are serialised: ldaps-tests mutates machine-global state on the
# self-hosted host (fixed container names, /etc/hosts entries, a trust-store CA),
# so two overlapping main runs would corrupt each other's fixtures. A newer queued
# main run supersedes an older queued one, which is fine: the newest commit contains
# the older ones. PR runs get one group per PR so a new push cancels the stale run.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'push' && 'main-push' || github.ref }}
cancel-in-progress: ${{ github.event_name != 'push' }}
jobs:
build-and-test:
# Split-trust runner model: push events only happen once code is on main (merged by
# an authorised person), so they may use the fast self-hosted runner. pull_request
# events execute code from anyone's fork and must stay on GitHub-hosted VMs; a
# self-hosted runner on a public repo would hand fork authors code execution on our
# infrastructure. The org runner group must also allowlist this workflow at
# refs/heads/main only, so that a fork PR editing this expression still cannot
# reach the runner. Every job in this file uses the same expression.
runs-on: ${{ github.event_name == 'push' && 'self-hosted' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Setup .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.x
- name: Restore dependencies
run: dotnet restore JIM.sln --locked-mode
- name: Build
run: dotnet build JIM.sln --no-restore
- name: Test .NET
run: dotnet test JIM.sln --no-build --verbosity normal
# Every source file must carry the Tetron copyright notice. .editorconfig
# only ever covered .cs (and only as a suggestion), so this is the gate for
# .razor, .ps1, .psm1, .psd1 and .sh.
- name: Lint copyright headers
shell: pwsh
run: pwsh -File ./scripts/Lint-CopyrightHeaders.ps1
# PowerShell Pester Tests for the JIM PowerShell module
- name: Install Pester
shell: pwsh
run: |
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module -Name Pester -MinimumVersion 5.0 -Force -Scope CurrentUser
- name: Run Pester Tests
shell: pwsh
run: |
$config = New-PesterConfiguration
$config.Run.Path = @("./src/JIM.PowerShell/Tests", "./scripts/Tests", "./test/integration/utils", "./.github/scripts/Tests")
$config.Run.Exit = $true
$config.Output.Verbosity = "Detailed"
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = "./TestResults/pester-results.xml"
$config.TestResult.OutputFormat = "NUnitXml"
Invoke-Pester -Configuration $config
- name: Upload Pester Test Results
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: pester-test-results
path: ./TestResults/pester-results.xml
openapi-document:
# Generate the OpenAPI document, and fail if it cannot be generated.
#
# The jim.web image bakes the document in at build time (the openapi-gen stage in
# src/JIM.Web/Dockerfile), so a document that cannot be generated is an image that
# cannot be built, and a release that cannot be cut. Generating it boots the app far
# enough to walk every route and every response type, which is why it catches a class
# of fault nothing else here can: duplicate route parameters, ambiguous templates,
# and reference cycles in the model graph. dotnet build stays clean through all of
# them, and unit tests call action methods directly, so they never touch routing or
# schema generation either.
#
# It is a required check because the alternative was demonstrated: #1238 shipped a
# cycle between ConnectedSystemObjectType and its tags, every check passed, and no
# release image could be built until it was found by hand days later. Local builds do
# not cover it either, deliberately: jim-build passes OPENAPI_STAGE=publish to skip
# the stage because it costs minutes.
#
# Its own job rather than a step in build-and-test: generation alone takes about five
# minutes (measured in both Debug and Release; it is the schema walk over the model
# graph, not the build), and it needs nothing the tests produce. Run alongside them
# it costs no wall clock; appended to them it would add five minutes to every PR.
runs-on: ${{ github.event_name == 'push' && 'self-hosted' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Setup .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.x
- name: Restore dependencies
run: dotnet restore JIM.sln --locked-mode
- name: Build
run: dotnet build JIM.sln --no-restore
# The same script the devcontainer uses (jim-openapi-generate), so what CI proves and
# what a developer can reproduce are the same code path. -NoBuild reuses the build
# above. The output goes to the runner's temp directory: the document is a build
# artefact of the image, not a file this repository tracks.
- name: Generate the OpenAPI document
shell: pwsh
run: ./scripts/Generate-OpenApiDoc.ps1 -NoBuild -OutputPath "${{ runner.temp }}/openapi-v1.json"
database-tests:
# Runs the RequiresPostgres NUnit tier (the "Database-backed component tests"
# tier in engineering/TESTING_STRATEGY.md) against a real PostgreSQL service.
# These fixtures self-skip in the in-memory build-and-test job (JIM_TEST_RESET_DB
# unset); here they execute, guarding provider-specific and raw-SQL behaviour the
# EF Core in-memory provider cannot reproduce (the class of bug behind #849/#850).
# See issue #861. Runs on every PR so a failure blocks the merge.
runs-on: ${{ github.event_name == 'push' && 'self-hosted' || 'ubuntu-latest' }}
services:
postgres:
# Pinned to match the production database image in docker-compose.yml; bump
# both together. Dependabot's docker ecosystem does not scan workflow service
# images, so this digest pin is maintained by hand.
image: postgres:18.4@sha256:4aabea78cf39b90e834caf3af7d602a18565f6fe2508705c8d01aa63245c2e20
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: jim_test
# Ephemeral host port. A fixed 5432:5432 collides on the self-hosted runner
# host, where a second runner service can be running another job (e.g. a
# JIM-Bench run) with its own PostgreSQL on the same port at the same moment.
ports:
- 5432
# Hold the job's steps until PostgreSQL is accepting connections.
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
# The RequiresPostgres fixtures read these to build the connection string and to
# opt in (JIM_TEST_RESET_DB being set is what flips them from Assert.Ignore to run).
# jim_test is a throwaway database: each fixture migrates the schema once and
# TRUNCATEs every table between tests.
env:
JIM_TEST_RESET_HOST: localhost
JIM_TEST_RESET_USER: postgres
JIM_TEST_RESET_PASSWORD: postgres
JIM_TEST_RESET_DB: jim_test
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Setup .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.x
- name: Restore dependencies
run: dotnet restore JIM.sln --locked-mode
- name: Build
run: dotnet build JIM.sln --no-restore
- name: Run database-backed tests (RequiresPostgres)
# The ephemeral host port must be read here rather than in the job-level env block
# above: the job context is only available inside steps, and referencing it at job
# level invalidates the whole workflow file (GitHub reports "workflow file issue"
# and no CI jobs run at all).
env:
JIM_TEST_RESET_PORT: ${{ job.services.postgres.ports['5432'] }}
run: dotnet test JIM.sln --no-build --filter "Category=RequiresPostgres" --verbosity normal
ldaps-tests:
# Runs the RequiresLdaps NUnit tier against real directory servers over TLS:
# three OpenLDAP variants (system-trusted CA, JIM-store-only CA, expired
# certificate) and a Samba AD Domain Controller. These fixtures self-skip in
# build-and-test (JIM_TEST_LDAPS_HOST unset); here the fixture script stands
# the servers up and exports the environment variables that flip them from
# Assert.Ignore to run. This is the regression net for LDAPS certificate
# validation (#1132): connections must be refused for untrusted issuers, name
# mismatches and expired certificates, with the reason reported, and must
# succeed when the CA is in the JIM certificate store. See issue #1141.
# Runs on every PR so a failure blocks the merge.
#
# LDAPTLS_REQCERT must never be set here: it would disable libldap certificate
# validation for the whole process and turn every refusal case into a false pass,
# which is the exact defect class this job exists to catch. A guard step asserts it.
runs-on: ${{ github.event_name == 'push' && 'self-hosted' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Setup .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.x
- name: Restore dependencies
run: dotnet restore JIM.sln --locked-mode
- name: Build
run: dotnet build test/JIM.Worker.Tests/ --no-restore
- name: Assert LDAPTLS_REQCERT is not in scope
run: |
if [ -n "${LDAPTLS_REQCERT}" ]; then
echo "LDAPTLS_REQCERT is set; it would disable certificate validation and void this job."
exit 1
fi
# Docker Hub rate-limits anonymous pulls (429s observed in testing), so pull the
# fixture images up front with a retry rather than letting the fixture script's
# docker run fail on a transient refusal.
- name: Pull directory server images
run: |
for image in bitnamilegacy/openldap:latest diegogslomp/samba-ad-dc:latest; do
for attempt in 1 2 3; do
docker pull "$image" && break
if [ "$attempt" = "3" ]; then
echo "Failed to pull $image after 3 attempts."
exit 1
fi
sleep 30
done
done
# The script needs root: it writes /etc/hosts entries for the certificate names
# and installs one test CA into the machine trust store. sudo -E preserves
# GITHUB_ENV so the script can export the connection details to later steps.
- name: Start LDAPS certificate test servers
run: sudo -E pwsh -File ./test/scripts/Start-LdapsCertificateTestServers.ps1 -IncludeSambaAd
- name: Run LDAPS certificate validation tests (RequiresLdaps)
run: dotnet test test/JIM.Worker.Tests/ --no-build --filter "Category=RequiresLdaps" --verbosity normal
# On push events this job runs on the self-hosted runner, which is NOT ephemeral:
# without teardown the containers, /etc/hosts entries and the trust-store CA
# persist on the machine between jobs. Harmless on GitHub-hosted PR runs, essential
# on the self-hosted host; run it unconditionally.
- name: Tear down LDAPS certificate test servers
if: always()
run: sudo pwsh -File ./test/scripts/Start-LdapsCertificateTestServers.ps1 -Stop
discover-base-images:
# Discover production Dockerfiles and enforce the digest-pinning policy.
# Production Dockerfiles are identified by the "# jim-compliance: production-image"
# directive; unlabelled Dockerfiles (devcontainer, test fixtures) are out of scope.
# Any production Dockerfile with a non-digest-pinned FROM line fails here. The job
# emits two matrices: the unique base image references (the pin policy's subject)
# and the production Dockerfiles themselves, which scan-images builds and scans.
# See .github/scripts/discover-base-images.ps1 and engineering/DEVELOPER_GUIDE.md.
runs-on: ${{ github.event_name == 'push' && 'self-hosted' || 'ubuntu-latest' }}
outputs:
matrix: ${{ steps.discover.outputs.matrix }}
image_matrix: ${{ steps.discover.outputs.image_matrix }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Discover production base images and enforce digest-pinning
id: discover
shell: pwsh
run: ./.github/scripts/discover-base-images.ps1
scan-images:
# Build each production JIM image and scan it for CRITICAL/HIGH CVEs with a
# published fix. Emits SARIF so findings are surfaced in the GitHub Security tab,
# not just the Actions log. Catches vulnerabilities early, before a release tag
# is created.
#
# The built image, not the base image, is the subject: it is what customers run,
# and it alone carries the apt pins and the build-time apt-get upgrade (see any
# production Dockerfile). Scanning the base image instead reported every Ubuntu
# advisory Microsoft's rebuild had not yet caught up with, none of which the
# shipped image still contained, and could not see the JIM layer at all. The
# matrix comes from discover-base-images, so adding a new production Dockerfile
# requires zero changes to this workflow: just add the
# "# jim-compliance: production-image" directive to the new file.
needs: discover-base-images
runs-on: ${{ github.event_name == 'push' && 'self-hosted' || 'ubuntu-latest' }}
permissions:
contents: read
security-events: write # required to upload SARIF to code scanning
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.discover-base-images.outputs.image_matrix) }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
# Same action, pins and cache scope as the release build, so a PR's build warms the
# cache the release will use and vice versa. The image is loaded into the local
# daemon for Trivy to read via --image-src docker; nothing is pushed. OPENAPI_STAGE
# skips the JIM.Web OpenAPI generation stage, which costs about five minutes and
# contributes nothing to what is scanned; the openapi-document job proves it separately.
- name: Build image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ${{ matrix.dockerfile }}
load: true
tags: ${{ matrix.image_name }}:scan
build-args: |
VERSION=scan
OPENAPI_STAGE=publish
cache-from: type=gha,scope=${{ matrix.image_name }}
cache-to: type=gha,scope=${{ matrix.image_name }},mode=max
- name: Scan image for vulnerabilities
id: trivy
# Run Trivy from its pinned container image rather than via
# aquasecurity/trivy-action + setup-trivy. The action path re-downloaded
# the Trivy binary from the GitHub releases API on every run, which failed
# intermittently on GitHub-hosted runners ("unable to find 'vX.Y.Z'"), and
# the action wrapper independently exited 1 without writing a SARIF
# (phantom failure). Both flakes blocked unrelated PRs at random. Calling
# the pinned binary directly removes the release-API lookup entirely and
# lets us own the exit code. See PR #520 and #800.
#
# github.token authenticates the vuln-DB pull from ghcr.io, avoiding the
# anonymous rate limit (the one external dependency left on the hot path).
#
# No severity filter on the scan: we emit every finding to the SARIF and
# apply our own CVSS-based gate in the next step (more reliable than the
# action's filter, and uses the same score GitHub Code Scanning shows).
# ignore-unfixed stays on: no value blocking on CVEs with no upstream fix.
#
# The image tag is passed via env, not interpolated into the run script, to
# avoid GitHub Actions expression-injection (flagged by CodeQL actions).
env:
# aquasec/trivy:0.70.0 pinned by digest (supply-chain governance).
TRIVY_IMAGE: 'aquasec/trivy:0.70.0@sha256:be1190afcb28352bfddc4ddeb71470835d16462af68d310f9f4bca710961a41e'
IMAGE_REF: ${{ matrix.image_name }}:scan
GITHUB_TOKEN: ${{ github.token }}
run: |
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$GITHUB_WORKSPACE:/workspace" \
-w /workspace \
-e GITHUB_TOKEN \
-e TRIVY_IGNORE_UNFIXED=true \
"$TRIVY_IMAGE" image \
--scanners vuln \
--format sarif \
--output trivy-results.sarif \
--ignorefile .trivyignore \
--image-src docker \
--exit-code 0 \
"$IMAGE_REF"
- name: Fail build on fixable HIGH/CRITICAL Trivy findings
shell: pwsh
run: |
if (-not (Test-Path 'trivy-results.sarif')) {
Write-Error "Trivy did not produce a SARIF file."
exit 1
}
$sarif = Get-Content 'trivy-results.sarif' -Raw | ConvertFrom-Json
# Build a ruleId -> CVSS score map from the SARIF rules catalogue.
# Trivy writes the CVSS base score to rule.properties.'security-severity'
# as a decimal string (e.g. "7.5"). This is the same field GitHub Code
# Scanning reads to classify alerts as low/medium/high/critical.
$severityByRule = @{}
foreach ($r in $sarif.runs) {
foreach ($rule in $r.tool.driver.rules) {
$score = 0.0
$raw = $rule.properties.'security-severity'
if ($raw) { [void][double]::TryParse($raw, [ref]$score) }
$severityByRule[$rule.id] = $score
}
}
# Walk results, classify by CVSS, and count only HIGH (>= 7.0) and
# CRITICAL (>= 9.0). Also collect a sample for the log.
$critical = 0
$high = 0
$medium = 0
$low = 0
$blocking = @()
foreach ($r in $sarif.runs) {
foreach ($result in @($r.results)) {
if (-not $result) { continue }
$ruleId = $result.ruleId
$score = [double]$severityByRule[$ruleId]
if ($score -ge 9.0) {
$critical++
$blocking += "$ruleId (CVSS $score)"
} elseif ($score -ge 7.0) {
$high++
$blocking += "$ruleId (CVSS $score)"
} elseif ($score -ge 4.0) {
$medium++
} else {
$low++
}
}
}
Write-Host "Trivy findings for ${{ matrix.image_name }} (${{ matrix.dockerfile }}):"
Write-Host " CRITICAL: $critical"
Write-Host " HIGH: $high"
Write-Host " MEDIUM: $medium (not blocking)"
Write-Host " LOW: $low (not blocking)"
Write-Host ''
$blockingCount = $critical + $high
if ($blockingCount -gt 0) {
Write-Host "Blocking CVEs (CVSS >= 7.0):"
$blocking | Sort-Object -Unique | ForEach-Object { Write-Host " $_" }
Write-Host ''
Write-Host 'Fixable CRITICAL/HIGH vulnerabilities found. Failing build.'
Write-Host 'See the GitHub Security tab for details:'
Write-Host ' https://github.com/TetronIO/JIM/security/code-scanning'
exit 1
}
Write-Host 'No fixable CRITICAL/HIGH vulnerabilities found.'
- name: Upload Trivy scan results to GitHub code scanning
if: always() && hashFiles('trivy-results.sarif') != ''
uses: github/codeql-action/upload-sarif@6f5948dfacef28e207b48d0905cf90c03365536d # v3.37.9
with:
sarif_file: trivy-results.sarif
# Category is keyed by the JIM image name (stable across base image
# digest bumps and Dockerfile edits), so fixed findings close on the
# next upload rather than sitting orphaned under a category that is
# never uploaded again. See discover-base-images.ps1.
category: trivy-image-${{ matrix.image_name }}
scan-base-images-summary:
# Stable-name gate for the dynamic scan-images matrix, providing the single
# check name the branch protection ruleset requires. The name predates the
# move from scanning base images to scanning the built JIM images and is
# kept because the ruleset names it; renaming it here without changing the
# ruleset would leave every PR waiting on a check that never reports.
if: always()
needs: [discover-base-images, scan-images]
runs-on: ${{ github.event_name == 'push' && 'self-hosted' || 'ubuntu-latest' }}
steps:
- name: Check matrix results
run: |
if [ "${{ needs.discover-base-images.result }}" != "success" ]; then
echo "discover-base-images failed or was cancelled"
exit 1
fi
if [ "${{ needs.scan-images.result }}" != "success" ]; then
echo "One or more scan-images matrix legs failed"
exit 1
fi
echo "All image scans passed"