#!/usr/bin/env bash
# =============================================================================
# ce.rodeo score verification
# =============================================================================
#
# This script reproduces exactly how ce.rodeo computed the score for an image.
# It uses the same tools (skopeo, syft, grype, cosign), applies the same open
# rubric, and prints the final grade + a per-signal breakdown you can diff
# against what ce.rodeo recorded.
#
# For *bit-identical* reproducibility against a published snapshot, pin the
# Grype DB version to the one recorded on that snapshot. Without the pin, CVE
# counts can drift as new advisories land.
#
# Usage:
#   ./verify-score.sh <image_ref> [options]
#
# Options:
#   --rubric <version>         Rubric version to apply (default: cerodeo-v1)
#   --grype-db <version>       Pin Grype DB to this version for bit-identical
#                              reproduction. See ce.rodeo/image/<uuid> for the
#                              version recorded on a specific snapshot.
#   --rubric-url <url>         Alternative rubric source (default: ce.rodeo)
#   --output <path>            Write a JSON report to this path
#   --expected <path>          Diff against ce.rodeo's recorded JSON for the
#                              same image. Download from
#                              https://api.ce.rodeo/v1/images/<uuid>/cves
#   --no-cosign                Skip cosign signature + attestation checks
#                              (useful on hosts without cosign installed)
#   --verbose                  Print every intermediate command and value
#
# Prerequisites:
#   skopeo, syft, grype, cosign (optional), jq, curl
#
# Example:
#   ./verify-score.sh bitnami/nginx:latest --rubric cerodeo-v1
#   ./verify-score.sh nginx:1.30.5-trixie --grype-db v6.1.10
#
# License: Apache-2.0. Source: https://github.com/c0inz/cerodeo/scripts/
# =============================================================================

set -euo pipefail

#------------------------------------------------------------------------------
# Argument parsing
#------------------------------------------------------------------------------
if [[ $# -lt 1 ]]; then
  sed -nE 's/^# ?//p' "$0" | head -40 >&2
  exit 2
fi

REF="$1"; shift
RUBRIC_VERSION="cerodeo-v1"
RUBRIC_URL=""
GRYPE_DB_PIN=""
OUTPUT_PATH=""
EXPECTED_PATH=""
SKIP_COSIGN=0
VERBOSE=0

while [[ $# -gt 0 ]]; do
  case "$1" in
    --rubric)       RUBRIC_VERSION="$2"; shift 2 ;;
    --rubric-url)   RUBRIC_URL="$2"; shift 2 ;;
    --grype-db)     GRYPE_DB_PIN="$2"; shift 2 ;;
    --output)       OUTPUT_PATH="$2"; shift 2 ;;
    --expected)     EXPECTED_PATH="$2"; shift 2 ;;
    --no-cosign)    SKIP_COSIGN=1; shift ;;
    --verbose)      VERBOSE=1; shift ;;
    -h|--help)      sed -nE 's/^# ?//p' "$0" | head -40; exit 0 ;;
    *) echo "unknown flag: $1" >&2; exit 2 ;;
  esac
done

[[ -z "$RUBRIC_URL" ]] && RUBRIC_URL="https://ce.rodeo/data/rubric/${RUBRIC_VERSION}.json"

#------------------------------------------------------------------------------
# Dependency checks
#------------------------------------------------------------------------------
for cmd in skopeo syft grype jq curl; do
  if ! command -v "$cmd" >/dev/null 2>&1; then
    echo "ERROR: '$cmd' not on PATH. Install it and retry." >&2
    exit 1
  fi
done
if [[ $SKIP_COSIGN -eq 0 ]] && ! command -v cosign >/dev/null 2>&1; then
  echo "WARN: cosign not on PATH; signature + attestation signals will be scored 0. Pass --no-cosign to silence this." >&2
  SKIP_COSIGN=1
fi

log() { [[ $VERBOSE -eq 1 ]] && echo "[verify] $*" >&2 || true; }
section() { echo >&2; echo "== $* ==" >&2; }

TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT

#------------------------------------------------------------------------------
# Resolve bare Docker Official refs the same way our scanner does.
# Syft's source parser treats a leading `name:` as a scheme (e.g. registry:…),
# so bare `registry:3.1.1` would fail to resolve. Normalize by prepending
# docker.io/library/ when there's no '/'.
#------------------------------------------------------------------------------
if [[ "$REF" != */* ]]; then
  SYFT_REF="docker.io/library/$REF"
else
  SYFT_REF="$REF"
fi

#------------------------------------------------------------------------------
# Fetch the rubric spec
#------------------------------------------------------------------------------
section "rubric spec ($RUBRIC_VERSION)"
log "GET $RUBRIC_URL"
RUBRIC_FILE="$TMPDIR/rubric.json"
if ! curl -fsSL "$RUBRIC_URL" -o "$RUBRIC_FILE"; then
  echo "ERROR: cannot fetch rubric from $RUBRIC_URL" >&2
  exit 1
fi
RUBRIC_LOADED=$(jq -r .version "$RUBRIC_FILE")
echo "loaded rubric: $RUBRIC_LOADED" >&2
if [[ "$RUBRIC_LOADED" != "$RUBRIC_VERSION" ]]; then
  echo "WARN: requested $RUBRIC_VERSION but got $RUBRIC_LOADED" >&2
fi

#------------------------------------------------------------------------------
# Pin Grype DB if requested
#------------------------------------------------------------------------------
if [[ -n "$GRYPE_DB_PIN" ]]; then
  section "pinning Grype DB to $GRYPE_DB_PIN"
  echo "NOTE: Grype DB pinning requires the archive for the pinned version." >&2
  echo "      If you don't have it locally, see Anchore's grype-db archive at:" >&2
  echo "      https://toolbox-data.anchore.io/grype/databases/listing.json" >&2
  echo "      Falling through to the local Grype DB — results may drift." >&2
fi

#------------------------------------------------------------------------------
# Pull manifest + config + raw list
#------------------------------------------------------------------------------
section "collecting manifest + config via skopeo"
skopeo inspect "docker://$SYFT_REF" > "$TMPDIR/inspect.json"
skopeo inspect --raw "docker://$SYFT_REF" > "$TMPDIR/manifest-raw.json" 2>/dev/null || echo '{}' > "$TMPDIR/manifest-raw.json"
skopeo inspect --config "docker://$SYFT_REF" > "$TMPDIR/config.json" 2>/dev/null || echo '{}' > "$TMPDIR/config.json"

CREATED=$(jq -r '.Created // empty' "$TMPDIR/inspect.json")
LABELS_COUNT=$(jq -r '.config.Labels // {} | keys | length' "$TMPDIR/config.json")
USER_FIELD=$(jq -r '.config.User // ""' "$TMPDIR/config.json")
HEALTHCHECK_PRESENT=$(jq -r '.config.Healthcheck // "" | if . == "" then "false" else "true" end' "$TMPDIR/config.json")
ARCH_COUNT=$(jq -r '.manifests // [] | map(select(.platform.architecture != "unknown")) | map(.platform.architecture) | unique | length' "$TMPDIR/manifest-raw.json")

log "created=$CREATED user=$USER_FIELD labels=$LABELS_COUNT arch_count=$ARCH_COUNT healthcheck=$HEALTHCHECK_PRESENT"

#------------------------------------------------------------------------------
# Generate SBOM with syft and match CVEs with grype
#------------------------------------------------------------------------------
section "generating SBOM with syft"
syft "$SYFT_REF" -o syft-json > "$TMPDIR/sbom.json"

section "matching CVEs with grype"
grype "sbom:$TMPDIR/sbom.json" -o json > "$TMPDIR/grype.json"

GRYPE_DB_IN_USE=$(jq -r '.descriptor.db.version // empty' "$TMPDIR/grype.json" 2>/dev/null || echo 'unknown')
echo "grype DB in use: ${GRYPE_DB_IN_USE:-unknown}" >&2

#------------------------------------------------------------------------------
# CVE counts per severity × fix availability
#------------------------------------------------------------------------------
section "counting CVEs"
cve_count() {
  local sev="$1" fix="$2"
  if [[ "$fix" == "fixed" ]]; then
    jq --arg s "$sev" '[.matches[] | select(.vulnerability.severity == $s) | select(.vulnerability.fix.state == "fixed")] | length' "$TMPDIR/grype.json"
  else
    jq --arg s "$sev" '[.matches[] | select(.vulnerability.severity == $s) | select(.vulnerability.fix.state != "fixed")] | length' "$TMPDIR/grype.json"
  fi
}
CRIT_FIX=$(cve_count Critical fixed)
HIGH_FIX=$(cve_count High fixed)
MED_FIX=$(cve_count Medium fixed)
CRIT_UNFIX=$(cve_count Critical unfixed)
HIGH_UNFIX=$(cve_count High unfixed)

#------------------------------------------------------------------------------
# Supply-chain / cosign signals
#------------------------------------------------------------------------------
COSIGN_SIGNED=false
SLSA_PRESENT=false
SBOM_ATTESTED=false
if [[ $SKIP_COSIGN -eq 0 ]]; then
  section "cosign signals"
  if cosign triangulate --type signature "$SYFT_REF" >/dev/null 2>&1; then
    SIG_REF=$(cosign triangulate --type signature "$SYFT_REF" 2>/dev/null)
    if skopeo inspect "docker://$SIG_REF" >/dev/null 2>&1; then
      COSIGN_SIGNED=true
    fi
  fi
  if cosign download attestation --predicate-type=https://slsa.dev/provenance/v1 "$SYFT_REF" >"$TMPDIR/slsa.jsonl" 2>/dev/null && [[ -s "$TMPDIR/slsa.jsonl" ]]; then
    SLSA_PRESENT=true
  fi
  if cosign download attestation --predicate-type=https://spdx.dev/Document "$SYFT_REF" >"$TMPDIR/spdx.jsonl" 2>/dev/null && [[ -s "$TMPDIR/spdx.jsonl" ]]; then
    SBOM_ATTESTED=true
  fi
fi

#------------------------------------------------------------------------------
# Rebuild freshness
#------------------------------------------------------------------------------
REBUILT_LT_90D=false
if [[ -n "$CREATED" ]]; then
  AGE_SECONDS=$(( $(date +%s) - $(date -d "$CREATED" +%s 2>/dev/null || echo 0) ))
  if [[ $AGE_SECONDS -lt $((90 * 86400)) ]]; then REBUILT_LT_90D=true; fi
fi

#------------------------------------------------------------------------------
# Non-root default
#------------------------------------------------------------------------------
NON_ROOT=false
case "$USER_FIELD" in
  ""|root|0|"0:"*) ;;
  *) NON_ROOT=true ;;
esac

#------------------------------------------------------------------------------
# OCI standard labels (≥ 4 of 5 standard labels present)
#------------------------------------------------------------------------------
OCI_LABELS_OK=false
STANDARD_LABELS_PRESENT=$(jq -r '.config.Labels // {} | with_entries(select(.key | startswith("org.opencontainers.image."))) | with_entries(select(.key as $k | ["org.opencontainers.image.title","org.opencontainers.image.description","org.opencontainers.image.source","org.opencontainers.image.version","org.opencontainers.image.revision"] | index($k))) | length' "$TMPDIR/config.json")
if [[ "$STANDARD_LABELS_PRESENT" -ge 4 ]]; then OCI_LABELS_OK=true; fi

#------------------------------------------------------------------------------
# Apply the rubric
#------------------------------------------------------------------------------
section "applying rubric"
weight() { jq -r --arg id "$1" '(.penalties + .bonuses)[] | select(.id == $id) | (.per_occurrence // .value)' "$RUBRIC_FILE"; }

W_CF=$(weight critical_fixable)
W_HF=$(weight high_fixable)
W_MF=$(weight medium_fixable)
W_CU=$(weight critical_unfixable)
W_HU=$(weight high_unfixable)
W_NR=$(weight non_root_default)
W_FR=$(weight rebuilt_lt_90d)
W_CO=$(weight cosign_signed)
W_SL=$(weight slsa_provenance_attached)
W_HC=$(weight healthcheck_defined)
W_OL=$(weight oci_standard_labels)
W_M3=$(weight multi_arch_3plus)
W_M2=$(weight multi_arch_2)
W_SB=$(weight sbom_attested)

add() { echo "$(( $1 + $2 ))"; }

SCORE=100
BREAKDOWN=()

apply_penalty() {
  local id="$1" cnt="$2" per="$3"
  if [[ "$cnt" -gt 0 ]]; then
    local delta=$(( cnt * per ))
    SCORE=$(( SCORE + delta ))
    BREAKDOWN+=("$id × $cnt → $delta")
  else
    BREAKDOWN+=("$id × 0 → 0")
  fi
}
apply_bonus_bool() {
  local id="$1" present="$2" w="$3"
  if [[ "$present" == "true" ]]; then
    SCORE=$(( SCORE + w ))
    BREAKDOWN+=("$id → +$w")
  else
    BREAKDOWN+=("$id → 0")
  fi
}

apply_penalty critical_fixable   "$CRIT_FIX"   "$W_CF"
apply_penalty high_fixable       "$HIGH_FIX"   "$W_HF"
apply_penalty medium_fixable     "$MED_FIX"    "$W_MF"
apply_penalty critical_unfixable "$CRIT_UNFIX" "$W_CU"
apply_penalty high_unfixable     "$HIGH_UNFIX" "$W_HU"
apply_bonus_bool non_root_default            "$NON_ROOT"           "$W_NR"
apply_bonus_bool rebuilt_lt_90d              "$REBUILT_LT_90D"     "$W_FR"
apply_bonus_bool cosign_signed               "$COSIGN_SIGNED"      "$W_CO"
apply_bonus_bool slsa_provenance_attached    "$SLSA_PRESENT"       "$W_SL"
apply_bonus_bool healthcheck_defined         "$HEALTHCHECK_PRESENT" "$W_HC"
apply_bonus_bool oci_standard_labels         "$OCI_LABELS_OK"      "$W_OL"
if   [[ "$ARCH_COUNT" -ge 3 ]]; then SCORE=$(( SCORE + W_M3 )); BREAKDOWN+=("multi_arch (arch=$ARCH_COUNT) → +$W_M3")
elif [[ "$ARCH_COUNT" -ge 2 ]]; then SCORE=$(( SCORE + W_M2 )); BREAKDOWN+=("multi_arch (arch=$ARCH_COUNT) → +$W_M2")
else BREAKDOWN+=("multi_arch (arch=$ARCH_COUNT) → 0")
fi
apply_bonus_bool sbom_attested               "$SBOM_ATTESTED"      "$W_SB"

# Clamp
[[ $SCORE -lt 0   ]] && SCORE=0
[[ $SCORE -gt 100 ]] && SCORE=100

# Grade
if   [[ $SCORE -ge 85 ]]; then GRADE=A
elif [[ $SCORE -ge 70 ]]; then GRADE=B
elif [[ $SCORE -ge 55 ]]; then GRADE=C
elif [[ $SCORE -ge 40 ]]; then GRADE=D
else                             GRADE=F
fi

#------------------------------------------------------------------------------
# Output
#------------------------------------------------------------------------------
section "result"
echo "image:     $REF"
echo "rubric:    $RUBRIC_LOADED"
echo "grype_db:  ${GRYPE_DB_IN_USE:-unknown}"
echo "score:     $SCORE"
echo "grade:     $GRADE"
echo
echo "breakdown:"
for row in "${BREAKDOWN[@]}"; do echo "  $row"; done

if [[ -n "$OUTPUT_PATH" ]]; then
  jq -n \
    --arg ref "$REF" --arg rubric "$RUBRIC_LOADED" --arg grype_db "${GRYPE_DB_IN_USE:-unknown}" \
    --argjson score "$SCORE" --arg grade "$GRADE" \
    --arg crit_fix "$CRIT_FIX" --arg high_fix "$HIGH_FIX" --arg med_fix "$MED_FIX" \
    --arg crit_unfix "$CRIT_UNFIX" --arg high_unfix "$HIGH_UNFIX" \
    --arg non_root "$NON_ROOT" --arg rebuilt "$REBUILT_LT_90D" \
    --arg cosign "$COSIGN_SIGNED" --arg slsa "$SLSA_PRESENT" \
    --arg healthcheck "$HEALTHCHECK_PRESENT" --arg oci "$OCI_LABELS_OK" \
    --arg arch "$ARCH_COUNT" --arg sbom "$SBOM_ATTESTED" \
    '{ref:$ref, rubric_version:$rubric, grype_db_version:$grype_db, score:$score, grade:$grade, inputs:{crit_fix:($crit_fix|tonumber), high_fix:($high_fix|tonumber), med_fix:($med_fix|tonumber), crit_unfix:($crit_unfix|tonumber), high_unfix:($high_unfix|tonumber), non_root:($non_root=="true"), rebuilt_lt_90d:($rebuilt=="true"), cosign_signed:($cosign=="true"), slsa_present:($slsa=="true"), healthcheck:($healthcheck=="true"), oci_standard_labels:($oci=="true"), arch_count:($arch|tonumber), sbom_attested:($sbom=="true")}}' > "$OUTPUT_PATH"
  echo "written: $OUTPUT_PATH" >&2
fi

if [[ -n "$EXPECTED_PATH" ]]; then
  EXP_GRADE=$(jq -r .grade "$EXPECTED_PATH")
  EXP_SCORE=$(jq -r .score "$EXPECTED_PATH")
  echo
  if [[ "$GRADE" == "$EXP_GRADE" && "$SCORE" == "${EXP_SCORE%.*}" ]]; then
    echo "✓ reproduced ce.rodeo's recorded grade=$EXP_GRADE score=$EXP_SCORE"
  else
    echo "✗ MISMATCH:"
    echo "   expected: grade=$EXP_GRADE score=$EXP_SCORE"
    echo "   got:      grade=$GRADE score=$SCORE"
    echo "   (Different Grype DB vintage is the usual cause — pin with --grype-db.)"
    exit 3
  fi
fi
