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
39 changes: 27 additions & 12 deletions .github/scripts/node_security_check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@
# everything is current. Exits non-zero only when the check itself breaks, so
# stale data never turns the workflow red. See #1567.
#
# Contract with the Dockerfile: each Node stage starts FROM node:${NODE<major>_VERSION}
# or from another Node stage, and each non-base-* stage is published as <channel>-<stage>.
#
# Env: DOCKERFILE (default Dockerfile.unified), DRY_RUN=1 prints the decision
# and issue body without calling gh, GH_TOKEN for gh.
set -euo pipefail
shopt -s inherit_errexit

DOCKERFILE=${DOCKERFILE:-Dockerfile.unified}
# Reserved for this script's tracker. close-unlinked-stale-issues.yml and issue-check.yml
# exempt it by label alone; rename it in all three files (this script and both workflows).
LABEL='node-security'
MARKER='<!-- node-security-check -->'
REPO=openabdev/openab
CHANNELS=(stable beta)
MANIFEST_ACCEPT=application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json
Expand All @@ -26,11 +32,14 @@ version_lt() { [[ $1 != "$2" && $(printf '%s\n%s\n' "$1" "$2" | sort -V | head -

status_of() {
if [[ -z $1 ]]; then echo "not published"
elif [[ ${1%%.*} != "$2" ]]; then echo "major mismatch"
elif [[ -n ${LATEST[$2]} ]] && version_lt "$1" "${LATEST[$2]}"; then echo stale
else echo current
fi
}

is_behind() { [[ $1 == stale || $1 == "major mismatch" ]]; }

parse_dockerfile() {
[[ -r $DOCKERFILE ]] || die "cannot read $DOCKERFILE"
local line stage m
Expand Down Expand Up @@ -63,7 +72,7 @@ parse_dockerfile() {

load_latest_security() {
local index m
index=$(curl -fsSL --retry 3 https://nodejs.org/dist/index.json) || die "cannot fetch https://nodejs.org/dist/index.json"
index=$(curl -fsSL --retry 3 --connect-timeout 10 --max-time 30 https://nodejs.org/dist/index.json) || die "cannot fetch https://nodejs.org/dist/index.json"
for m in "${MAJORS[@]}"; do
LATEST[$m]=$(jq -r --arg p "v$m." '.[] | select(.security and (.version | startswith($p))) | .version | ltrimstr("v")' <<<"$index" | sort -V | tail -n1)
done
Expand All @@ -72,7 +81,7 @@ load_latest_security() {
# Prints the response body, or nothing when the registry answers 404.
registry_get() {
local code
code=$(curl -sSL --retry 3 -o "$TMP/registry" -w '%{http_code}' \
code=$(curl -sSL --retry 3 --connect-timeout 10 --max-time 30 -o "$TMP/registry" -w '%{http_code}' \
-H "Authorization: Bearer $REGISTRY_TOKEN" -H "Accept: $2" \
"https://ghcr.io/v2/$REPO/$1") || die "GET ghcr.io/v2/$REPO/$1 failed"
case $code in
Expand All @@ -99,17 +108,17 @@ image_node_version() {

assess() {
local m agent channel node status
REGISTRY_TOKEN=$(curl -fsS --retry 3 "https://ghcr.io/token?scope=repository:$REPO:pull" | jq -er .token) ||
REGISTRY_TOKEN=$(curl -fsS --retry 3 --connect-timeout 10 --max-time 30 "https://ghcr.io/token?scope=repository:$REPO:pull" | jq -er .token) ||
die "cannot get an anonymous ghcr.io pull token"
for m in "${MAJORS[@]}"; do
PIN_STATUS[$m]=$(status_of "${PINNED[$m]}" "$m")
if [[ ${PIN_STATUS[$m]} == stale ]]; then STALE_MAJOR[$m]=1; fi
if is_behind "${PIN_STATUS[$m]}"; then STALE_MAJOR[$m]=1; fi
for agent in ${AGENTS[$m]:-}; do
for channel in "${CHANNELS[@]}"; do
node=$(image_node_version "$channel-$agent")
status=$(status_of "$node" "$m")
IMAGE_ROWS+=("| \`$channel-$agent\` | ${node:--} | $status |")
if [[ $status == stale ]]; then STALE_MAJOR[$m]=1; fi
if is_behind "$status"; then STALE_MAJOR[$m]=1; fi
done
done
done
Expand Down Expand Up @@ -140,15 +149,17 @@ render_tables() {

render_issue_body() {
cat <<EOF
Node.js has published a security release that a pinned base image or a published openab image is behind on. The \`node-security-check\` workflow keeps this issue up to date daily and closes it once every pin and published image is current.
$MARKER
A pinned base image or a published openab image is behind the latest Node.js security release for its line, or is on the wrong Node major. The \`node-security-check\` workflow keeps this issue up to date daily and closes it once every pin and published image is current.

$(render_tables)

### Next steps

1. For each stale pin, bump \`ARG NODE<major>_VERSION\` to the latest security release in every Dockerfile that declares it (\`git grep -lE 'ARG NODE[0-9]+_VERSION' -- 'Dockerfile*'\`). The smoke tests check that the values match.
2. Follow the "Base image security release" runbook in [RELEASING.md](https://github.com/$REPO/blob/main/RELEASING.md#base-image-security-release) to cut a beta, verify \`node --version\` in each \`beta-<agent>\` image, and promote to stable.
3. Background and policy: #1567.
1. For each stale pin, bump \`ARG NODE<major>_VERSION\` to the latest security release in every Dockerfile that declares it (\`git grep -lE 'ARG NODE[0-9]+_VERSION' -- 'Dockerfile*'\`). \`$DOCKERFILE\` declares every line and each other Dockerfile declares only its own. The smoke tests read the expected versions from the Dockerfiles, so a bump needs no workflow edit.
2. A \`major mismatch\` row means the pin or image is on a different Node major than its line. Fix the \`ARG NODE<major>_VERSION\` value, or rebuild the image from the correct line, instead of bumping the patch version.
3. Follow the "Base image security release" runbook in [RELEASING.md](https://github.com/$REPO/blob/main/RELEASING.md#base-image-security-release) to cut a beta, verify \`node --version\` in each \`beta-<agent>\` image, and promote to stable.
4. Background and policy: #1567.

Last checked: $(date -u '+%Y-%m-%d %H:%M UTC')
EOF
Expand All @@ -157,14 +168,15 @@ EOF
issue_title() {
local m versions=()
for m in "${MAJORS[@]}"; do
if [[ -n ${STALE_MAJOR[$m]:-} ]]; then versions+=("${LATEST[$m]}"); fi
# A line can be behind on major mismatch alone, with no security release to name.
if [[ -n ${STALE_MAJOR[$m]:-} ]]; then versions+=("${LATEST[$m]:-Node $m major mismatch}"); fi
done
local joined
joined=$(printf '%s / ' "${versions[@]}")
echo "Node.js security release pending: ${joined% / }"
}

# (anything stale, labeled issue open) -> action.
# (anything stale, marked issue open) -> action.
decide() {
case "$1$2" in
10) echo create ;;
Expand All @@ -183,7 +195,10 @@ ensure_label() {
# Args: stale (0|1), title, body file.
reconcile() {
local issue action has_issue=0
issue=$(gh issue list --label "$LABEL" --state open --limit 1 --json number --jq '.[0].number // empty')
# Only issues carrying MARKER are ours; a hand-labeled issue is left alone.
# The label is reserved for this script, so at most one open issue is expected.
issue=$(gh issue list --label "$LABEL" --state open --limit 100 --json number,body \
--jq "[.[] | select((.body // \"\") | contains(\"$MARKER\"))][0].number // empty")
if [[ -n $issue ]]; then has_issue=1; fi
action=$(decide "$1" "$has_issue")
echo "decision: $action${issue:+ (#$issue)}"
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/issue-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ jobs:
const issue = context.payload.issue;
const body = issue.body || '';
const labels = issue.labels.map(l => l.name);
// node-security issues are generated and kept current by node-security-check.yml.
// Its GITHUB_TOKEN writes do not trigger this workflow; this covers human edits and relabels.
if (labels.includes('node-security')) return;
const marker = '<!-- openab-issue-check -->';

const rules = {
Expand Down
Loading