Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,25 @@ jobs:
with:
persist-credentials: false
- name: shellcheck
run: shellcheck scripts/get_mid_url.sh tests/get_mid_url_test.sh
run: shellcheck scripts/*.sh tests/*.sh
- name: get_mid_url.sh tests
run: tests/get_mid_url_test.sh
- name: check_dockerfile.sh tests
run: tests/check_dockerfile_test.sh

dockerfile:
# BuildKit's build checks on the Dockerfile, without building it: no RUN step executes, so neither a ServiceNow
# instance nor the MID installer download is involved. The checks frontend is pinned by digest in the script, which
# accepts only the three known findings it lists.
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: docker build --call=check
run: scripts/check_dockerfile.sh

zizmor:
# security audit of the workflows and dependabot.yml (config: .github/zizmor.yml). The action runs zizmor's container
Expand Down
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
error instead of a Python traceback; a null property value no longer crashes the parse.
- CLAUDE.md's bump procedure said `--recipe` downloads and unpacks the recipe; it prints the
URL.
- CLAUDE.md and this changelog described `META-INF/` both as gitignored and as committed
vendor content. It is gitignored and untracked; the docs now say so and explain how to
verify the vendor files against the signed recipe zip instead.

### Changed
- `get_mid_url.sh` takes all three `MID_INSTANCE_*` settings from one source: the
`--env-file` if given, else the environment if any of them is set there, else `.env`.
It used to read only the env file. Settings are never mixed across sources, so a URL
from the environment can't be sent a password from a file.
- **MID Server bumped from Xanadu Patch 4a (Jan 2025) to Australia Patch 2 (Apr 2026)**,
four release families forward. Vendor recipe (`Dockerfile.unmodified`, `asset/`,
`META-INF/`) refreshed from ServiceNow's Australia Linux container recipe.
four release families forward. Vendor recipe (`Dockerfile.unmodified`, `asset/`)
refreshed from ServiceNow's Australia Linux container recipe. Its `META-INF/` signature
is gitignored and not part of the repository.
- Runtime base image `almalinux:9.2` → `almalinux:9.8`. 9.2 is an EOL point release, so
every build was replaying years of `dnf update --security` deltas under emulation.
- Builder base image `eclipse-temurin:8-jdk-alpine` → `eclipse-temurin:21-jdk-alpine`,
Expand All @@ -43,6 +47,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`!mid.env` negation.

### Added
- `scripts/check_dockerfile.sh` and a CI job that runs it: BuildKit's build checks
(`docker build --call=check`) on the `Dockerfile`, without building it, so no instance or
installer download is needed. The checks frontend is pinned by digest, and only the three
known findings (the deliberate `linux/amd64` pin and the vendor recipe's
`MID_INSTANCE_PASSWORD`/`MID_PROXY_PASSWORD` ENV names) are accepted, each once. The rule
checks names, not values; the vendor-diff rule keeps those values empty. Tested with a
stubbed `docker` in `tests/check_dockerfile_test.sh`; shellcheck now covers every script.
- `tests/get_mid_url_test.sh` (stubbed `curl`, no instance needed) and a CI workflow that
runs it with shellcheck.
- `scripts/get_mid_url.sh` — derives the MID installer or container-recipe download URL
Expand Down
20 changes: 15 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

`--env-file .env` is required. Without it `asset/init` exits 2 before the MID Server starts.

- Lint the Dockerfile without building it: `scripts/check_dockerfile.sh` (BuildKit build checks;
needs docker and jq, no instance). CI runs it; only the three findings it lists are accepted.
- Script tests: `tests/get_mid_url_test.sh`, `tests/check_dockerfile_test.sh` (stubbed, offline).

## Version Pins

Currently tracking the **Australia** family (Patch 2). All pins live in `Dockerfile`:
Expand All @@ -28,7 +32,9 @@ Line numbers are for the current recipe; re-derive them after a recipe refresh.
1. `scripts/get_mid_url.sh --buildstamp` — read the instance's current MID buildstamp.
2. `curl -fLO "$(scripts/get_mid_url.sh --recipe)"`, then unzip it. `--recipe` only prints
that release's vendor recipe URL; it does not download anything.
3. Replace `Dockerfile.unmodified`, `asset/*` and `META-INF/*` from the unpacked recipe.
3. Replace `Dockerfile.unmodified` (the recipe's `Dockerfile`) and `asset/*` from the
unpacked recipe. Its `META-INF/` holds the signature; it is gitignored, so refresh any
local copy but never commit it.
4. Re-derive `Dockerfile` from the new `Dockerfile.unmodified` by re-applying the two
`LOCAL MOD` `FROM` lines. Take the vendor's installer URL unless ours is newer.
5. `diff -u Dockerfile.unmodified Dockerfile` — must show **exactly two hunks**, both `FROM`
Expand All @@ -53,9 +59,12 @@ docker run --rm --platform=linux/amd64 -e MID_INSTANCE_URL=https://example.inval
## Gotchas

- **`Dockerfile.unmodified`, `asset/*`, `mid.env`, `mid-secrets.properties` and the EULA are
ServiceNow-signed vendor content** (covered by `META-INF/MANIFEST.MF`). Never hand-edit
them — replace them wholesale from a fresh recipe zip. Only `Dockerfile` carries local
changes, and only on its two `FROM` lines.
ServiceNow-signed vendor content.** Never hand-edit them — replace them wholesale from a
fresh recipe zip. Only `Dockerfile` carries local changes, and only on its two `FROM` lines.
The signature (`META-INF/MANIFEST.MF` and its `.SF`/`.RSA`) ships in the recipe zip and is
**not tracked here** (`META-INF/` is gitignored), so a clone cannot verify these files on
its own. To check them, run `jarsigner -verify -strict` on the downloaded recipe zip, unzip
it, and diff its files against the tracked copies (its `Dockerfile` is `Dockerfile.unmodified`).
- **Do not add files to `asset/`.** `Dockerfile` does `COPY asset/*`, so anything there is
baked into the build context and the image. Local tooling goes in `scripts/`.
- **The runtime image is x86-64 only** ([KB2675992](https://support.servicenow.com/kb?id=kb_article_view&sysparm_article=KB2675992))
Expand Down Expand Up @@ -103,8 +112,9 @@ docker run --rm --platform=linux/amd64 -e MID_INSTANCE_URL=https://example.inval
- `Dockerfile.unmodified`: vendor recipe, byte-identical to what ServiceNow signs
- `asset/`: vendor scripts; `asset/init` is the entrypoint and container initialization
- `scripts/get_mid_url.sh`: derives installer/recipe URLs from a live instance
- `scripts/check_dockerfile.sh`: BuildKit build checks on `Dockerfile`, run in CI
- `docs/servicenow-australia/`: harvested official docs for the pinned release
- `META-INF/`: vendor jar signature (gitignored — a download artifact)
- `META-INF/`: the recipe zip's signature (gitignored, not in the repo — a download artifact)
- `.env` (gitignored) vs `.env.template` (committed) vs `mid.env` (vendor sample)

Please maintain existing patterns when modifying code.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ so keep them unquoted and without inline comments. The script never takes the pa
an argument, because argv shows up in `ps` and in shell history. Run its tests with
`tests/get_mid_url_test.sh`; they stub `curl`, so no instance is needed.

`scripts/check_dockerfile.sh` runs BuildKit's build checks on the `Dockerfile` without
building it, so it needs Docker and `jq` but no instance and no installer download. CI runs
it on every push and pull request. A full image build stays manual: it downloads the MID
installer from ServiceNow.

Keep the MID Server within one or two families of your instance. A MID Server several
releases behind may fail to auto-upgrade and validate.

Expand Down
75 changes: 75 additions & 0 deletions scripts/check_dockerfile.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
#
# Lint a Dockerfile with BuildKit's build checks (`docker build --call=check`) without
# building it. No RUN step executes, so this needs neither a ServiceNow instance nor the MID
# installer download; only base-image metadata is resolved from the registry.
#
# The recipe cannot be fully built in CI: the final stage copies the MID installer that the
# first stage downloads from install.service-now.com and signature-verifies.
#
# Findings listed in ACCEPTED below are known and deliberate, and each is accepted once:
# any other finding, a second instance of an accepted one, or a Dockerfile that does not
# parse fails the check.
#
# scripts/check_dockerfile.sh [DOCKERFILE] (default: Dockerfile)
#
# Requires docker with buildx 0.15 or newer, and jq.

set -euo pipefail

# The Dockerfile frontend that runs the checks, pinned by digest so the rule set changes
# only when this line does (docker/dockerfile 1.27.1, published 2026-09-30).
readonly FRONTEND="docker/dockerfile:1.27.1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e"

# "<rule>: <detail>", exactly as the frontend reports them.
readonly ACCEPTED=(
# LOCAL MOD: the runtime stage is pinned to x86-64 on purpose (ServiceNow KB2675992).
'FromPlatformFlagConstDisallowed: FROM --platform flag should not use constant value "linux/amd64"'
# Placeholders in the vendor-signed recipe's ENV block, which asset/init reads. The rule
# checks variable names, not values: that they stay empty is enforced by Dockerfile changing
# only on its two FROM lines (compare Dockerfile.unmodified), so these cannot be edited here.
'SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "MID_INSTANCE_PASSWORD")'
'SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "MID_PROXY_PASSWORD")'
)

errorExit() {
echo "ERROR: $1" 1>&2
exit 1
}

ROOT=$(cd "$(dirname "$0")/.." && pwd)
readonly ROOT
dockerfile=${1:-$ROOT/Dockerfile}
[ -f "$dockerfile" ] || errorExit "Dockerfile not found: $dockerfile"
command -v docker >/dev/null || errorExit "docker is required"
command -v jq >/dev/null || errorExit "jq is required"

result=$(mktemp)
trap 'rm -f "$result"' EXIT

# Exit status 1 only means "findings"; the JSON result says what happened.
status=0
docker build --quiet --call=check,format=json --build-arg "BUILDKIT_SYNTAX=$FRONTEND" \
-f "$dockerfile" "$ROOT" >"$result" || status=$?

jq -e 'type == "object"' "$result" >/dev/null 2>&1 \
|| errorExit "docker build --call=check gave no check result (exit $status): it needs buildx 0.15 or newer, or the frontend could not be resolved (see docker's output above)"

build_error=$(jq -r '.buildError.message // empty' "$result")
[ -z "$build_error" ] || errorExit "could not check $dockerfile: $build_error"

findings=$(jq -r '(.warnings // [])[] | "\(.ruleName): \(.detail)"' "$result")
[ "$status" -eq 0 ] || [ -n "$findings" ] \
|| errorExit "docker build --call=check failed (exit $status) without reporting findings"

# Multiset difference: an accepted finding that appears twice leaves one unexpected copy.
unexpected=$(comm -23 <(sort <<<"$findings") <(printf '%s\n' "${ACCEPTED[@]}" | sort))
if [ -n "$unexpected" ]; then
echo "Unexpected Dockerfile check findings in $dockerfile:" 1>&2
while IFS= read -r line; do printf ' %s\n' "$line"; done <<<"$unexpected" 1>&2
exit 1
fi

accepted_seen=0
[ -z "$findings" ] || accepted_seen=$(wc -l <<<"$findings" | tr -d ' ')
echo "Dockerfile checks passed: $accepted_seen accepted finding(s), none unexpected."
103 changes: 103 additions & 0 deletions tests/check_dockerfile_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
#
# Tests for scripts/check_dockerfile.sh. A stub docker on PATH records its arguments and
# answers with a canned check result and exit status, so no Docker daemon is needed.
#
# tests/check_dockerfile_test.sh

set -uo pipefail

ROOT=$(cd "$(dirname "$0")/.." && pwd)
readonly ROOT
readonly SCRIPT="$ROOT/scripts/check_dockerfile.sh"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT

mkdir "$WORK/bin"
cat >"$WORK/bin/docker" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "$@" >"$STUB_ARGS"
cat "$STUB_RESPONSE"
exit "$(cat "$STUB_STATUS")"
STUB
chmod +x "$WORK/bin/docker"

export PATH="$WORK/bin:$PATH"
export STUB_RESPONSE="$WORK/response" STUB_STATUS="$WORK/status" STUB_ARGS="$WORK/args"

readonly PLATFORM_FINDING='{"ruleName":"FromPlatformFlagConstDisallowed","detail":"FROM --platform flag should not use constant value \"linux/amd64\""}'
readonly PASSWORD_FINDING='{"ruleName":"SecretsUsedInArgOrEnv","detail":"Do not use ARG or ENV instructions for sensitive data (ENV \"MID_INSTANCE_PASSWORD\")"}'
readonly PROXY_FINDING='{"ruleName":"SecretsUsedInArgOrEnv","detail":"Do not use ARG or ENV instructions for sensitive data (ENV \"MID_PROXY_PASSWORD\")"}'

failures=0
out="" status=0

respond() {
printf '%s' "$1" >"$STUB_RESPONSE"
printf '%s' "$2" >"$STUB_STATUS"
}

run() {
out=$("$SCRIPT" "$@" 2>&1)
status=$?
}

check() {
local name=$1; shift
if "$@"; then
echo "ok $name"
else
echo "FAIL $name"
echo " status=$status output: $out"
failures=$((failures + 1))
fi
}

succeeded() { [ "$status" -eq 0 ]; }
failedWith() { [ "$status" -ne 0 ] && [[ "$out" == *"$1"* ]]; }
passedArg() { grep -qxF -- "$1" "$STUB_ARGS"; }

respond "{\"warnings\":[$PLATFORM_FINDING,$PASSWORD_FINDING,$PROXY_FINDING]}" 1
run
check "the known findings are accepted" succeeded
check "checks run without building" passedArg "--call=check,format=json"
check "the frontend is pinned by digest" grep -qE '^BUILDKIT_SYNTAX=docker/dockerfile:[0-9.]+@sha256:[0-9a-f]{64}$' "$STUB_ARGS"
check "the repo Dockerfile is checked by default" passedArg "$ROOT/Dockerfile"

respond '{"warnings":null}' 0
run
check "no findings" succeeded

respond "{\"warnings\":[$PLATFORM_FINDING,{\"ruleName\":\"SecretsUsedInArgOrEnv\",\"detail\":\"Do not use ARG or ENV instructions for sensitive data (ARG \\\"MID_TOKEN\\\")\"}]}" 1
run
check "a new finding fails" failedWith 'Unexpected Dockerfile check findings'
check "the new finding is named" failedWith 'MID_TOKEN'

respond "{\"warnings\":[$PLATFORM_FINDING,$PLATFORM_FINDING,$PASSWORD_FINDING,$PROXY_FINDING]}" 1
run
check "a second instance of an accepted finding fails" failedWith 'Unexpected Dockerfile check findings'

respond "{\"warnings\":[$PLATFORM_FINDING,$PASSWORD_FINDING,$PASSWORD_FINDING,$PROXY_FINDING]}" 1
run
check "a repeated password finding fails" failedWith 'MID_INSTANCE_PASSWORD'

respond '{"warnings":null,"buildError":{"message":"dockerfile parse error on line 1"}}' 1
run
check "a Dockerfile that cannot be checked fails" failedWith "dockerfile parse error on line 1"

respond 'unknown flag: --call' 1
run
check "a docker without --call fails" failedWith "gave no check result"

respond '{"warnings":null}' 1
run
check "a failed check with no findings fails" failedWith "without reporting findings"

run "$WORK/no-such-Dockerfile"
check "a missing Dockerfile fails" failedWith "Dockerfile not found"

if [ "$failures" -ne 0 ]; then
echo "$failures test(s) failed"
exit 1
fi
echo "all tests passed"
Loading