11#! /usr/bin/env bash
2- # Supply-chain gate for pkgdiff: NuGet, npm and (optionally) the built images.
3- #
4- # bash .github/scripts/check-deps.sh run from any directory
5- #
6- # TRIVY=1 also scan ${BACKEND_IMAGE:-ghcr.io/pkgdiff-dev/pkgdiff-backend:local} and
7- # ${FRONTEND_IMAGE:-ghcr.io/pkgdiff-dev/pkgdiff-frontend:local} with the
8- # pinned Trivy image (the defaults are what docker-compose.build.yml builds)
9- # CHECK_DEPS_DOCKER=auto auto (default): use dotnet / npm from PATH, and the pinned SDK / Node
10- # image for a tool that is missing; 1: always containers; 0: never
11- # PKGDIFF_ROOT=<dir> check another checkout (default: the checkout this script is in)
12- #
13- # Checks (every check runs, also after a failure):
14- # sdk-image-pin the SDK image is pinned by digest and is the same reference in
15- # src/backend/Dockerfile, src/backend/Directory.Build.props and the QuestPDF harness
16- # nuget-locked-restore dotnet restore --locked-mode with the audit gate (lock drift, NU1901-NU1904)
17- # nuget-vulnerable dotnet list package --vulnerable --include-transitive, read as JSON
18- # npm-audit-prod npm audit --omit=dev (production dependencies must be clean)
19- # npm-audit-high .github/scripts/npm-audit-gate.mjs with src/frontend/audit-allowlist.json
20- # trivy-backend TRIVY=1 only: fixable HIGH/CRITICAL in the backend image
21- # trivy-frontend TRIVY=1 only: fixable HIGH/CRITICAL in the frontend image
22- #
23- # Output: one "PASS <check>" or "FAIL <check>: <detail>" line per check (evidence is indented
24- # above it), then "check-deps: N passed, M failed". A check that could not be carried out is a
25- # FAIL whose detail starts with "could not check"; it is never reported as a pass.
26- # Exit status: 0 when every check passed, otherwise 1.
27- #
28- # The checkout is never modified: the manifests are copied to a temporary directory and the
29- # tools run on that copy, either on this machine or inside a container.
2+ # Supply-chain gate: NuGet and npm audits, plus image scans with TRIVY=1. Exit 0 when all pass.
3+ # Env: TRIVY=1, CHECK_DEPS_DOCKER=auto|1|0, PKGDIFF_ROOT=<dir>, BACKEND_IMAGE, FRONTEND_IMAGE.
304set -uo pipefail
315
326export DOTNET_CLI_UI_LANGUAGE=en DOTNET_NOLOGO=1 DOTNET_CLI_TELEMETRY_OPTOUT=1
@@ -51,8 +25,7 @@ case "$MODE" in
5125 ;;
5226esac
5327
54- # Git Bash rewrites arguments that look like POSIX paths (/work, /var/run/docker.sock) before
55- # docker.exe sees them; turn that off and use the socket form Docker Desktop accepts.
28+ # Git Bash rewrites POSIX-looking arguments before docker.exe sees them; turn that off.
5629DOCKER_SOCKET=' /var/run/docker.sock'
5730case " $( uname -s 2> /dev/null) " in
5831 MINGW* | MSYS* | CYGWIN* )
@@ -121,21 +94,18 @@ one_line() {
12194 tr ' \r\n' ' ' | sed ' s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//'
12295}
12396
124- # Prints the image of the "AS build" stage of a Dockerfile.
12597build_stage_image () {
12698 [ -f " $1 " ] || return 1
12799 tr -d ' \r' < " $1 " |
128100 sed -n ' s/^FROM[[:space:]][[:space:]]*\([^[:space:]][^[:space:]]*\)[[:space:]][[:space:]]*[Aa][Ss][[:space:]][[:space:]]*build[[:space:]]*$/\1/p' |
129101 head -n 1
130102}
131103
132- # A status that decides something never comes from `writer | grep -q`: under pipefail a writer
133- # killed by SIGPIPE makes a match read as "no match". These tests read a here-string or a file.
104+ # No `writer | grep -q` for decisions: under pipefail a SIGPIPE'd writer turns a match into a miss.
134105is_pinned () {
135106 grep -Eq ' ^[A-Za-z0-9][A-Za-z0-9./_-]*:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$' <<< " $1"
136107}
137108
138- # Decides whether a tool set runs in a container: 0 = container, 1 = this machine.
139109use_container () {
140110 local tool
141111 case " $MODE " in
@@ -148,9 +118,7 @@ use_container() {
148118 return 1
149119}
150120
151- # run_in_container <image> <dir> [docker create options]
152- # Copies <dir> to /work in a fresh container, runs /work/run.sh there and copies /work/out back.
153- # No bind mount is used, so nothing depends on how this machine shares its file system with Docker.
121+ # Copies files in and out instead of bind-mounting, so host file sharing with Docker doesn't matter.
154122run_in_container () {
155123 local image=" $1 " dir=" $2 " cid rc
156124 shift 2
@@ -169,8 +137,6 @@ run_in_container() {
169137 return " $rc "
170138}
171139
172- # ---------------------------------------------------------------- sdk-image-pin
173-
174140check_sdk_image_pin () {
175141 local name=' sdk-image-pin'
176142 local dockerfile=" $ROOT /src/backend/Dockerfile"
@@ -205,8 +171,6 @@ check_sdk_image_pin() {
205171 pass " $name "
206172}
207173
208- # ---------------------------------------------------------------- NuGet
209-
210174prepare_nuget () {
211175 local dir=" $1 " rel
212176 mkdir -p " $dir " || return 1
@@ -240,7 +204,6 @@ echo "$?" >out/vulnerable.rc
240204EOF
241205}
242206
243- # Unique "error XX0000: message" lines of a restore log, without the trailing "[project path]".
244207restore_errors () {
245208 tr -d ' \r' < " $1 " | sed -n ' s/.*\(error [A-Z][A-Z]*[0-9][0-9]*: .*\)$/\1/p' | sed ' s/ \[[^][]*\]$//' | sort -u
246209}
@@ -286,11 +249,9 @@ check_nuget() {
286249 return
287250 fi
288251
289- # nuget-locked-restore
290252 rc=" $( read_rc " $out /locked-restore.rc" ) "
291253 if [ " $rc " = " 0" ] && grep -Eq ' NU190[05]' " $out /locked-restore.log" ; then
292- # With packages in the local cache, a restore still succeeds when the advisory data cannot be
293- # downloaded; it only warns (NU1900 / NU1905). The audit did not run, so this is not a pass.
254+ # A cached restore succeeds with only a warning when advisory data can't load: no audit ran.
294255 tr -d ' \r' < " $out /locked-restore.log" | grep -oE ' warning NU190[05]: [^[]*' | sort -u | evidence
295256 unchecked nuget-locked-restore " the restore succeeded, but the advisory data could not be loaded (NU1900/NU1905), so the audit did not run"
296257 elif [ " $rc " = " 0" ]; then
@@ -315,7 +276,6 @@ check_nuget() {
315276 fi
316277 fi
317278
318- # nuget-vulnerable
319279 rc=" $( read_rc " $out /plain-restore.rc" ) "
320280 if [ " $rc " != " 0" ]; then
321281 restore_errors " $out /plain-restore.log" 2> /dev/null | head -n 10 | evidence
@@ -343,7 +303,6 @@ check_nuget() {
343303 return
344304 fi
345305 if grep -q ' "vulnerabilities"' " $out /vulnerable.json" ; then
346- # The report lists only affected packages, so every id / resolvedVersion pair in it is a finding.
347306 findings=" $( grep -oE ' "(id|resolvedVersion|severity|advisoryurl)": *"[^"]*"' " $out /vulnerable.json" |
348307 awk -F' "' '
349308 $2 == "id" { id = $4 }
@@ -358,8 +317,6 @@ check_nuget() {
358317 pass nuget-vulnerable
359318}
360319
361- # ---------------------------------------------------------------- npm
362-
363320prepare_npm () {
364321 local dir=" $1 " file
365322 mkdir -p " $dir /Frontend" " $dir /scripts" || return 1
@@ -437,7 +394,7 @@ check_npm() {
437394 return
438395 fi
439396
440- # npm-audit-prod: npm audit --omit=dev exits non-zero on findings and on errors; the JSON tells them apart.
397+ # npm audit exits non-zero on findings and on errors; only the JSON tells them apart.
441398 rc=" $( read_rc " $out /audit-prod.rc" ) "
442399 if ! grep -q ' "auditReportVersion"' " $out /audit-prod.json" 2> /dev/null; then
443400 cat " $out /audit-prod.json" " $out /audit-prod.err" 2> /dev/null | tail -n 10 | evidence
@@ -451,13 +408,9 @@ check_npm() {
451408 fail npm-audit-prod " production dependencies have advisories: $( printf ' %s' " $packages " | one_line) "
452409 fi
453410
454- # npm-audit-high: replaces a bare `npm audit --audit-level=high`, which cannot tell a reviewed,
455- # time-boxed exception from a new advisory. The gate keeps its own 0 / 1 / 2 exit status visible.
456411 rc=" $( read_rc " $out /gate.rc" ) "
457412 case " $rc " in
458413 0)
459- # Only the entries of the "Accepted exceptions" block are reviewed exceptions. The "Not gated"
460- # blocks list advisories below high that nobody reviewed; they keep their own label.
461414 tr -d ' \r' < " $out /gate.log" | sed -n \
462415 -e ' 1p' \
463416 -e ' /^Accepted exceptions/,/^$/s/^ - \(GHSA-.*\)$/accepted exception: \1/p' \
@@ -481,8 +434,6 @@ check_npm() {
481434 esac
482435}
483436
484- # ---------------------------------------------------------------- Trivy
485-
486437check_trivy () {
487438 local name=" $1 " image=" $2 " log=" $WORK /$1 .log" rc detail
488439
@@ -513,24 +464,16 @@ check_trivy() {
513464 unchecked " $name " " trivy exit 0, but it detected no OS packages in $image , so nothing was scanned"
514465 fi
515466 elif [ " $rc " -eq 1 ] && grep -Eq ' ^Total: [1-9][0-9]* \(' " $log " ; then
516- # Exit 1 together with a report is the only result that means "findings".
517- # grep reads the file itself: behind a pipe, `grep -q` stops at the first match, the writer
518- # dies of SIGPIPE on a large report, and pipefail turns the match into "false". The pattern
519- # has no end anchor, so a trailing CR does not matter.
520- # The whole report: whoever fixes the image needs the package and the fixed version.
467+ # Only exit 1 together with a report means findings.
521468 grep -v " $( printf ' \tINFO\t' ) " " $log " | evidence
522469 fail " $name " " fixable HIGH/CRITICAL findings in $image : $( tr -d ' \r' < " $log " | grep -E ' ^Total: ' | one_line) "
523470 else
524- # No report: the scanner never started (docker run exits 125, 126 or 127 when the image cannot
525- # be pulled or run) or it stopped before the scan finished. Nothing is known about the image.
526471 tail -n 10 " $log " | evidence
527472 detail=" $( grep -E ' FATAL|[Ee]rror' " $log " | head -n 1 | one_line) "
528473 unchecked " $name " " trivy did not complete (exit $rc )${detail: +: $detail } "
529474 fi
530475}
531476
532- # ---------------------------------------------------------------- run
533-
534477echo " check-deps: root $ROOT "
535478
536479check_sdk_image_pin
0 commit comments