#!/usr/bin/env bash
# drone-scan — offline Syft / Grype / Trivy wrapper for Drone pipelines.
#
#   drone-scan info                          tool versions + vulnerability data dates
#   drone-scan db-check                      fail if any DB is older than MAX_DB_AGE_DAYS
#   drone-scan sbom   <dir|image.tar> <out>  Syft SBOMs (syft-json, CycloneDX, SPDX)
#   drone-scan grype  <out>                  Grype scan of <out>/sbom.syft.json
#   drone-scan trivy-fs    <dir> <out>       Trivy vulnerabilities + secrets + misconfig
#   drone-scan trivy-image <image.tar> <out> Trivy scan of a docker/OCI image archive
#   drone-scan gate   <out>                  summarise every report in <out>; exit 1 on
#                                            blocking findings
#   drone-scan archive <out> <file.tar.gz>   bundle reports + SHA256SUMS as evidence
#   drone-scan smoke                         offline self-test (expects a vulnerable
#                                            fixture to FAIL and a clean one to PASS)
#
# Scan commands exit non-zero only when a scanner itself fails (bad input,
# missing/stale DB). Findings are judged once, by `gate`, so every scanner
# always runs and the build log shows the complete picture.
#
# Gate policy (environment):
#   SCAN_FAIL_ON=high            vulnerability threshold: critical|high|medium|low|none
#   SCAN_MISCONFIG_FAIL_ON=high  misconfiguration threshold (same values)
#   SCAN_SECRETS_FAIL=true       any detected secret blocks
#   SCAN_ONLY_FIXED=false        true: vulnerabilities without a fix do not block
#   SCAN_GRYPE_CONFIG / SCAN_TRIVYIGNORE   exception files (default: the
#                                admin-managed ones in /etc/devsecops)
# Repository-provided .grype.yaml, .syft.yaml, trivy.yaml and .trivyignore
# are deliberately NOT honoured: scanners run from a neutral directory with
# explicit config paths.
set -euo pipefail

CONF=/etc/devsecops
MAX_DB_AGE_DAYS="${MAX_DB_AGE_DAYS:-7}"
SCAN_FAIL_ON="${SCAN_FAIL_ON:-high}"
SCAN_MISCONFIG_FAIL_ON="${SCAN_MISCONFIG_FAIL_ON:-high}"
SCAN_SECRETS_FAIL="${SCAN_SECRETS_FAIL:-true}"
SCAN_ONLY_FIXED="${SCAN_ONLY_FIXED:-false}"
SCAN_GRYPE_CONFIG="${SCAN_GRYPE_CONFIG:-$CONF/grype.yaml}"
SCAN_TRIVYIGNORE="${SCAN_TRIVYIGNORE:-$CONF/trivyignore}"
TRIVY_CACHE_DIR="${TRIVY_CACHE_DIR:-/opt/scanners/trivy}"

die()  { printf 'drone-scan: ERROR: %s\n' "$*" >&2; exit 2; }
hdr()  { printf '\n==== %s ====\n' "$*"; }

# Offline flags passed on every Trivy invocation, regardless of environment.
TRIVY_OFFLINE=(--cache-dir "$TRIVY_CACHE_DIR" --skip-db-update --skip-java-db-update
               --skip-check-update --offline-scan --skip-version-check
               --disable-telemetry --no-progress)

# Run scanners from an empty directory so repo-level config files are ignored.
neutral_cwd() { local d; d="$(mktemp -d)"; cd "$d"; }

rank() {  # severity name -> number (case-insensitive)
  case "${1^^}" in
    CRITICAL) echo 4 ;; HIGH) echo 3 ;; MEDIUM) echo 2 ;; LOW) echo 1 ;;
    NONE) echo 99 ;; *) echo 0 ;;
  esac
}

epoch() { date -u -d "$1" +%s; }

# ---------------------------------------------------------------------------
grype_db_status() { (neutral_cwd; grype db status -c "$SCAN_GRYPE_CONFIG" -o json); }

cmd_info() {
  hdr "tools"
  syft version  | grep -E '^(Application|Version)'
  grype version | grep -E '^(Application|Version)'
  trivy version --cache-dir "$TRIVY_CACHE_DIR" 2>/dev/null
  hdr "vulnerability data"
  grype_db_status | jq -r '"grype db: schema \(.schemaVersion) built \(.built) valid=\(.valid)"'
  printf 'trivy db:      updated %s\n' "$(jq -r .UpdatedAt "$TRIVY_CACHE_DIR/db/metadata.json")"
  printf 'trivy java db: updated %s\n' "$(jq -r .UpdatedAt "$TRIVY_CACHE_DIR/java-db/metadata.json")"
  printf 'trivy checks:  %s\n' "$(jq -r .Digest "$TRIVY_CACHE_DIR/policy/metadata.json")"
  printf 'max accepted age: %s days\n' "$MAX_DB_AGE_DAYS"
}

cmd_db_check() {
  local now rc=0 name ts age
  now="$(date -u +%s)"
  while IFS='|' read -r name ts; do
    [[ -n "$ts" && "$ts" != null ]] || { echo "FAIL $name: no build date"; rc=1; continue; }
    age=$(( (now - $(epoch "$ts")) / 86400 ))
    if (( age > MAX_DB_AGE_DAYS )); then
      echo "FAIL $name: $age days old (built $ts, limit $MAX_DB_AGE_DAYS)"; rc=1
    else
      echo "ok   $name: $age days old (built $ts)"
    fi
  done < <(
    printf 'grype-db|%s\n'      "$(grype_db_status | jq -r .built)"
    printf 'trivy-db|%s\n'      "$(jq -r .UpdatedAt "$TRIVY_CACHE_DIR/db/metadata.json")"
    printf 'trivy-java-db|%s\n' "$(jq -r .UpdatedAt "$TRIVY_CACHE_DIR/java-db/metadata.json")"
  )
  (( rc == 0 )) || echo "Vulnerability data is stale: rebuild the scanner image (GUIDE.md §7)."
  return $rc
}

cmd_sbom() {
  local target="${1:?target}" out="${2:?output dir}" src
  target="$(realpath "$target")"; mkdir -p "$out"; out="$(realpath "$out")"
  if [[ -d "$target" ]]; then src="dir:$target"; else src="$target"; fi
  hdr "syft $src"
  neutral_cwd
  syft scan "$src" -c "$CONF/syft.yaml" -q \
    --exclude './.git/**' \
    -o "syft-json=$out/sbom.syft.json" \
    -o "cyclonedx-json=$out/sbom.cdx.json" \
    -o "spdx-json=$out/sbom.spdx.json"
  jq -r '"packages catalogued: \(.artifacts | length)"' "$out/sbom.syft.json"
  jq -r '.artifacts | group_by(.type) | map("  \(.[0].type): \(length)") | .[]' "$out/sbom.syft.json"
}

cmd_grype() {
  local out="${1:?output dir}"
  out="$(realpath "$out")"
  [[ -s "$out/sbom.syft.json" ]] || die "$out/sbom.syft.json missing — run 'drone-scan sbom' first"
  hdr "grype sbom:$out/sbom.syft.json"
  neutral_cwd
  grype "sbom:$out/sbom.syft.json" -c "$SCAN_GRYPE_CONFIG" -q \
    -o table -o "json=$out/grype.json" -o "sarif=$out/grype.sarif"
}

trivy_report() {  # JSON report -> table on stdout + SARIF next to it
  local json="$1"
  trivy convert --quiet --format table "$json"
  trivy convert --quiet --format sarif --output "${json%.json}.sarif" "$json"
}

cmd_trivy_fs() {
  local dir="${1:?directory}" out="${2:?output dir}"
  dir="$(realpath "$dir")"; mkdir -p "$out"; out="$(realpath "$out")"
  hdr "trivy fs $dir"
  neutral_cwd
  trivy fs "${TRIVY_OFFLINE[@]}" --config "$CONF/trivy.yaml" --ignorefile "$SCAN_TRIVYIGNORE" \
    --scanners vuln,secret,misconfig --skip-dirs .git \
    --format json --output "$out/trivy-fs.json" "$dir"
  trivy_report "$out/trivy-fs.json"
}

cmd_trivy_image() {
  local archive="${1:?image archive}" out="${2:?output dir}" name report
  archive="$(realpath "$archive")"; mkdir -p "$out"; out="$(realpath "$out")"
  # Report name carries the archive name (dist/app.tar -> trivy-image-app.json)
  # so several images can be scanned into one report directory.
  name="$(basename "${archive%.tar}")"
  [[ "$name" == image ]] && report=trivy-image || report="trivy-image-$name"
  hdr "trivy image --input $archive"
  neutral_cwd
  trivy image "${TRIVY_OFFLINE[@]}" --config "$CONF/trivy.yaml" --ignorefile "$SCAN_TRIVYIGNORE" \
    --scanners vuln,secret --format json --output "$out/$report.json" --input "$archive"
  trivy_report "$out/$report.json"
}

# ---------------------------------------------------------------------------
# gate: normalise every report into one TSV, then apply the policy.
#   kind  severity  id  package  installed  fixed  target
# ---------------------------------------------------------------------------
cmd_gate() {
  local out="${1:?output dir}" rows vt mt only_fixed f blocking
  out="$(realpath "$out")"
  rows="$(mktemp)"

  # Reports may sit in sub-directories (e.g. <out>/image/grype.json); the
  # source label carries that prefix so findings stay attributable.
  local found=0 label
  while IFS= read -r -d '' f; do
    found=1
    label="$(realpath --relative-to="$out" "$(dirname "$f")")"
    if [[ "$label" == . ]]; then label=grype; else label="$label/grype"; fi
    jq -r --arg src "$label" '.matches[] | ["vuln:\($src)", (.vulnerability.severity|ascii_upcase), .vulnerability.id,
            .artifact.name, .artifact.version,
            ((.vulnerability.fix.versions // []) | join(",")), (.artifact.locations[0].path // "-")] | @tsv' \
      "$f" >> "$rows"
  done < <(find "$out" -name grype.json -print0 | sort -z)
  while IFS= read -r -d '' f; do
    found=1
    jq -r --arg src "$(basename "$f" .json)" '.Results[]? as $r |
      ( $r.Vulnerabilities[]? | ["vuln:\($src)", .Severity, .VulnerabilityID, .PkgName,
          .InstalledVersion, (.FixedVersion // ""), $r.Target] ),
      ( $r.Misconfigurations[]? | select(.Status=="FAIL") |
          ["misconfig:\($src)", .Severity, .ID, .Title, "-", "", $r.Target] ),
      ( $r.Secrets[]? | ["secret:\($src)", .Severity, .RuleID, .Title, "line \(.StartLine)", "", $r.Target] )
      | @tsv' "$f" >> "$rows"
  done < <(find "$out" -name 'trivy-*.json' -print0 | sort -z)
  (( found )) || die "no reports found in $out"

  vt="$(rank "$SCAN_FAIL_ON")"; mt="$(rank "$SCAN_MISCONFIG_FAIL_ON")"
  only_fixed=0; [[ "$SCAN_ONLY_FIXED" == true ]] && only_fixed=1
  local secrets=0; [[ "$SCAN_SECRETS_FAIL" == true ]] && secrets=1

  hdr "summary (rows per source and severity)"
  awk -F'\t' '{c[$1"\t"$2]++} END {for (k in c) print c[k]"\t"k}' "$rows" \
    | sort -t$'\t' -k2,2 -k3,3 | awk -F'\t' '{printf "  %-40s %-9s %5d\n", $2, $3, $1}'
  [[ -s "$rows" ]] || echo "  (no findings)"

  blocking="$(mktemp)"
  awk -F'\t' -v vt="$vt" -v mt="$mt" -v of="$only_fixed" -v sec="$secrets" '
    function rank(s) { return s=="CRITICAL"?4 : s=="HIGH"?3 : s=="MEDIUM"?2 : s=="LOW"?1 : 0 }
    $1 ~ /^vuln:/      && rank($2) >= vt && !(of && $6=="") { print; next }
    $1 ~ /^misconfig:/ && rank($2) >= mt                    { print; next }
    $1 ~ /^secret:/    && sec                               { print }
  ' "$rows" | sort -u > "$blocking"

  printf '\npolicy: vulnerabilities>=%s%s, misconfig>=%s, secrets=%s\n' \
    "$SCAN_FAIL_ON" "$([[ $only_fixed == 1 ]] && echo ' (fixable only)')" \
    "$SCAN_MISCONFIG_FAIL_ON" "$SCAN_SECRETS_FAIL"
  cp "$rows" "$out/findings.tsv"; cp "$blocking" "$out/blocking.tsv"

  if [[ -s "$blocking" ]]; then
    hdr "BLOCKING findings ($(wc -l < "$blocking"))"
    awk -F'\t' '{printf "  %-36s %-9s %-20s %s %s -> %s (%s)\n", $1, $2, $3, $4, $5, ($6==""?"no fix":$6), $7}' \
      "$blocking" | head -n 200
    (( $(wc -l < "$blocking") > 200 )) && echo "  ... full list in $out/blocking.tsv"
    echo; echo "SECURITY GATE: FAIL"
    return 1
  fi
  echo; echo "SECURITY GATE: PASS"
}

cmd_archive() {
  local out="${1:?output dir}" dest="${2:?archive path}"
  out="$(realpath "$out")"
  (cd "$out" && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS)
  tar -czf "$dest" -C "$out" .
  sha256sum "$dest"
}

# ---------------------------------------------------------------------------
cmd_smoke() {
  local work vuln clean rc
  work="$(mktemp -d)"; vuln="$work/vulnerable"; clean="$work/clean"
  mkdir -p "$vuln" "$clean"
  # Known-vulnerable Python pins, a root-user Dockerfile and a synthetic
  # GitHub token assembled at runtime (no literal secret stored in the image).
  printf 'Django==3.2.0\nrequests==2.19.1\nPyYAML==5.3\n' > "$vuln/requirements.txt"
  printf 'FROM registry.access.redhat.com/ubi9/ubi-minimal\nRUN echo hi\n' > "$vuln/Dockerfile"
  printf 'token = "%s%s"\n' "ghp_" "$(printf 'x7Kq2%.0s' 1 2 3 4 5 6 7)Zp" > "$vuln/settings.py"
  printf '#!/bin/sh\necho hello\n' > "$clean/hello.sh"

  cmd_db_check
  ( cmd_sbom "$vuln" "$work/r-vuln" && cmd_grype "$work/r-vuln" && cmd_trivy_fs "$vuln" "$work/r-vuln" )
  rc=0; ( cmd_gate "$work/r-vuln" ) || rc=$?
  (( rc == 1 )) || die "smoke: vulnerable fixture should FAIL the gate (rc=$rc)"
  grep -q 'secret:' "$work/r-vuln/blocking.tsv"    || die "smoke: secret not detected"
  grep -q 'misconfig:' "$work/r-vuln/blocking.tsv" || die "smoke: Dockerfile misconfiguration not detected"
  grep -q $'vuln:grype\t' "$work/r-vuln/blocking.tsv" || die "smoke: grype found no blocking vulnerability"
  grep -q $'vuln:trivy-fs\t' "$work/r-vuln/blocking.tsv" || die "smoke: trivy found no blocking vulnerability"

  ( cmd_sbom "$clean" "$work/r-clean" && cmd_grype "$work/r-clean" && cmd_trivy_fs "$clean" "$work/r-clean" )
  cmd_gate "$work/r-clean" || die "smoke: clean fixture should PASS the gate"
  rm -rf "$work"
  echo; echo "SMOKE TEST: PASS (vulnerable fixture blocked, clean fixture passed)"
}

sub="${1:-}"; shift || true
case "$sub" in
  info) cmd_info ;;
  db-check) cmd_db_check ;;
  sbom) cmd_sbom "$@" ;;
  grype) cmd_grype "$@" ;;
  trivy-fs) cmd_trivy_fs "$@" ;;
  trivy-image) cmd_trivy_image "$@" ;;
  gate) cmd_gate "$@" ;;
  archive) cmd_archive "$@" ;;
  smoke) cmd_smoke ;;
  *) sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; [[ -z "$sub" || "$sub" == help || "$sub" == -h ]] || exit 2 ;;
esac
