-
Notifications
You must be signed in to change notification settings - Fork 188
198 lines (185 loc) · 8.63 KB
/
Copy pathimage-scan.yml
File metadata and controls
198 lines (185 loc) · 8.63 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
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Grype vulnerability scans of the images main publishes.
#
# These ran inside the build job in docker-build.yml. They are advisory
# (fail-build is false and nothing consumes their result), but they sat on Core
# CI's critical path, where the last job a run waits on spent 24 of its 38
# minutes fetching an 8 GB image back out of the registry and scanning it.
# Running them after the workflow finishes covers the same images without Core CI
# waiting on them.
#
# The contract: docker-build.yml's "Record the pushed image as a scan target"
# step uploads one scan-target-<image> artifact per image, holding the image
# reference and the runner label that built it. This workflow makes those its
# matrix, so scan: true at the ci.yaml call site remains the only place deciding
# which images are covered. Only main records targets, so only main scans.
#
# Editing note: workflow_run triggers are read from the default branch, so
# changes here take effect only once merged and cannot be exercised by a pull
# request.
name: NICo Image Scan
on:
workflow_run:
workflows: ["NICo Core CI"]
types: [completed]
permissions:
contents: read
# Required to list and download the triggering run's artifacts.
actions: read
jobs:
collect:
# workflow_run fires for every ref Core CI ran on, including pull requests,
# and those record no targets. Checked here so the common case costs one
# skipped job rather than an artifact listing.
if: ${{ github.event.workflow_run.head_branch == 'main' }}
runs-on: linux-amd64-cpu4
outputs:
targets: ${{ steps.matrix.outputs.targets }}
steps:
# Deliberately not gated on the triggering run's conclusion. A target only
# exists for an image that built and pushed successfully, so one unrelated
# failing job elsewhere in Core CI is no reason to stop scanning the images
# that did publish.
- name: Look for scan targets
id: present
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
run: |
set -euo pipefail
count=$(gh api --paginate "repos/${REPO}/actions/runs/${RUN_ID}/artifacts" \
--jq '[.artifacts[] | select(.name | startswith("scan-target-"))] | length' \
| jq -s 'add // 0')
printf 'count=%s\n' "${count}" >> "$GITHUB_OUTPUT"
if [ "${count}" -eq 0 ]; then
echo "::notice::Core CI run ${RUN_ID} recorded no scan targets; nothing to scan."
fi
# Skipped when there is nothing to fetch: download-artifact fails outright
# on a pattern that matches no artifact.
- name: Download the scan targets
if: ${{ steps.present.outputs.count != '0' }}
uses: actions/download-artifact@v4
with:
pattern: scan-target-*
path: targets
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build the scan matrix
id: matrix
env:
COUNT: ${{ steps.present.outputs.count }}
run: |
set -euo pipefail
if [ "${COUNT}" = '0' ]; then
printf 'targets=[]\n' >> "$GITHUB_OUTPUT"
exit 0
fi
# Each artifact expands to its own directory holding one
# scan-target.json; -s folds the objects into the array the matrix
# takes. Sorted so the job list is stable between runs.
targets=$(find targets -name scan-target.json -exec cat {} + | jq -sc 'sort_by(.key)')
printf 'targets=%s\n' "${targets}" >> "$GITHUB_OUTPUT"
jq -r '.[] | "\(.key)\t\(.image)\t\(.runner)"' <<<"${targets}"
scan:
needs: collect
if: ${{ needs.collect.outputs.targets != '[]' }}
strategy:
# The images are independent, and a scan that fails should not hide the
# results for the rest.
fail-fast: false
matrix:
target: ${{ fromJson(needs.collect.outputs.targets) }}
# The label the image was built on. A single-platform manifest can only be
# pulled by a host of that architecture.
runs-on: ${{ matrix.target.runner }}
# Generous against the largest image: the boot-artifacts carrier has taken
# 24 minutes to fetch and scan. Present so a hung scan cannot occupy a
# self-hosted runner for the six-hour default.
timeout-minutes: 60
steps:
- name: Resolve the registry host
id: registry
env:
IMAGE: ${{ matrix.target.image }}
run: printf 'host=%s\n' "${IMAGE%%/*}" >> "$GITHUB_OUTPUT"
# Same credentials and fallback order docker-build.yml pushed with, so
# anything it could publish, this can read back.
- name: Log in to the registry
uses: docker/login-action@v3
with:
registry: ${{ steps.registry.outputs.host }}
username: ${{ secrets.NICO_TARGET_REGISTRY_USERNAME || secrets.NVCR_USERNAME }}
password: ${{ secrets.NICO_TARGET_REGISTRY_TOKEN || secrets.NVCR_TOKEN }}
# security-container-scan inspects the local Docker daemon and never pulls
# for itself; without this the scan reports "local docker image not found"
# and silently skips.
- name: Pull the image
env:
IMAGE: ${{ matrix.target.image }}
run: docker pull "${IMAGE}"
- name: Grype vulnerability scan
id: scan
continue-on-error: true
uses: dsx-ai-factory/dsx-github-actions/.github/actions/security-container-scan@aa4e470cc53f3886545c7d72a984eb81f1d203e2 # v1.16.2
with:
image: ${{ matrix.target.image }}
fail-on: critical
fail-build: 'false'
write-summary: 'false'
# Named after the Core CI run rather than this one, which is what makes
# a report traceable back to the build that produced the image.
artifact-name: grype-${{ matrix.target.key }}-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
sbom-artifact-name: sbom-${{ matrix.target.key }}-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
# fail-build stays false, preserving the policy the scan already had: this
# change is about when the scan runs, not about converting pre-existing
# findings into red checks. Since nothing downstream waits on this
# workflow any more, tightening that is now a decision on its own.
# Meanwhile the status is at least stated, rather than being knowable only
# by opening the artifact.
- name: Report the scan status
if: ${{ !cancelled() }}
env:
KEY: ${{ matrix.target.key }}
IMAGE: ${{ matrix.target.image }}
STATUS: ${{ steps.scan.outputs.status }}
DETAIL: ${{ steps.scan.outputs.detail }}
SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
status="${STATUS:-unknown}"
# Counts and statuses only. This summary is public on a public
# repository, so which CVE affects which shipped image stays in the
# artifact rather than being published as a checklist.
{
printf '### %s\n\n' "${KEY}"
printf -- '- Image: %s\n' "${IMAGE}"
printf -- '- Commit: %s\n' "${SHA}"
printf -- '- Status: %s\n' "${status}"
} >>"$GITHUB_STEP_SUMMARY"
if [ "${status}" != 'ok' ]; then
echo "::warning::${KEY}: Grype status ${status} (${DETAIL:-no detail reported})"
fi
# These runners are self-hosted, so the daemon keeps whatever a job leaves
# behind. Nothing here reuses the image, and the boot-artifacts carrier
# alone is 8 GB, so every scan would otherwise add to the disk until it
# ran out. Untagging is enough: the layers become unreferenced and the
# daemon reclaims them.
- name: Remove the pulled image
if: ${{ always() }}
env:
IMAGE: ${{ matrix.target.image }}
run: docker image rm "${IMAGE}" || true