#!/usr/bin/env bash
# check-quay-cve-2026-110697.sh: strictly read-only, offline review for CVE-2026-110697 in Red Hat Quay (robot listing builds a
# SQL LIKE pattern from the namespace name without escaping wildcards, CWE-89). Red Hat rates it Critical, CVSS 3.1 9.9 (Red Hat
# calls the score preliminary). Report only: it reads files an admin exports and hands over. No network, no file writes, nothing
# is run, and it never rotates or changes anything.
#
# Usage (one or more of --version, --namespaces):
#   --version LIST     comma separated Quay versions or image references (3.17.6, v3.17.6, .../quay/quay-rhel9:v3.17.6)
#   --namespaces FILE  namespace names: a text file (one name per line, # comments), a JSON array of names, or JSON from the
#                      superuser API ({"organizations": [{"name": ...}]}, {"users": [{"username": ...}]}, or both)
#   --robots FILE      robot accounts (needs --namespaces): GET /api/v1/organization/ORG/robots?token=false output
#                      ({"robots": [{"name": "ns+robot"}]}), a JSON array, or a text file with one ns+robot per line
#   --name NAME        label for the output (default: input)
# Version verdicts per stream: PATCHED or VULNERABLE against the fixed release Red Hat names for that 3.x stream (no errata
#   listed), NO FIX NAMED (Quay 3 streams Red Hat does not name, and Quay 5), OUT OF RANGE (not Quay 3 or 5), UNKNOWN (unreadable).
# Namespace and robot lines: COLLISION, ROTATE, NOTE, NO FINDINGS, UNKNOWN.
# Exit: 0 NO FINDINGS, 1 ACTION NEEDED (VULNERABLE, COLLISION or ROTATE), 3 UNKNOWN (UNKNOWN, NO FIX NAMED or OUT OF RANGE and
#   nothing worse), 4 usage or read error.
set -uf
export LC_ALL=C
FIXES="3.9.28 3.10.28 3.12.24 3.14.10 3.15.9 3.16.7 3.17.7 3.18.3"
NAMED="3.9, 3.10, 3.12, 3.14, 3.15, 3.16, 3.17, 3.18"

usage() {
  cat >&2 <<'U'
Usage: check-quay-cve-2026-110697.sh [--version LIST] [--namespaces FILE] [--robots FILE] [--name NAME]
  --version     comma separated Quay versions or image references (3.17.6, v3.17.6, registry/quay/quay-rhel9:v3.17.6)
  --namespaces  namespace names (text, one per line; JSON array; or superuser API organizations or users JSON)
  --robots      robot accounts, needs --namespaces (robots API JSON with token=false, JSON array, or text ns+robot per line)
  --name        label for the output (default: input)
At least one of --version, --namespaces is needed. Report only: nothing is changed or rotated.
Exit: 0 NO FINDINGS, 1 ACTION NEEDED, 3 UNKNOWN, 4 usage or read error
U
  exit 4
}
die() { echo "ERROR: $1" >&2; exit 4; }
say() { printf '%s: %s: %s\n' "$1" "$2" "$3"; }
# 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 256 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, 256)
  }
  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"
}

# Text list reader: one value per line, BOM and CR removed, spaces and tabs trimmed, blank and # lines skipped, then the same
# safe-character reduction as the JSON reader. Prints ROOT and then s|value per line.
read_text() {
  awk '
  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, 256)
  }
  BEGIN { print "ROOT" }
  {
    v = $0
    if (NR == 1) sub(/^\357\273\277/, "", v)
    sub(/\r$/, "", v); sub(/^[ \t]+/, "", v); sub(/[ \t]+$/, "", v)
    if (v == "" || substr(v, 1, 1) == "#") next
    print "s|" clean(v) "|-|"
  }' "$1"
}

# items FLAT KEYS EXTRA: the root array, or every array under one of KEYS in a root object, in file order. One line per item:
# t|value|extratype|extravalue, where value is the item itself when it is a string, else its name, else its username
# (t is - when there is none). Prints ROOT first, or NOROOT when there is no such array.
items() {
  printf '%s\n' "$1" | awk -F'|' -v keys="$2" -v extra=".$3" '
  BEGIN { nk = split(keys, K, " "); for (j = 1; j <= nk; j++) KK[K[j]] = 1; seq = 0; no = 0 }
  NR == 1 { if ($1 == "$" && $2 == "a") { mode = "a"; seq = 1; P[1] = "$[" } else if ($1 == "$" && $2 == "o") mode = "o"; else { print "NOROOT"; exit } next }
  mode == "o" && $2 == "a" && index($1, "$.") == 1 && (substr($1, 3) in KK) { seq++; P[seq] = $1 "["; next }
  seq > 0 && index($1, P[seq]) == 1 {
    rest = substr($1, length(P[seq]) + 1); j = index(rest, "]"); if (j == 0) next
    i = substr(rest, 1, j - 1); sub_ = substr(rest, j + 1)
    if (i !~ /^[0-9]+$/) next
    k = seq SUBSEP i
    if (sub_ == "") { if (!(k in T0)) ORD[++no] = k; T0[k] = $2; V0[k] = $3; next }
    if (sub_ == ".name" || sub_ == ".username" || sub_ == extra) { T[k, sub_] = $2; V[k, sub_] = $3 }
  }
  END {
    if (mode == "" || (mode == "o" && seq == 0)) { print "NOROOT"; exit }
    print "ROOT"
    for (x = 1; x <= no; x++) {
      k = ORD[x]; t = "-"; v = ""
      if (T0[k] == "s") { t = "s"; v = V0[k] }
      else if (T0[k] == "o") {
        if (T[k, ".name"] == "s") { t = "s"; v = V[k, ".name"] } else if (T[k, ".username"] == "s") { t = "s"; v = V[k, ".username"] }
      }
      et = "-"; ev = ""
      if (T0[k] == "o" && ((k, extra) in T)) { et = T[k, extra]; ev = V[k, extra] }
      print t "|" v "|" et "|" ev
    }
  }'
}

# read_list FILE LABEL KEYS EXTRA: JSON when the first character (after a BOM and whitespace) is [ or {, else a text list.
read_list() {
  if [ ! -f "$1" ] || [ ! -r "$1" ]; then die "cannot read $2 file: $1"; fi
  local first flat r
  first=$(awk '{ v = $0; if (NR == 1) sub(/^\357\273\277/, "", v); if (match(v, /[^ \t\r]/)) { print substr(v, RSTART, 1); exit } }' "$1") || die "cannot read $2 file: $1"
  if [ "$first" = "[" ] || [ "$first" = "{" ]; then
    flat=$(flatten "$1") || die "$2 file is not valid JSON: $1"
    r=$(items "$flat" "$3" "$4")
    [ "$r" = NOROOT ] && die "$2 file has no list of names (want a JSON array or {\"$5\": [...]}): $1"
    printf '%s\n' "$r"
  else
    read_text "$1" || die "cannot read $2 file: $1"
  fi
}

valid_ns() { [ "${#1}" -ge 2 ] && [ "${#1}" -le 255 ] && [[ $1 =~ ^[a-z0-9]+([._-][a-z0-9]+)*$ ]]; }

do_versions() { # list name
  local item disp v M m P fx fm fp who found
  local -a ITEMS=()
  IFS=, read -r -a ITEMS <<< "$1"
  for item in "${ITEMS[@]}"; do
    [ -n "$item" ] || continue
    disp=$(printf '%s' "$item" | awk -v RS='\001' '{ o = ""; for (i = 1; i <= length($0); i++) { c = substr($0, i, 1); if (c ~ /[A-Za-z0-9._:@\/+-]/) o = o c; else if (c >= "\200" && c <= "\277") continue; else o = o "?" } print substr(o, 1, 100) }')
    who="$2 (version $disp)"
    v=${disp##*/}
    if [[ $v == *@* ]]; then say UNKNOWN "$who" "a digest names no version. Read the version from the image labels or the Quay UI footer."; continue; fi
    v=${v##*:}; v=${v#v}
    if ! [[ $v =~ ^([0-9]{1,4})\.([0-9]{1,4})\.([0-9]{1,4})(-[0-9]{1,6})?$ ]]; then
      say UNKNOWN "$who" "cannot read a Quay version from this (want 3.17.6, v3.17.6 or an image reference ending :v3.17.6). A tag such as latest needs the version from the image labels."; continue
    fi
    M=$((10#${BASH_REMATCH[1]})); m=$((10#${BASH_REMATCH[2]})); P=$((10#${BASH_REMATCH[3]}))
    if [ "$M" -eq 5 ]; then say "NO FIX NAMED" "$who" "Red Hat lists Quay 5 (quay-rhel9) as affected and names no Quay 5 fixed release. Apply Red Hat's interim steps and ask Red Hat for the fix."; continue; fi
    if [ "$M" -ne 3 ]; then say "OUT OF RANGE" "$who" "Red Hat's record covers Quay 3 and Quay 5 only, so this tool gives no verdict for $M.$m.$P."; continue; fi
    found=""
    for fx in $FIXES; do fm=${fx#3.}; fm=${fm%%.*}; fp=${fx##*.}; [ "$fm" -eq "$m" ] && found=$fx && break; done
    if [ -z "$found" ]; then say "NO FIX NAMED" "$who" "Red Hat lists Quay 3 as affected and names no fixed release for the 3.$m stream (streams with a named release: $NAMED). Move to a stream with a named release or ask Red Hat."; continue; fi
    if [ "$P" -ge "$fp" ]; then say PATCHED "$who" "3.$m.$P is at or above $found, the fixed release Red Hat names for the 3.$m stream (fixed release named by Red Hat; no errata listed, and Red Hat's packages table still shows Quay 3 as Affected). Confirm the build came from Red Hat."
    else say VULNERABLE "$who" "3.$m.$P is below $found, the fixed release Red Hat names for the 3.$m stream (fixed release named by Red Hat; no errata listed). Patch now, multi-tenant and customer-shared registries first (OUR ADVICE)."; fi
  done
}

# collisions NAMES...: for every name with _ prints name|match1 match2 ... (matches in input order). Quay lists robots with
# username LIKE NAME+%, so _ matches any single character and only a namespace of the same length can match (robot names
# hold exactly one +, and namespace names cannot hold % or a backslash).
collisions() {
  printf '%s\n' "$@" | awk '
  { N[NR] = $0; L[NR] = length($0) }
  END {
    for (a = 1; a <= NR; a++) {
      if (index(N[a], "_") == 0) continue
      out = ""
      for (b = 1; b <= NR; b++) {
        if (b == a || L[b] != L[a]) continue
        ok = 1
        for (i = 1; i <= L[a]; i++) { c = substr(N[a], i, 1); if (c != "_" && c != substr(N[b], i, 1)) { ok = 0; break } }
        if (ok) out = out (out == "" ? "" : " ") N[b]
      }
      print N[a] "|" out
    }
  }'
}

VICTIMS=" "
declare -A HITBY=()
do_namespaces() { # file name
  local f=$1 n=$2 r t v _et _ev tot=0 skip=0 nu=0 ncol=0 a ms
  local -a NS=()
  declare -A SEEN=()
  r=$(read_list "$f" namespaces "organizations users namespaces" "" "organizations") || exit 4
  while IFS='|' read -r t v _et _ev; do
    [ "$t" = ROOT ] && continue
    tot=$((tot+1))
    if [ "$t" != s ] || ! valid_ns "$v"; then skip=$((skip+1)); continue; fi
    [ -n "${SEEN[$v]:-}" ] && continue
    SEEN[$v]=1; NS+=("$v")
  done <<< "$r"
  if [ "${#NS[@]}" -gt 0 ]; then
    while IFS='|' read -r a ms; do
      [ -n "$a" ] || continue
      nu=$((nu+1))
      [ -n "$ms" ] || continue
      ncol=$((ncol+1))
      say COLLISION "$n (namespace $a)" "contains _ and matches ${ms// /, } in Quay's robot listing pattern ($a+%, where _ matches any single character). On an affected release, Red Hat says this lets an attacker who creates such a namespace list other tenants' robot accounts with their tokens. Red Hat Bugzilla 2552237 adds that renaming or deleting $a renames or deletes the matched robots."
      for v in $ms; do case $VICTIMS in *" $v "*) ;; *) VICTIMS="$VICTIMS$v ";; esac; HITBY[$v]="${HITBY[$v]:+${HITBY[$v]}, }$a"; done
    done <<< "$(collisions "${NS[@]}")"
  fi
  say NOTE "$n (namespaces file)" "$tot names read: ${#NS[@]} valid and distinct, $nu contain _, $ncol of those match another namespace, $skip skipped (not a valid Quay namespace name)."
  if [ "${#NS[@]}" -eq 0 ]; then say UNKNOWN "$n (namespaces file)" "no valid namespace names in the export. Export every organization and user name (superuser API or database)."
  elif [ "$nu" -eq 0 ]; then say "NO FINDINGS" "$n (namespaces file)" "no namespace name contains _, so no name in this export can widen the robot listing pattern."
  elif [ "$ncol" -eq 0 ]; then say "NO FINDINGS" "$n (namespaces file)" "$nu name(s) contain _ but none matches another namespace in this export. A namespace created later could still match, so keep the export current."; fi
}

do_robots() { # file name
  local f=$1 n=$2 r t v et ev tot=0 skip=0 nr=0 ns short la
  r=$(read_list "$f" robots "robots" "last_accessed" "robots") || exit 4
  while IFS='|' read -r t v et ev; do
    [ "$t" = ROOT ] && continue
    tot=$((tot+1))
    if [ "$t" != s ] || [[ $v != *+* ]]; then skip=$((skip+1)); continue; fi
    ns=${v%%+*}; short=${v#*+}
    if ! valid_ns "$ns" || ! valid_ns "$short"; then skip=$((skip+1)); continue; fi
    case $VICTIMS in *" $ns "*) ;; *) continue;; esac
    nr=$((nr+1))
    la=""; [ "$et" = s ] && [ -n "$ev" ] && la=" Last accessed $ev."
    say ROTATE "$n (robot $v)" "namespace $ns matches underscore namespace ${HITBY[$ns]}, so on an affected release this robot's token could have been listed from there. Regenerate the token after patching (Red Hat's interim steps also say to rotate robot tokens for colliding namespaces; OUR NOTE: a token rotated while the release is still affected and the underscore namespace still exists can be listed again).$la Report only: this tool rotates nothing."
  done <<< "$r"
  say NOTE "$n (robots file)" "$tot robots read: $nr to rotate, $skip skipped (not namespace+robot). Token values are never read or printed; export with token=false."
  if [ "$tot" -eq 0 ]; then say UNKNOWN "$n (robots file)" "no robots in the export."
  elif [ "$nr" -eq 0 ]; then say "NO FINDINGS" "$n (robots file)" "no robot in this export belongs to a namespace that matches an underscore namespace."; fi
}

vers="" nsf="" robf="" name="input" seen=" "
while [ $# -gt 0 ]; do
  [ $# -ge 2 ] || usage
  case $1 in --version|--namespaces|--robots|--name) case $seen in *" $1 "*) usage;; esac; seen="$seen$1 ";; *) usage;; esac
  case $1 in --version) vers=$2;; --namespaces) nsf=$2;; --robots) robf=$2;; --name) name=$2;; esac
  shift 2
done
{ [ -n "$vers" ] || [ -n "$nsf" ]; } || usage
[ -z "$robf" ] || [ -n "$nsf" ] || die "--robots needs --namespaces (the collisions come from the namespace list)"
case $seen in *" --version "*) [ -n "${vers//,/}" ] || die "--version needs at least one version";; esac
case $vers in *$'\n'*) die "--version must be a single line";; esac

# Read everything before printing anything, so read errors leave stdout empty.
out=""
if [ -n "$vers" ]; then o=$(do_versions "$vers" "$name"); out+="$o"$'\n'; fi
if [ -n "$nsf" ]; then
  o=$(do_namespaces "$nsf" "$name"; printf '\nV=%s' "$VICTIMS"; for k in "${!HITBY[@]}"; do printf '\nH=%s=%s' "$k" "${HITBY[$k]}"; done) || exit 4
  while IFS= read -r line; do
    case $line in V=*) VICTIMS=${line#V=};; H=*) line=${line#H=}; HITBY[${line%%=*}]=${line#*=};; esac
  done <<< "$o"
  o=$(printf '%s\n' "$o" | grep -vE '^(V|H)=')
  o=${o%$'\n'}; out+="$o"$'\n'
  if [ -n "$robf" ]; then o=$(do_robots "$robf" "$name") || exit 4; out+="$o"$'\n'; fi
fi
out=${out%$'\n'}
NA=$(printf '%s\n' "$out" | grep -cE '^(VULNERABLE|COLLISION|ROTATE): ')
NU=$(printf '%s\n' "$out" | grep -cE '^(UNKNOWN|NO FIX NAMED|OUT OF RANGE): ')
NCR=$(printf '%s\n' "$out" | grep -cE '^(COLLISION|ROTATE): ')

echo "check-quay-cve-2026-110697: offline review for CVE-2026-110697 in Red Hat Quay (robot listing LIKE wildcard, CWE-89). Report only: it reads exports, changes nothing and rotates nothing."
echo "FIX STATE (as published by Red Hat on 11 Nov 2026, not resolved here): Red Hat's mitigation names fixed releases 3.9.28, 3.10.28, 3.12.24, 3.14.10, 3.15.9, 3.16.7, 3.17.7 and 3.18.3, but Red Hat's packages table still shows Quay 3 and Quay 5 as Affected with no advisory, and no errata are listed. No Quay 5 fixed release is named."
echo "KEV: CVE-2026-110697 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, interim, does not fix the flaw): restrict who can create organizations or namespaces containing an underscore, rotate robot tokens for any namespace that collides with an underscore-containing one, and monitor registry activity for pulls or pushes that don't match expected robot usage."
printf '%s\n' "$out"
[ "$NCR" -gt 0 ] && echo "NOTE: $name: if robots were misused, compare image digests with what your build system pushed and review downstream deployments (OUR ADVICE)."
if [ "$NA" -gt 0 ]; then res="ACTION NEEDED"; rc=1
elif [ "$NU" -gt 0 ]; then res=UNKNOWN; rc=3
else res="NO FINDINGS"; rc=0; fi
echo "RESULT: $res ($NA VULNERABLE, COLLISION or ROTATE; $NU UNKNOWN, NO FIX NAMED or OUT OF RANGE)"
exit "$rc"
