318 lines
13 KiB
Bash
Executable File
318 lines
13 KiB
Bash
Executable File
#!/bin/sh
|
|
set -eu
|
|
|
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
|
cd "$ROOT"
|
|
|
|
SHELLCHECK_IMAGE="koalaman/shellcheck-alpine:v0.11.0@sha256:9955be09ea7f0dbf7ae942ac1f2094355bb30d96fffba0ec09f5432207544002"
|
|
HADOLINT_IMAGE="hadolint/hadolint:v2.14.0-debian@sha256:158cd0184dcaa18bd8ec20b61f4c1cabdf8b32a592d062f57bdcb8e4c1d312e2"
|
|
ACTIONLINT_IMAGE="rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667"
|
|
TRIVY_IMAGE="aquasec/trivy:0.72.0@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f"
|
|
PROMETHEUS_IMAGE="quay.io/prometheus/prometheus:v3.13.1@sha256:3c42b892cf723fa54d2f262c37a0e1f80aa8c8ddb1da7b9b0df9455a35a7f893"
|
|
ALERTMANAGER_IMAGE="quay.io/prometheus/alertmanager:v0.33.1@sha256:9e082985f56f4c8c9f724e18f2288c6708f472e56a5286b8863d080434ea065d"
|
|
PYTHON_IMAGE="python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4"
|
|
|
|
run_id="$(date -u +%Y%m%d%H%M%S)-$$"
|
|
project="wnh_quality_$(printf '%s' "$run_id" | tr -d '-')"
|
|
quality_image="who-need-help:quality-$run_id"
|
|
assets_image="who-need-help:assets-audit-$run_id"
|
|
e2e_image="who-need-help:e2e-audit-$run_id"
|
|
release_image="who-need-help:security-$run_id"
|
|
backup_image="who-need-help:backup-audit-$run_id"
|
|
minio_image="who-need-help:minio-audit-$run_id"
|
|
mc_image="who-need-help:mc-audit-$run_id"
|
|
boundary_mock_image="who-need-help:boundary-mock-audit-$run_id"
|
|
scan_dir=$(mktemp -d "${TMPDIR:-/tmp}/wnh-quality-scan.XXXXXX")
|
|
scan_list="${scan_dir}.files"
|
|
scan_tar="${scan_dir}.tar"
|
|
|
|
umask 077
|
|
QUALITY_POSTGRES_USER="wnh_quality_$(openssl rand -hex 6)"
|
|
QUALITY_POSTGRES_PASSWORD=$(openssl rand -base64 48 | tr -d '\n')
|
|
export QUALITY_POSTGRES_USER QUALITY_POSTGRES_PASSWORD
|
|
|
|
compose="docker compose -p $project -f $ROOT/compose.quality.yaml"
|
|
|
|
cleanup() {
|
|
$compose down --volumes --remove-orphans >/dev/null 2>&1 || true
|
|
docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \
|
|
"$backup_image" "$minio_image" "$mc_image" \
|
|
"$boundary_mock_image" \
|
|
>/dev/null 2>&1 || true
|
|
rm -rf "$scan_dir" "$scan_list" "$scan_tar"
|
|
}
|
|
trap cleanup EXIT HUP INT TERM
|
|
|
|
echo "Checking shell scripts with ShellCheck 0.11.0"
|
|
# Word splitting is intentional: find emits repository-controlled paths and
|
|
# ShellCheck expects each file as a separate argument.
|
|
# shellcheck disable=SC2046
|
|
docker run --rm \
|
|
--volume "$ROOT:/mnt:ro" \
|
|
--workdir /mnt \
|
|
--entrypoint shellcheck \
|
|
"$SHELLCHECK_IMAGE" \
|
|
$(find scripts -type f -name '*.sh' -print | sort)
|
|
|
|
echo "Checking Dockerfiles with Hadolint 2.14.0"
|
|
for dockerfile in Dockerfile Dockerfile.backup Dockerfile.minio \
|
|
android/Dockerfile e2e/Dockerfile ops/external-boundaries/Dockerfile; do
|
|
docker run --rm --interactive "$HADOLINT_IMAGE" \
|
|
hadolint --failure-threshold warning - <"$dockerfile"
|
|
done
|
|
|
|
echo "Checking the GitHub Actions workflow with actionlint 1.7.12"
|
|
docker run --rm \
|
|
--volume "$ROOT:/repo:ro" \
|
|
--workdir /repo \
|
|
"$ACTIONLINT_IMAGE"
|
|
|
|
echo "Rendering every Docker Compose profile"
|
|
docker compose --env-file .env.example -f compose.yaml config --quiet
|
|
docker compose --env-file .env.example -f compose.yaml config --format json |
|
|
jq --exit-status '
|
|
[.services.migrate, .services.web, .services.worker]
|
|
| all(.environment.ERL_ZFLAGS == "+Q 65536")
|
|
' >/dev/null
|
|
PORTABILITY_IMAGE=who-need-help:portability-render \
|
|
docker compose --env-file .env.example \
|
|
-f compose.yaml -f compose.portability.yaml config --quiet
|
|
mkdir -p "$scan_dir/e2e-output"
|
|
E2E_OUTPUT_DIR="$scan_dir/e2e-output" docker compose --env-file .env.e2e.example \
|
|
-f compose.yaml -f compose.e2e.yaml config --quiet
|
|
REHEARSAL_IMAGE=who-need-help:rehearsal-render \
|
|
docker compose --env-file .env.e2e.example \
|
|
-f compose.yaml -f compose.upgrade-rehearsal.yaml config --quiet
|
|
docker compose --env-file .env.load.example \
|
|
-f compose.yaml -f compose.load.yaml config --quiet
|
|
test "$(
|
|
docker compose --env-file .env.load.example \
|
|
-f compose.yaml -f compose.load.yaml config --format json |
|
|
jq -r '.services.db.command | join(" ")'
|
|
)" = "postgres -c shared_preload_libraries=pg_stat_statements -c compute_query_id=on"
|
|
mkdir -p "$scan_dir/observability-runtime/prometheus" \
|
|
"$scan_dir/observability-runtime/grafana"
|
|
OBSERVABILITY_RUNTIME_DIR="$scan_dir/observability-runtime" \
|
|
docker compose --env-file .env.load.example \
|
|
-f compose.yaml -f compose.load.yaml -f compose.observability.yaml \
|
|
--profile observability config --quiet
|
|
mkdir -p "$scan_dir/backup-runtime"
|
|
BACKUP_RUNTIME_DIR="$scan_dir/backup-runtime" \
|
|
BACKUP_HOST_UID="$(id -u)" \
|
|
BACKUP_HOST_GID="$(id -g)" \
|
|
docker compose --env-file .env.load.example \
|
|
-f compose.yaml -f compose.load.yaml -f compose.backup.yaml \
|
|
--profile backup config --quiet
|
|
docker compose -p "$project" -f compose.quality.yaml config --quiet
|
|
mkdir -p "$scan_dir/external-boundary-output"
|
|
EXTERNAL_BOUNDARY_APP_IMAGE=who-need-help:boundary-render \
|
|
EXTERNAL_BOUNDARY_MOCK_IMAGE=who-need-help:boundary-mock-render \
|
|
EXTERNAL_BOUNDARY_OUTPUT_DIR="$scan_dir/external-boundary-output" \
|
|
EXTERNAL_BOUNDARY_HOST_UID="$(id -u)" \
|
|
EXTERNAL_BOUNDARY_HOST_GID="$(id -g)" \
|
|
EXTERNAL_OAUTH_CLIENT_ID=render-client \
|
|
EXTERNAL_OAUTH_CLIENT_SECRET=render-secret \
|
|
EXTERNAL_PUSH_BEARER_TOKEN=render-push-token \
|
|
EXTERNAL_POSTGRES_PASSWORD=render-database-secret \
|
|
EXTERNAL_DATABASE_URL=ecto://boundary:render-database-secret@boundary-db/boundary \
|
|
EXTERNAL_SECRET_KEY_BASE=render-secret-key-base \
|
|
EXTERNAL_HANDOVER_SECRET=render-handover-secret \
|
|
docker compose -f compose.external-boundaries.yaml config --quiet
|
|
|
|
echo "Validating local observability configuration"
|
|
sed \
|
|
-e 's/__SCRAPE_INTERVAL__/1s/g' \
|
|
-e 's/__EVALUATION_INTERVAL__/1s/g' \
|
|
ops/observability/prometheus.template.yml \
|
|
>"$scan_dir/observability-runtime/prometheus/prometheus.yml"
|
|
printf '%s' 'isolated-quality-metrics-token' \
|
|
>"$scan_dir/observability-runtime/prometheus/metrics-token"
|
|
printf '%s\n' '[]' \
|
|
>"$scan_dir/observability-runtime/prometheus/web-targets.json"
|
|
docker run --rm \
|
|
--user 0:0 \
|
|
--volume "$scan_dir/observability-runtime/prometheus:/runtime:ro" \
|
|
--volume "$ROOT/ops/observability/rules.yml:/etc/prometheus/rules.yml:ro" \
|
|
--entrypoint /bin/promtool \
|
|
"$PROMETHEUS_IMAGE" check config /runtime/prometheus.yml
|
|
docker run --rm \
|
|
--user 0:0 \
|
|
--volume "$ROOT/ops/observability/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro" \
|
|
--entrypoint /bin/amtool \
|
|
"$ALERTMANAGER_IMAGE" check-config /etc/alertmanager/alertmanager.yml
|
|
docker run --rm \
|
|
--volume "$ROOT/scripts/alert-receiver.py:/src/alert-receiver.py:ro" \
|
|
"$PYTHON_IMAGE" python -c \
|
|
'import py_compile; py_compile.compile("/src/alert-receiver.py", cfile="/tmp/alert-receiver.pyc", doraise=True)'
|
|
docker run --rm \
|
|
--volume "$ROOT/ops/external-boundaries/mock_server.py:/src/mock_server.py:ro" \
|
|
"$PYTHON_IMAGE" python -c \
|
|
'import py_compile; py_compile.compile("/src/mock_server.py", cfile="/tmp/mock_server.pyc", doraise=True)'
|
|
jq --exit-status \
|
|
'type == "object" and .uid == "wnh-overview" and (.panels | length) == 4' \
|
|
ops/observability/grafana/dashboards/who-need-help-overview.json \
|
|
>/dev/null
|
|
|
|
echo "Linting the Helm chart"
|
|
"$ROOT/scripts/bootstrap-kubernetes-tools.sh" >/dev/null
|
|
"$ROOT/.tools/bin/helm" lint \
|
|
--values "$ROOT/deploy/helm/who-need-help/values-kind.yaml" \
|
|
"$ROOT/deploy/helm/who-need-help"
|
|
|
|
echo "Scanning only tracked and non-ignored source files"
|
|
git ls-files --cached --others --exclude-standard -z >"$scan_list"
|
|
tar --null --no-recursion --files-from="$scan_list" --create --file="$scan_tar"
|
|
tar --extract --file="$scan_tar" --directory "$scan_dir"
|
|
"$ROOT/.tools/bin/helm" template who-need-help \
|
|
--values "$ROOT/deploy/helm/who-need-help/values-kind.yaml" \
|
|
"$ROOT/deploy/helm/who-need-help" \
|
|
>"$scan_dir/rendered-helm.yaml"
|
|
test "$(
|
|
grep -c 'name: ERL_ZFLAGS' "$scan_dir/rendered-helm.yaml"
|
|
)" -eq 5
|
|
test "$(
|
|
grep -c 'value: "+Q 65536"' "$scan_dir/rendered-helm.yaml"
|
|
)" -eq 5
|
|
mkdir -p "$ROOT/.tools/trivy-cache"
|
|
docker run --rm \
|
|
--volume "$scan_dir:/scan:ro" \
|
|
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
|
"$TRIVY_IMAGE" fs \
|
|
--scanners misconfig,secret \
|
|
--severity HIGH,CRITICAL \
|
|
--exit-code 1 \
|
|
/scan
|
|
|
|
echo "Building the pinned quality image and cached Dialyzer PLTs"
|
|
docker build --target quality --tag "$quality_image" .
|
|
|
|
echo "Running Elixir format, compiler, xref, Credo, Sobelow, Dialyzer, and Hex audit"
|
|
docker run --rm "$quality_image" sh -euc '
|
|
mix format --check-formatted
|
|
mix compile --force --warnings-as-errors
|
|
mix xref graph --label compile-connected --fail-above 0
|
|
mix credo --strict --min-priority high
|
|
mix sobelow --exit --strict --private --skip
|
|
mix dialyzer
|
|
mix hex.audit
|
|
'
|
|
|
|
echo "Starting an isolated PostgreSQL/PostGIS volume for the Phoenix suite"
|
|
$compose up --detach --wait db
|
|
docker run --rm \
|
|
--network "${project}_internal" \
|
|
--env MIX_ENV=test \
|
|
--env DB_HOST=db \
|
|
--env "DB_USER=$QUALITY_POSTGRES_USER" \
|
|
--env "DB_PASSWORD=$QUALITY_POSTGRES_PASSWORD" \
|
|
--env TEST_POOL_SIZE=10 \
|
|
"$quality_image" \
|
|
mix test
|
|
|
|
echo "Auditing locked browser dependencies"
|
|
docker build --target node_deps --tag "$assets_image" .
|
|
docker run --rm "$assets_image" npm audit --audit-level=high
|
|
docker build --tag "$e2e_image" e2e
|
|
docker run --rm "$e2e_image" npm audit --audit-level=high
|
|
|
|
echo "Building and scanning the pinned non-root backup tool image"
|
|
docker build --tag "$backup_image" --file Dockerfile.backup .
|
|
test "$(docker image inspect --format '{{.Config.User}}' "$backup_image")" = \
|
|
"10001:10001"
|
|
backup_versions=$(docker run --rm \
|
|
--user 10001:10001 \
|
|
--read-only \
|
|
--tmpfs /tmp \
|
|
"$backup_image" \
|
|
sh -euc 'restic version; pg_dump --version; test "$(id -u)" = 10001')
|
|
printf '%s\n' "$backup_versions"
|
|
printf '%s\n' "$backup_versions" |
|
|
grep -F 'restic 0.19.1 compiled with go1.26.5' >/dev/null
|
|
printf '%s\n' "$backup_versions" |
|
|
grep -F 'pg_dump (PostgreSQL) 18.4' >/dev/null
|
|
docker run --rm \
|
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
|
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
|
"$TRIVY_IMAGE" image \
|
|
--scanners vuln \
|
|
--severity HIGH,CRITICAL \
|
|
--ignore-unfixed \
|
|
--exit-code 1 \
|
|
"$backup_image"
|
|
|
|
echo "Building and scanning the pinned non-root MinIO server and client images"
|
|
docker build --target server --tag "$minio_image" --file Dockerfile.minio .
|
|
docker build --target client --tag "$mc_image" --file Dockerfile.minio .
|
|
test "$(docker image inspect --format '{{.Config.User}}' "$minio_image")" = \
|
|
"10001:10001"
|
|
test "$(docker image inspect --format '{{.Config.User}}' "$mc_image")" = \
|
|
"10001:10001"
|
|
minio_version=$(docker run --rm \
|
|
--user 10001:10001 \
|
|
--read-only \
|
|
--tmpfs /tmp \
|
|
"$minio_image" \
|
|
--version)
|
|
mc_version=$(docker run --rm \
|
|
--user 10001:10001 \
|
|
--read-only \
|
|
--tmpfs /tmp \
|
|
"$mc_image" \
|
|
--version)
|
|
printf '%s\n' "$minio_version"
|
|
printf '%s\n' "$mc_version"
|
|
printf '%s\n' "$minio_version" |
|
|
grep -F 'RELEASE.2025-10-15T17-29-55Z' >/dev/null
|
|
printf '%s\n' "$minio_version" |
|
|
grep -F 'commit-id=9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a' >/dev/null
|
|
printf '%s\n' "$minio_version" |
|
|
grep -F 'Runtime: go1.26.5 linux/' >/dev/null
|
|
printf '%s\n' "$mc_version" |
|
|
grep -F 'RELEASE.2025-08-13T08-35-41Z' >/dev/null
|
|
printf '%s\n' "$mc_version" |
|
|
grep -F 'commit-id=7394ce0dd2a80935aded936b09fa12cbb3cb8096' >/dev/null
|
|
printf '%s\n' "$mc_version" |
|
|
grep -F 'Runtime: go1.26.5 linux/' >/dev/null
|
|
for image in "$minio_image" "$mc_image"; do
|
|
docker run --rm \
|
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
|
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
|
"$TRIVY_IMAGE" image \
|
|
--scanners vuln \
|
|
--severity HIGH,CRITICAL \
|
|
--ignore-unfixed \
|
|
--exit-code 1 \
|
|
"$image"
|
|
done
|
|
|
|
echo "Building and scanning the pinned non-root external-boundary mock image"
|
|
docker build \
|
|
--tag "$boundary_mock_image" \
|
|
--file ops/external-boundaries/Dockerfile \
|
|
ops/external-boundaries
|
|
test "$(docker image inspect --format '{{.Config.User}}' "$boundary_mock_image")" = \
|
|
"10001:10001"
|
|
docker run --rm \
|
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
|
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
|
"$TRIVY_IMAGE" image \
|
|
--scanners vuln \
|
|
--severity HIGH,CRITICAL \
|
|
--ignore-unfixed \
|
|
--exit-code 1 \
|
|
"$boundary_mock_image"
|
|
|
|
echo "Building and scanning the production release image"
|
|
docker build --target release --tag "$release_image" .
|
|
docker run --rm \
|
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
|
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
|
"$TRIVY_IMAGE" image \
|
|
--scanners vuln \
|
|
--severity HIGH,CRITICAL \
|
|
--ignore-unfixed \
|
|
--exit-code 1 \
|
|
"$release_image"
|
|
|
|
echo "All isolated quality and security gates passed."
|