#!/usr/bin/env bash
# check-quay-cve-2026-123710.sh: strictly read-only, offline review for CVE-2026-123710 in Red Hat Quay (service key server
# signing-key takeover). Red Hat rates it Critical, CVSS 3.1 9.8 (Red Hat calls the score preliminary). Red Hat lists Quay 3
# (quay-rhel8, quay-rhel9) and Quay 5 (quay-rhel9) as affected with no fix available yet, so there is NO version verdict.
# It reads files an admin exports and hands over. No network, no file writes, nothing is run.
#
# Usage (one or more of --keys, --logs, --proxy-config):
#   --keys FILE          service keys JSON: GET /api/v1/superuser/keys output ({"keys": [...]}), or a JSON array of servicekey
#                        rows from a database export (approval_id instead of approval is accepted)
#   --logs FILE          audit log JSON: GET /api/v1/superuser/logs output or a log export ({"logs": [...]}), or a JSON array
#   --proxy-config FILE  an nginx or HAProxy config; looks for a /keys restriction (OUR HEURISTIC, single file, no includes)
#   --services LIST      comma separated service names expected to own keys (default: quay, Quay's INSTANCE_SERVICE_KEY_SERVICE)
#   --name NAME          label for the output (default: input)
# Verdicts: SUSPICIOUS (needs review), NO FINDINGS (nothing matched in what was supplied), UNKNOWN (could not judge),
#   with NOTE lines and EXPOSURE lines (RESTRICTED, NOT RESTRICTED or UNCLEAR, all OUR HEURISTIC).
# Exit: 0 NO FINDINGS, 1 SUSPICIOUS, 3 UNKNOWN, 4 usage or read error, 5 no SUSPICIOUS but the proxy config shows no
#   effective /keys restriction (NOT RESTRICTED or UNCLEAR).
set -uf
export LC_ALL=C
TEST_URI=/keys/services/quay/keys/testkid

usage() {
  cat >&2 <<'U'
Usage: check-quay-cve-2026-123710.sh [--keys FILE] [--logs FILE] [--proxy-config FILE] [--services LIST] [--name NAME]
  --keys          service keys JSON (GET /api/v1/superuser/keys, or a JSON array of servicekey rows)
  --logs          audit log JSON (GET /api/v1/superuser/logs, a log export, or a JSON array of log entries)
  --proxy-config  nginx or HAProxy config to check for a /keys restriction (heuristic)
  --services      comma separated services expected to own keys (default: quay)
  --name          label for the output (default: input)
At least one of --keys, --logs, --proxy-config is needed.
Exit: 0 NO FINDINGS, 1 SUSPICIOUS, 3 UNKNOWN, 4 usage or read error, 5 no /keys restriction found in the proxy config
U
  exit 4
}
die() { echo "ERROR: $1" >&2; exit 4; }
say() { printf '%s: %s: %s\n' "$1" "$2" "$3"; }
trim() { local s=$1; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }

# JSON flattener (POSIX awk). Prints path|type|value for every value, depth first, in file order. Types: s string, n number,
# b boolean, z null, o object, a array. Strings are decoded and then reduced to a safe character set (anything else becomes ?,
# a UTF-8 sequence or a surrogate pair counts as one character) and cut to 100 characters. Exits 2 on invalid JSON.
flatten() {
  awk '
  function fail() { bad = 1; exit 2 }
  function ws() { while (p <= n && index(" \t\r\n", substr(s, p, 1)) > 0) p++ }
  function clean(v,   o, i, c) {
    o = ""
    for (i = 1; i <= length(v); i++) {
      c = substr(v, i, 1)
      if (c ~ /[A-Za-z0-9 ._:@\/+=,()?%~-]/) o = o c
      else if (c >= "\200" && c <= "\277") continue
      else o = o "?"
    }
    return substr(o, 1, 100)
  }
  function hexv(h,   i, r) { r = 0; h = tolower(h); for (i = 1; i <= 4; i++) r = r * 16 + index("0123456789abcdef", substr(h, i, 1)) - 1; return r }
  function str(   o, c, e, h, cp, h2) {
    o = ""; p++
    while (1) {
      if (n < p) fail()
      c = substr(s, p, 1)
      if (c == "\"") { p++; SV = o; return }
      if (c < " " && c >= "\000") fail()
      if (c == "\\") {
        e = substr(s, p + 1, 1)
        if (e == "\"" || e == "\\" || e == "/") { o = o e; p += 2; continue }
        if (index("bfnrt", e) > 0 && e != "") { o = o "\t"; p += 2; continue }
        if (e == "u") {
          h = substr(s, p + 2, 4)
          if (h !~ /^[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]$/) fail()
          cp = hexv(h); p += 6
          if (cp >= 55296 && cp <= 56319 && substr(s, p, 2) == "\\u") {
            h2 = substr(s, p + 2, 4)
            if (h2 ~ /^[Dd][C-Fc-f][0-9A-Fa-f][0-9A-Fa-f]$/) p += 6
          }
          if (cp >= 32 && cp < 127) o = o sprintf("%c", cp); else o = o "\t"
          continue
        }
        fail()
      }
      o = o c; p++
    }
  }
  function emit(path, t, v) { print path "|" t "|" v }
  function val(path, d,   c, k, i, t) {
    if (d > 64) fail()
    ws(); if (n < p) fail()
    c = substr(s, p, 1)
    if (c == "{") {
      emit(path, "o", ""); p++; ws()
      if (substr(s, p, 1) == "}") { p++; return }
      while (1) {
        ws(); if (substr(s, p, 1) != "\"") fail()
        str(); k = clean(SV); ws()
        if (substr(s, p, 1) != ":") fail()
        p++; val(path "." k, d + 1); ws()
        c = substr(s, p, 1)
        if (c == ",") { p++; continue }
        if (c == "}") { p++; return }
        fail()
      }
    }
    if (c == "[") {
      emit(path, "a", ""); p++; ws(); i = 0
      if (substr(s, p, 1) == "]") { p++; return }
      while (1) {
        val(path "[" i "]", d + 1); i++; ws()
        c = substr(s, p, 1)
        if (c == ",") { p++; continue }
        if (c == "]") { p++; return }
        fail()
      }
    }
    if (c == "\"") { str(); emit(path, "s", clean(SV)); return }
    if (substr(s, p, 4) == "true") { p += 4; emit(path, "b", "true"); return }
    if (substr(s, p, 5) == "false") { p += 5; emit(path, "b", "false"); return }
    if (substr(s, p, 4) == "null") { p += 4; emit(path, "z", ""); return }
    t = substr(s, p, 64)
    if (match(t, /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/)) { emit(path, "n", substr(t, 1, RLENGTH)); p += RLENGTH; return }
    fail()
  }
  { s = s $0 "\n" }
  END {
    if (bad) exit 2
    n = length(s); p = 1
    if (substr(s, 1, 3) == "\357\273\277") p = 4
    ws(); if (n < p) exit 2
    val("$", 0); ws()
    if (p <= n) exit 2
  }' "$1"
}

# pick FLAT ARRAYNAME FIELD...: one line per item of the root array (or of root.ARRAYNAME):
# index|type1|value1|type2|value2... (type - when the field is absent). Prints NOROOT when there is no such array.
pick() {
  local flat=$1 arr=$2; shift 2
  printf '%s\n' "$flat" | awk -F'|' -v arr="$arr" -v fields="$*" '
  NR == 1 { if ($1 == "$" && $2 == "a") pre = "$["; else if ($1 == "$" && $2 == "o") pre = "?"; else { print "NOROOT"; exit } next }
  pre == "?" { if ($1 == "$." arr && $2 == "a") pre = "$." arr "["; else next }
  pre != "?" && pre != "" && index($1, pre) == 1 {
    rest = substr($1, length(pre) + 1); j = index(rest, "]"); if (j == 0) next
    i = substr(rest, 1, j - 1); sub_ = substr(rest, j + 1)
    if (i !~ /^[0-9]+$/) next
    if (sub_ == "") { if (cnt < i + 1) cnt = i + 1; next }
    T[i, sub_] = $2; V[i, sub_] = $3
  }
  END {
    if (pre == "?" || pre == "") { print "NOROOT"; exit }
    nf = split(fields, F, " ")
    for (i = 0; i < cnt; i++) {
      line = i
      for (k = 1; k <= nf; k++) { if ((i, F[k]) in T) line = line "|" T[i, F[k]] "|" V[i, F[k]]; else line = line "|-|" }
      print line
    }
  }'
}

read_json() { # file label: prints the flattened JSON or dies
  if [ ! -f "$1" ] || [ ! -r "$1" ]; then die "cannot read $2 file: $1"; fi
  local flat
  flat=$(flatten "$1") || die "$2 file is not valid JSON: $1"
  [ -n "$flat" ] || die "$2 file is empty: $1"
  printf '%s\n' "$flat"
}

in_services() { local x; for x in "${SVCS[@]}"; do [ "$x" = "$1" ] && return 0; done; return 1; }

do_keys() { # file name
  local f=$1 n=$2 flat recs i kt kv st sv at ait aiv et ev ct cv it iv appr exp reasons who tot=0 na=0 nu=0 ne=0 bad=0 unk=0
  flat=$(read_json "$f" keys) || exit 4
  recs=$(pick "$flat" keys .kid .service .approval .approval_id .expiration_date .metadata.created_by .metadata.ip)
  [ "$recs" = NOROOT ] && die "keys file has no keys array (want {\"keys\": [...]} or a JSON array): $f"
  while IFS='|' read -r i kt kv st sv at _ ait aiv et ev ct cv it iv; do
    [ -n "$i" ] || continue
    tot=$((tot+1))
    [ "$kt" = s ] || kv="(no kid)"
    [ "$st" = s ] || sv=""
    # approval: an object (superuser API) or approval_id (database row); null means not approved; anything else is not judged.
    if [ "$at" = z ]; then appr=no; elif [ "$at" = o ]; then appr=yes; elif [ "$at" != - ]; then appr=unknown
    elif [ "$ait" = z ]; then appr=no; elif [ "$ait" = n ] || { [ "$ait" = s ] && [ -n "$aiv" ]; }; then appr=yes; else appr=unknown; fi
    if [ "$et" = z ] || { [ "$et" = s ] && [ -z "$ev" ]; }; then exp=none; elif [ "$et" != - ]; then exp="set"; else exp=unknown; fi
    who="$n (key kid=$kv service=${sv:-?})"
    if [ "$appr" = unknown ] || [ "$exp" = unknown ]; then
      unk=$((unk+1)); say UNKNOWN "$who" "cannot tell whether this key is approved or when it expires (approval, approval_id or expiration_date is missing or of an unexpected type)."
      continue
    fi
    if [ "$appr" = yes ]; then na=$((na+1)); else nu=$((nu+1)); fi
    reasons=""
    if [ "$appr" = yes ] && [ "$exp" = none ]; then ne=$((ne+1)); reasons="approved with no expiration"; fi
    if [ "$appr" = yes ] && { [ -z "$sv" ] || ! in_services "$sv"; }; then reasons="${reasons:+$reasons; }approved for a service not in the expected list ($SVCLIST)"; fi
    if [ -n "$reasons" ]; then
      bad=$((bad+1))
      [ "$ct" = s ] && reasons="$reasons; metadata created_by \"$cv\""
      [ "$it" = s ] && reasons="$reasons; metadata ip $iv"
      say SUSPICIOUS "$who" "$reasons. Red Hat's mitigation says to remove unrecognised approved service keys. Quay's own instance keys carry an expiration; a superuser can also create a key with no expiration, so confirm with its owner before you act."
    fi
  done <<< "$recs"
  say NOTE "$n (keys file)" "$tot keys read: $na approved, $nu not approved, $ne approved with no expiration, $unk not judged."
  if [ "$tot" -eq 0 ]; then say UNKNOWN "$n (keys file)" "no keys in the export. A running Quay has at least its own instance key, so check the export."
  elif [ "$bad" -eq 0 ] && [ "$unk" -eq 0 ]; then say "NO FINDINGS" "$n (keys file)" "no approved key without an expiration and no approved key for a service outside $SVCLIST."; fi
}

do_logs() { # file name
  local f=$1 n=$2 flat recs i kdt kd dtt dt ipt ip kt kv sgt sg svt sv et ev mit mi pft pf tot=0 nsk=0 bad=0 exp src
  local c_push=0 c_ct=0 c_mt=0 c_dt=0 c_rt=0 c_ce=0
  flat=$(read_json "$f" "audit log") || exit 4
  recs=$(pick "$flat" logs .kind .datetime .ip .metadata.kid .metadata.signer_kid .metadata.service .metadata.expiration_date .metadata.ip .performer.name)
  [ "$recs" = NOROOT ] && die "audit log file has no logs array (want {\"logs\": [...]} or a JSON array): $f"
  while IFS='|' read -r i kdt kd dtt dt ipt ip kt kv sgt sg svt sv et ev mit mi pft pf; do
    [ -n "$i" ] || continue
    [ "$kdt" = s ] || continue
    tot=$((tot+1))
    case $kd in
      push_repo) c_push=$((c_push+1)); continue;; create_tag) c_ct=$((c_ct+1)); continue;; move_tag) c_mt=$((c_mt+1)); continue;;
      delete_tag) c_dt=$((c_dt+1)); continue;; revert_tag) c_rt=$((c_rt+1)); continue;; change_tag_expiration) c_ce=$((c_ce+1)); continue;;
      service_key_create|service_key_approve|service_key_delete|service_key_modify|service_key_extend|service_key_rotate) ;;
      *) continue;;
    esac
    nsk=$((nsk+1))
    [ "$dtt" = s ] || dt="?"; [ "$kt" = s ] || kv="?"; [ "$svt" = s ] || sv="?"; [ "$sgt" = s ] || sg=""; [ "$pft" = s ] || pf=""
    if [ "$ipt" = s ] && [ -n "$ip" ]; then src=$ip; elif [ "$mit" = s ]; then src=$mi; else src="?"; fi
    if [ "$et" = z ] || [ "$et" = - ] || { [ "$et" = s ] && [ -z "$ev" ]; }; then exp=none; else exp=$ev; fi
    local who="$n ($kd at $dt from $src)"
    case $kd in
      service_key_rotate)
        if [ "$exp" = none ]; then bad=$((bad+1))
          say SUSPICIOUS "$who" "key kid=$kv for service $sv was installed by rotation (signer ${sg:-?}) with no expiration. Red Hat's mitigation names service_key_rotate events with a null expiration_date."
        else say NOTE "$who" "key kid=$kv for service $sv rotated (signer ${sg:-?}), expiration $exp. Review it if nothing in your estate rotates keys through /keys."; fi;;
      service_key_delete)
        if [ "$sgt" = s ] && [ "$sg" != "$kv" ]; then bad=$((bad+1))
          say SUSPICIOUS "$who" "key kid=$kv for service $sv was deleted through the key server by a different key (signer $sg). Red Hat's mitigation names service_key_delete events; Quay logs key server deletes with no expiration_date field."
        else say NOTE "$who" "key kid=$kv for service $sv deleted${pf:+ by user $pf}${sg:+ (self-signed)}."; fi;;
      *) if [ "$et" = - ]; then say NOTE "$who" "key kid=$kv for service $sv${pf:+, user $pf}."
         else say NOTE "$who" "key kid=$kv for service $sv${pf:+, user $pf}, expiration $exp."; fi;;
    esac
  done <<< "$recs"
  local ntag=$((c_push+c_ct+c_mt+c_dt+c_rt+c_ce))
  say NOTE "$n (audit log)" "$tot log entries read: $nsk service key events, $ntag tag or push events (push_repo $c_push, create_tag $c_ct, move_tag $c_mt, delete_tag $c_dt, revert_tag $c_rt, change_tag_expiration $c_ce)."
  if [ "$tot" -eq 0 ]; then say UNKNOWN "$n (audit log)" "no log entries in the export. Service key events have no namespace, so export the superuser logs (GET /api/v1/superuser/logs) for the right time range."
  elif [ "$bad" -eq 0 ]; then say "NO FINDINGS" "$n (audit log)" "no service_key_rotate with no expiration and no service_key_delete signed by a different key in this export."; fi
}

# Proxy config check (OUR HEURISTIC): one file, comments stripped, includes and server blocks not followed or separated.
EXPO="not checked"
loc_match() { # mod pattern: does nginx location (mod pattern) match TEST_URI?
  local m=$1 pt=$2 r=1
  case $m in
    =) [ "$pt" = "$TEST_URI" ] && r=0;;
    ''|'^~') [ "${TEST_URI#"$pt"}" != "$TEST_URI" ] && r=0;;
    '~') [[ $TEST_URI =~ $pt ]] && r=0;;
    '~*') shopt -s nocasematch; [[ $TEST_URI =~ $pt ]] && r=0; shopt -u nocasematch;;
  esac
  return $r
}
do_proxy() { # file name
  local f=$1 n=$2 raw line lno=0 kind=unknown
  if [ ! -f "$f" ] || [ ! -r "$f" ]; then die "cannot read proxy config file: $f"; fi
  raw=$(tr -d '\r' < "$f") || die "cannot read proxy config file: $f"
  local -a L=()
  while IFS= read -r line; do line=${line%%#*}; line=$(trim "$line"); line=$(printf '%s' "$line" | tr -s ' \t' '  '); L+=("$line"); done <<< "$raw"
  for line in "${L[@]}"; do
    if [[ $line =~ ^(frontend|backend|listen|defaults|global)( |$) ]]; then kind=haproxy; break; fi
  done
  if [ "$kind" = unknown ]; then
    for line in "${L[@]}"; do if [[ $line =~ ^(server|location|http|upstream)( .*)?\{ ]]; then kind=nginx; break; fi; done
  fi
  local who="$n (proxy config $f)"
  if [ "$kind" = unknown ]; then EXPO=UNCLEAR; say EXPOSURE "UNCLEAR: $who" "not recognised as an nginx or HAProxy config (OUR HEURISTIC)."; return; fi
  if [ "$kind" = nginx ]; then
    local depth=0 inloc=0 locdepth=0 cur=-1 c o x m pt
    local -a LM=() LP=() LN=() LR=()
    for x in "${!L[@]}"; do
      line=${L[$x]}; lno=$((x+1))
      if [[ $line =~ ^location\ +(=|\^~|~\*|~)?\ *([^\ {]+)\ *\{ ]]; then
        m=${BASH_REMATCH[1]}; pt=${BASH_REMATCH[2]}; pt=${pt#\"}; pt=${pt%\"}
        if [ "$inloc" -eq 0 ] && loc_match "$m" "$pt"; then cur=${#LM[@]}; LM+=("$m"); LP+=("$pt"); LN+=("$lno"); LR+=(""); inloc=1; locdepth=$depth; fi
      fi
      if [ "$inloc" -eq 1 ] && [ "$cur" -ge 0 ]; then
        if [[ $line =~ (^|\ |\{)deny\ all\; ]]; then LR[cur]="${LR[cur]:-deny all}"; fi
        if [[ $line =~ (^|\ |\{)return\ 4[0-9][0-9] ]]; then LR[cur]="${LR[cur]:-return 4xx}"; fi
        if [[ $line =~ (^|\ |\{)internal\; ]]; then LR[cur]="${LR[cur]:-internal}"; fi
      fi
      o=${line//[^\{]/}; c=${line//[^\}]/}; depth=$((depth + ${#o} - ${#c}))
      if [ "$inloc" -eq 1 ] && [ "$depth" -le "$locdepth" ]; then inloc=0; fi
    done
    if [ "${#LM[@]}" -eq 0 ]; then EXPO=UNCLEAR; say EXPOSURE "UNCLEAR: $who" "no location block matches $TEST_URI, so where nginx sends /keys depends on the rest of the config (OUR HEURISTIC)."; return; fi
    local best=-1 bl=-1 rx=-1 eff
    for x in "${!LM[@]}"; do
      case ${LM[$x]} in
        ''|'^~') if [ "${#LP[$x]}" -gt "$bl" ]; then bl=${#LP[$x]}; best=$x; fi;;
        '~'|'~*') [ "$rx" -lt 0 ] && rx=$x;;
        =) best=$x; bl=99999;;
      esac
    done
    if [ "$best" -ge 0 ] && { [ "${LM[$best]}" = '^~' ] || [ "${LM[$best]}" = = ]; }; then eff=$best
    elif [ "$rx" -ge 0 ]; then eff=$rx; else eff=$best; fi
    local d="location ${LM[$eff]:+${LM[$eff]} }${LP[$eff]} at line ${LN[$eff]}"
    if [ -n "${LR[$eff]}" ]; then EXPO=RESTRICTED; say EXPOSURE "RESTRICTED: $who" "nginx would pick $d for $TEST_URI, and it has ${LR[$eff]} (OUR HEURISTIC; check allow lines and test from an untrusted network)."; return; fi
    for x in "${!LM[@]}"; do
      if [ -n "${LR[$x]}" ]; then EXPO=UNCLEAR; say EXPOSURE "UNCLEAR: $who" "location ${LM[$x]:+${LM[$x]} }${LP[$x]} at line ${LN[$x]} restricts /keys, but nginx would pick $d instead (a regex location wins over a plain prefix location; use ^~) (OUR HEURISTIC)."; return; fi
    done
    EXPO="NOT RESTRICTED"; say EXPOSURE "NOT RESTRICTED: $who" "nginx would send $TEST_URI to $d, which has no deny all, return 4xx or internal (OUR HEURISTIC)."; return
  fi
  # HAProxy
  local -a ACL=() ; local an rest hit="" hitln=0 unl="" t w neg
  for x in "${!L[@]}"; do
    line=${L[$x]}
    if [[ $line =~ ^acl\ +([A-Za-z0-9_.:-]+)\ +(path|path_beg|path_dir|path_sub|path_reg|path_end|url|url_beg|url_sub|url_reg|base|base_beg|base_reg)(\ |\().*keys ]]; then ACL+=("${BASH_REMATCH[1]}"); fi
  done
  for x in "${!L[@]}"; do
    line=${L[$x]}; lno=$((x+1))
    if [[ $line =~ ^(http-request\ +(deny|reject|tarpit|silent-drop|return)|tcp-request\ +content\ +reject)(\ .*)?\ (if|unless)\ (.*)$ ]]; then
      w=${BASH_REMATCH[4]}; rest=${BASH_REMATCH[5]}
      if [ "$w" = if ] && [[ $rest =~ \{[^}]*keys[^}]*\} ]]; then hit=inline; hitln=$lno; break; fi
      for t in $rest; do
        neg=0; [ "${t#!}" != "$t" ] && neg=1; t=${t#!}
        for an in "${ACL[@]}"; do
          if [ "$t" = "$an" ]; then
            if { [ "$w" = if ] && [ "$neg" -eq 0 ]; } || { [ "$w" = unless ] && [ "$neg" -eq 1 ]; }; then hit=$an; hitln=$lno; break 3
            else unl="$an at line $lno"; fi
          fi
        done
      done
    fi
  done
  local hitd="acl $hit"; [ "$hit" = inline ] && hitd="an inline /keys condition"
  if [ -n "$hit" ]; then EXPO=RESTRICTED; say EXPOSURE "RESTRICTED: $who" "line $hitln denies requests matching $hitd (OUR HEURISTIC; check the other conditions on that line and test from an untrusted network)."; return; fi
  if [ -n "$unl" ]; then EXPO=UNCLEAR; say EXPOSURE "UNCLEAR: $who" "acl $unl is used in a deny rule, but not in a way that denies /keys (OUR HEURISTIC)."; return; fi
  if [ "${#ACL[@]}" -gt 0 ]; then EXPO="NOT RESTRICTED"; say EXPOSURE "NOT RESTRICTED: $who" "acl ${ACL[0]} matches /keys but no http-request deny, reject, tarpit, silent-drop or return uses it (OUR HEURISTIC)."; return; fi
  EXPO="NOT RESTRICTED"; say EXPOSURE "NOT RESTRICTED: $who" "no acl matches /keys and no deny rule covers it (OUR HEURISTIC)."
}

keys="" logs="" proxy="" name="input" SVCLIST=quay seen=" "
while [ $# -gt 0 ]; do
  [ $# -ge 2 ] || usage
  case $1 in --keys|--logs|--proxy-config|--services|--name) case $seen in *" $1 "*) usage;; esac; seen="$seen$1 ";; *) usage;; esac
  case $1 in --keys) keys=$2;; --logs) logs=$2;; --proxy-config) proxy=$2;; --services) SVCLIST=$2;; --name) name=$2;; esac
  shift 2
done
{ [ -n "$keys" ] || [ -n "$logs" ] || [ -n "$proxy" ]; } || usage
IFS=, read -r -a SVCS <<< "$SVCLIST"
[ "${#SVCS[@]}" -gt 0 ] || die "--services needs at least one name"
for s in "${SVCS[@]}"; do [[ $s =~ ^[a-z0-9_]+$ ]] || die "service names are lower case letters, digits and _ (Quay's rule): $s"; done

# Read everything before printing anything, so read errors leave stdout empty.
out=""
if [ -n "$keys" ]; then o=$(do_keys "$keys" "$name") || exit 4; out+="$o"$'\n'; fi
if [ -n "$logs" ]; then o=$(do_logs "$logs" "$name") || exit 4; out+="$o"$'\n'; fi
if [ -n "$proxy" ]; then o=$(do_proxy "$proxy" "$name"; printf '\nEXPO=%s' "$EXPO") || exit 4
  EXPO=${o##*EXPO=}; o=${o%$'\n'EXPO=*}; o=${o%$'\n'}; out+="$o"$'\n'; fi
out=${out%$'\n'}
NS=$(printf '%s\n' "$out" | grep -c '^SUSPICIOUS: ')
NU=$(printf '%s\n' "$out" | grep -c '^UNKNOWN: ')

echo "check-quay-cve-2026-123710: offline review for CVE-2026-123710 in Red Hat Quay. Red Hat listed no fixed build when this pack was written (11 Nov 2026), so there is no version verdict."
echo "KEV: CVE-2026-123710 is not in CISA KEV catalog 2026.11.10 (released 10 Nov 2026 19:03 GMT), which predates the CVE (public 11 Nov 2026 14:00 GMT)."
echo "MITIGATION (Red Hat): restrict /keys at the proxy or load balancer so only trusted internal components can reach it."
printf '%s\n' "$out"
[ "$NS" -gt 0 ] && echo "NOTE: $name: treat repository tags as untrusted until you have compared their digests with what your build system pushed (OUR ADVICE)."
echo "EXPOSURE NOTE: Red Hat says deployments where /keys is reachable by untrusted clients are affected. This tool cannot test reachability; test /keys from an untrusted network."
if [ "$NS" -gt 0 ]; then res=SUSPICIOUS; rc=1
elif [ "$EXPO" = "NOT RESTRICTED" ] || [ "$EXPO" = UNCLEAR ]; then res="CHECK EXPOSURE"; rc=5
elif [ "$NU" -gt 0 ]; then res=UNKNOWN; rc=3
else res="NO FINDINGS"; rc=0; fi
echo "RESULT: $res ($NS SUSPICIOUS, $NU UNKNOWN, exposure: $EXPO)"
exit "$rc"
