#!/bin/bash
#
# fetch-fortify.sh
# Query a Fortify SSC instance for a project version's open findings and
# emit a normalized JSON view that Phase 4 (Review) consumes as a
# deterministic security gate.
#
# Three input forms:
#   ./fetch-fortify.sh <ssc-url>                         # parses versionId / issue from URL
#   ./fetch-fortify.sh --version-id <id> [--issue <id>]  # explicit components
#   ./fetch-fortify.sh --instance-id <id>                # instance id alone; the
#       project version is resolved by searching prefs.global.fortify.versionIds
#       in order. A finding often reaches a ticket as "Fortify Instance ID: <id>"
#       with no SSC link at all - a scanner-to-tracker bridge knows the id, and
#       the person reading the ticket never needed the URL.
#
# Required configuration:
#   prefs.global.hosts.fortify            -  SSC host (e.g. ssc.example.com)
#   prefs.global.keychainMapping.fortify  -  keychain key holding the API token
#   prefs.global.fortify.versionIds[]     -  project versions searched, in order,
#                                            when only an instance id is known
#
# Optional env:
#   FORTIFY_TIMEOUT_SECONDS    default 25
#   FORTIFY_HOST_OVERRIDE      forces the host (used by tests and CI dry-runs)
#   FORTIFY_AUTH_SCHEME        auto|bearer|fortifytoken (default: auto)
#
# Output (stdout, single JSON object):
#   {
#     "fetchedAt": "<ISO8601>",
#     "source": { "url": "<url|null>", "host": "<host>", "versionId": "...",
#                 "versionName": "...", "projectName": "..." },
#     "severityCounts": { "Critical": <n>, "High": <n>, "Medium": <n>, "Low": <n> },
#     "totalOpenIssues": <n>,
#     "findings": [
#       { "id": "...", "issueName": "...", "severity": "Critical|High|Medium|Low",
#         "friority": "...", "kingdom": "...", "analyzer": "...",
#         "file": "...", "line": <n>,
#         "description": "...", "recommendation": "...", "codeSnippet": "..."|null }
#     ],
#     "gateOutcome": { "blocking": <bool>, "reason": "..." }
#   }
#
# Exit codes:
#   0  success (gateOutcome may still be blocking)
#   2  missing/expired credential (orchestrator handles the Save Flow)
#   3  network/auth failure
#   4  bad usage
#   6  not configured (prefs.global.hosts.fortify empty, or an instance-id-only
#      lookup with no prefs.global.fortify.versionIds to search)
#
# Phase 4 review gate contract:
#   Critical > 0   → blocking=true, reason="critical-findings"
#   High > 0       → blocking=false, reason="high-findings-warning"
#   else           → blocking=false, reason="clean"

set -euo pipefail

URL=""
VERSION_ID=""
ISSUE_ID=""
TIMEOUT="${FORTIFY_TIMEOUT_SECONDS:-25}"
AUTH_SCHEME="${FORTIFY_AUTH_SCHEME:-auto}"

while [ $# -gt 0 ]; do
  case "$1" in
    --version-id) VERSION_ID="$2"; shift 2 ;;
    --issue)      ISSUE_ID="$2";   shift 2 ;;
    --instance-id) ISSUE_ID="$2";  shift 2 ;;
    -h|--help)
      echo "usage: $0 <ssc-url> | $0 --version-id <id> [--issue <id>] | $0 --instance-id <id>" >&2
      exit 4 ;;
    *)
      if [ -z "$URL" ]; then URL="$1"; shift; else
        echo "ERR: unexpected arg $1" >&2; exit 4
      fi ;;
  esac
done

# Host comes from prefs (private config); skill templates only show <ssc-host>.
PREFS="$HOME/.claude/multi-agent-preferences.json"
HOST="${FORTIFY_HOST_OVERRIDE:-}"
if [ -z "$HOST" ] && [ -f "$PREFS" ]; then
  HOST=$(python3 -c "
import json
try:
    p = json.load(open('$PREFS'))
    print(p.get('global', {}).get('hosts', {}).get('fortify') or '')
except Exception:
    print('')
")
fi
if [ -z "$HOST" ]; then
  printf '%s\n' '{"status":"blocked","reason":"host-not-configured","service":"fortify","expected_pref":"global.hosts.fortify"}' >&2
  exit 6
fi
HOST=${HOST%/}  # strip trailing slash if any

# Parse URL components when present.
if [ -n "$URL" ] && [ -z "$VERSION_ID" ]; then
  PARSED=$(URL_IN="$URL" python3 - <<'PY'
import os, re, urllib.parse as up
url = os.environ["URL_IN"]
p = up.urlparse(url)
# Common Fortify SSC URL shapes  -  match the version id and (optionally) the
# issue instance id from the path or fragment.
text = p.path + ("#" + p.fragment if p.fragment else "")
m = re.search(r"/version/(?P<ver>\d+)(?:/fix/(?P<inst>[A-Za-z0-9-]+))?", text)
ver = m.group("ver") if m else ""
inst = m.group("inst") if (m and m.group("inst")) else ""
if not ver:
    m2 = re.search(r"/projectVersions/(?P<ver>\d+)", text)
    ver = m2.group("ver") if m2 else ""
print(f"{ver}\t{inst}")
PY
)
  VERSION_ID=$(printf '%s' "$PARSED" | cut -f1)
  [ -z "$ISSUE_ID" ] && ISSUE_ID=$(printf '%s' "$PARSED" | cut -f2)
fi

# An instance id with no version is resolvable: search the configured versions
# below, once the token is in hand. Anything else is a usage error.
if [ -z "$VERSION_ID" ] && [ -z "$ISSUE_ID" ]; then
  echo "ERR: no Fortify version id (pass --version-id, --instance-id, or a URL containing /version/<id>)" >&2
  exit 4
fi

VERSION_CANDIDATES=""
if [ -z "$VERSION_ID" ] && [ -f "$PREFS" ]; then
  VERSION_CANDIDATES=$(python3 -c "
import json
try:
    p = json.load(open('$PREFS'))
    ids = p.get('global', {}).get('fortify', {}).get('versionIds') or []
    print(' '.join(str(i) for i in ids if str(i).strip()))
except Exception:
    print('')
")
fi
if [ -z "$VERSION_ID" ] && [ -z "$VERSION_CANDIDATES" ]; then
  printf '%s\n' '{"status":"blocked","reason":"version-not-configured","service":"fortify","expected_pref":"global.fortify.versionIds"}' >&2
  exit 6
fi

# Token via keychain mapping.
TOKEN_KEY=""
if [ -f "$PREFS" ]; then
  TOKEN_KEY=$(python3 -c "
import json
try:
    p = json.load(open('$PREFS'))
    print(p.get('global', {}).get('keychainMapping', {}).get('fortify') or '')
except Exception:
    print('')
")
fi
[ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Fortify_Access_Token"

# Locate the resolver with an existence check, not a `.`-chain.
#
# Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
# so `. <candidate> || . <candidate> || { error }` reaches neither its later candidates
# nor its error branch. Every fetcher used that shape starting from `$HOME/.claude/...`,
# so on a Copilot-only or Codex-only install they all died with a bare exit 1 and no
# message. Reordering does not help: whichever candidate is absent aborts at that point.
# Checking for the file before sourcing it is the only safe form.
for _cred_resolver in \
  "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
  "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/credential-store-resolver.sh" \
  "$HOME/.claude/lib/credential-store-resolver.sh" \
  "$HOME/.copilot/lib/credential-store-resolver.sh" \
  "$HOME/.codex/lib/credential-store-resolver.sh"; do
  [ -f "$_cred_resolver" ] || continue
  # shellcheck source=/dev/null
  . "$_cred_resolver" 2>/dev/null || true
  # `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
  # 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
  # candidate that does not resolve - the very case the loop exists to survive.
  if [ -n "${CRED_STORE:-}" ]; then break; fi
done
unset _cred_resolver
if [ -z "${CRED_STORE:-}" ]; then
  printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"fortify","expected_key":"'"$TOKEN_KEY"'"}' >&2
  exit 2
fi

TOKEN=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
if [ -z "$TOKEN" ]; then
  printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"fortify","expected_key":"'"$TOKEN_KEY"'"}' >&2
  exit 2
fi

API_BASE="https://$HOST/ssc/api/v1"

fortify_get() {
  # Usage: fortify_get <path-relative-to-API_BASE>
  local path="$1"
  local url="$API_BASE/$path"
  local resp http schemes
  case "$AUTH_SCHEME" in
    bearer)       schemes="bearer" ;;
    fortifytoken) schemes="fortifytoken" ;;
    *)            schemes="bearer fortifytoken" ;;
  esac
  for s in $schemes; do
    local hdr
    if [ "$s" = "bearer" ]; then
      hdr="Authorization: Bearer $TOKEN"
    else
      hdr="Authorization: FortifyToken $(printf '%s' "$TOKEN" | base64 | tr -d '\n')"
    fi
    # Auth header goes through a curl config via process substitution so the
    # token never appears in argv (argv is visible to ps).
    resp=$(curl -sS --max-time "$TIMEOUT" --connect-timeout 5 -w "\n%{http_code}" \
             -K <(printf 'header = "%s"\n' "$hdr") \
             -H "Accept: application/json" "$url" 2>/dev/null || true)
    http=$(printf '%s' "$resp" | tail -n1)
    if [ "$http" = "200" ]; then
      printf '%s' "$resp" | sed '$d'
      return 0
    fi
    if [ "$http" = "401" ] || [ "$http" = "403" ]; then
      continue
    fi
    # Other non-200 → return whatever the server said + the code.
    printf '%s\nHTTP_%s' "$(printf '%s' "$resp" | sed '$d')" "$http"
    return 1
  done
  printf 'HTTP_AUTH_FAILED'
  return 1
}

# Instance-id-only lookup: ask each configured version whether it owns the id.
# First hit wins; instance ids are globally unique in practice, and searching a
# handful of versions costs one bounded call each.
if [ -z "$VERSION_ID" ]; then
  for _cand in $VERSION_CANDIDATES; do
    _probe=$(fortify_get "projectVersions/$_cand/issues?q=issueInstanceId:\"$ISSUE_ID\"&limit=1" 2>/dev/null || true)
    _hit=$(printf '%s' "$_probe" | python3 -c "
import sys, json
try:
    print('yes' if (json.load(sys.stdin).get('data') or []) else 'no')
except Exception:
    print('no')
" 2>/dev/null || echo no)
    if [ "$_hit" = "yes" ]; then
      VERSION_ID="$_cand"
      break
    fi
  done
  if [ -z "$VERSION_ID" ]; then
    echo "ERR: Fortify instance $ISSUE_ID not found in configured versions ($VERSION_CANDIDATES)" >&2
    exit 3
  fi
fi
unset _cand _probe _hit 2>/dev/null || true

# Check fortify_get's actual exit status, not a string pattern over its
# output - the old `case ... HTTP_AUTH_FAILED|*HTTP_4*)` only recognized
# auth failures and 4xx. A dropped connection, timeout, or 5xx made
# fortify_get return "...\nHTTP_000"/"...\nHTTP_5xx", which matched neither
# arm, so the case fell through, VERSION_JSON stayed unparseable garbage,
# and the Python block below silently treated that as "0 issues found" -
# the security gate passing precisely when it couldn't check anything.
if ! VERSION_JSON=$(fortify_get "projectVersions/$VERSION_ID"); then
  echo "ERR: Fortify fetch failed for projectVersions/$VERSION_ID: ${VERSION_JSON:-no response}" >&2
  exit 3
fi

ISSUES_JSON=""
if [ -n "$ISSUE_ID" ]; then
  if ! ISSUES_JSON=$(fortify_get "projectVersions/$VERSION_ID/issues?q=issueInstanceId:\"$ISSUE_ID\"&limit=20"); then
    echo "ERR: Fortify issues fetch failed for issue $ISSUE_ID: ${ISSUES_JSON:-no response}" >&2
    exit 3
  fi
else
  # filter=ANALYSIS!=NotAnIssue,ANALYSIS!=FalsePositive (%21%3D is `!=`) is
  # meant to EXCLUDE suppressed findings. It was encoded as %3D%3D (`==`),
  # which instead selected only suppressed findings - the opposite of the
  # gate's intent whenever SSC honors the filter.
  if ! ISSUES_JSON=$(fortify_get "projectVersions/$VERSION_ID/issues?limit=200&filter=ANALYSIS%21%3DNotAnIssue,ANALYSIS%21%3DFalsePositive&filterset=Critical%2CHigh"); then
    echo "ERR: Fortify issues fetch failed for version $VERSION_ID: ${ISSUES_JSON:-no response}" >&2
    exit 3
  fi
fi

# Issue details are fetched per-issue lazily  -  only for the first 10 to keep
# the response size bounded.
ISSUE_DETAILS_LIST=$(printf '%s' "$ISSUES_JSON" | python3 -c "
import sys, json
try:
    r = json.load(sys.stdin)
    items = r.get('data') or []
    ids = []
    for it in items[:10]:
        if isinstance(it, dict) and it.get('id') is not None:
            ids.append(str(it['id']))
    print('\\n'.join(ids))
except Exception:
    pass
" 2>/dev/null || true)

DETAILS_JSON_LIST=""
for id in $ISSUE_DETAILS_LIST; do
  d=$(fortify_get "issueDetails/$id" 2>/dev/null || true)
  if [ -n "$d" ]; then
    DETAILS_JSON_LIST="${DETAILS_JSON_LIST}${d}"$'\n---SEP---\n'
  fi
done

SRC_URL="$URL" SRC_HOST="$HOST" SRC_VERSION="$VERSION_ID" \
VERSION_JSON_IN="$VERSION_JSON" ISSUES_JSON_IN="$ISSUES_JSON" \
DETAILS_LIST_IN="$DETAILS_JSON_LIST" \
python3 - <<'PY'
import json, os, datetime

def safe_load(s):
    try:
        return json.loads(s) if s else None
    except Exception:
        return None

version = safe_load(os.environ["VERSION_JSON_IN"]) or {}
version_data = version.get("data") or version
issues = safe_load(os.environ["ISSUES_JSON_IN"]) or {}
issues_data = issues.get("data") or []

details_blob = os.environ.get("DETAILS_LIST_IN") or ""
detail_map = {}
for piece in details_blob.split("\n---SEP---\n"):
    if not piece.strip():
        continue
    d = safe_load(piece)
    if d is None:
        continue
    payload = d.get("data") or d
    iid = payload.get("id") or payload.get("issueId")
    if iid is not None:
        detail_map[str(iid)] = payload

def severity_of(item):
    # Friority is Fortify's canonical bucket  -  Critical/High/Medium/Low.
    fri = (item.get("friority") or item.get("severity") or "").capitalize()
    if fri in ("Critical","High","Medium","Low"):
        return fri
    # Numeric severity fallback (1-4 → Critical→Low).
    sev = item.get("severity")
    try:
        n = float(sev)
        if n >= 3.5: return "Critical"
        if n >= 2.5: return "High"
        if n >= 1.5: return "Medium"
        return "Low"
    except Exception:
        return "Medium"

counts = {"Critical": 0, "High": 0, "Medium": 0, "Low": 0}
findings = []
for it in issues_data:
    sev = severity_of(it)
    counts[sev] = counts.get(sev, 0) + 1
    iid = str(it.get("id") or "")
    det = detail_map.get(iid) or {}
    findings.append({
        "id": iid,
        "issueName": it.get("issueName") or it.get("name") or det.get("issueName") or "",
        "severity": sev,
        "friority": it.get("friority") or "",
        "kingdom": it.get("kingdom") or det.get("kingdom") or "",
        "analyzer": it.get("analyzer") or det.get("analyzer") or "",
        "file": it.get("fullFileName") or it.get("primaryLocation") or det.get("fullFileName") or "",
        "line": it.get("lineNumber") or det.get("lineNumber") or 0,
        "description": (det.get("detail") or det.get("description") or "")[:1500],
        "recommendation": (det.get("recommendation") or "")[:1500],
        "codeSnippet": (det.get("brief") or det.get("codeSnippet") or None),
    })

# Sort severity-first.
order = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3}
findings.sort(key=lambda x: order.get(x["severity"], 9))

if counts["Critical"] > 0:
    gate = {"blocking": True,  "reason": "critical-findings"}
elif counts["High"] > 0:
    gate = {"blocking": False, "reason": "high-findings-warning"}
else:
    gate = {"blocking": False, "reason": "clean"}

result = {
    "fetchedAt": datetime.datetime.utcnow().isoformat() + "Z",
    "source": {
        "url": os.environ.get("SRC_URL") or None,
        "host": os.environ["SRC_HOST"],
        "versionId": os.environ["SRC_VERSION"],
        "versionName": version_data.get("name") or "",
        "projectName": (version_data.get("project") or {}).get("name") or "",
    },
    "severityCounts": counts,
    "totalOpenIssues": sum(counts.values()),
    "findings": findings,
    "gateOutcome": gate,
}
print(json.dumps(result, ensure_ascii=False))
PY
