#!/usr/bin/env bash
# check-wfx-ci-stealer.sh: strictly read-only, offline check for signs of the wfx GitHub Actions secret stealer
# (November 2026 campaign through hijacked GitHub accounts; no CVE). It reads what you hand it:
#   --repo DIR          a local git clone: branch names and commit subjects (read-only git: rev-parse, for-each-ref, log; with
#                       GIT_OPTIONAL_LOCKS=0, hooks and fsmonitor off, no fetch, no network protocols), plus a file scan of DIR
#   --dir DIR           a folder (checkout, vendor tree, runner work folder, home folder) scanned as files only, no git
#   --entra-json FILE   operator export of Entra app federated identity credentials (az ad app federated-credential list,
#                       or Graph output): flags names starting wfx-ci-
#   --aws-json FILE     operator export of IAM roles (aws iam get-role or list-roles): flags trust policies open to any
#                       AWS account, and GitHub OIDC trust with no sub condition
#   --extra-sha256 HEX  an extra SHA-256 to look for (repeatable, up to 20)
#   --extra-ip IP       an extra IPv4 address to look for in scanned files (repeatable, up to 20)
#   --name NAME         label for the output (default: input)
# Nothing is fetched, written or run apart from the read-only git commands. No file content or secret is ever printed:
# only paths, ref names, commit hashes, versions and credential or role names.
# Verdicts: INDICATORS FOUND, REVIEW, NO INDICATORS FOUND, UNKNOWN.
# Exit: 0 NO INDICATORS FOUND; 1 INDICATORS FOUND; 5 REVIEW; 3 UNKNOWN; 4 usage or read error.
set -uf
export LC_ALL=C

usage() {
  cat >&2 <<'U'
Usage: check-wfx-ci-stealer.sh [--repo DIR] [--dir DIR] [--entra-json FILE] [--aws-json FILE] [--extra-sha256 HEX]... [--extra-ip IP]... [--name NAME]
  --repo          local git clone (read-only git: refs and commit subjects) plus a file scan
  --dir           folder scanned as files only (no git)
  --entra-json    exported Entra federated identity credentials (JSON)
  --aws-json      exported IAM roles with trust policies (JSON)
  --extra-sha256  extra SHA-256 to look for (repeatable)
  --extra-ip      extra IPv4 address to look for (repeatable)
  --name          label for the output (default: input)
At least one of --repo, --dir, --entra-json or --aws-json is needed. Read-only: no network, no writes.
Exit: 0 NO INDICATORS FOUND; 1 INDICATORS FOUND; 5 REVIEW; 3 UNKNOWN; 4 usage or read error
U
  exit 4
}
die() { echo "ERROR: $1" >&2; exit 4; }
CLEAN_AWK='function clean(v) { return substr(cleanall(v), 1, 120) }
  function cleanall(v,   o, i, c) {
    o = ""
    for (i = 1; i <= length(v); i++) {
      c = substr(v, i, 1)
      if (c ~ /[A-Za-z0-9 ._:@\/+=,()?%~#-]/) o = o c
      else if (c >= "\200" && c <= "\277") continue
      else o = o "?"
    }
    return o
  }'
clean() { printf '%s' "$1" | awk -v RS='\001' "$CLEAN_AWK"' { printf "%s", clean($0) }'; }
readable() { [ -f "$1" ] && [ -r "$1" ]; }

# Indicators. THN: published research, as reported by The Hacker News (12 Nov 2026).
# RESEARCH: research-only, not on the THN page. OURS: CybaVerse check of public registry data (12 Nov 2026).
EXFIL_IPS="194.150.166.244 18.143.168.96 153.75.82.159"
HASHES="89695abb392d5cfd4972672961e644bb46baa98aa1ed7173c2a2816c762735cf:THN:stealer script
6960931e69bbcd70f9d0727c536251fa94167d0b8a988d12c5efe45e54b1d389:RESEARCH:.ci-e5infq.sh in neuronai-studio 3.14.3
91c82e1b370bfa37e66fcac423fc52939d87e9a987d85c61f1ef3f099fee250d:OURS:.ci-uxe0r4.sh in neuronai-studio 3.14.4
a45d3ced951d2deaaa76e39c8d20c78cfcb0092b9b6bde2fb8347c98caad2d54:RESEARCH:beta.yml in docker-env/redock v1.0.278
73cec6bc7d2306dcf7087c445bc2f57029a1b74ae24b076605e66b1123a7ddee:RESEARCH:beta.yml in docker-env/redock v1.0.280
7128295ffbb8470d62932294e859c1208619a9ef34fe92609b4fcd478bbbcb59:RESEARCH:beta.yml in docker-env/redock v1.0.282
63046ae820408da91a18d1b4b53bad8c78f244a266800dccb643658fb4ef0a78:OURS:beta.yml in docker-env/redock v1.0.284"

repo="" dir="" entra="" aws="" name="input" seen=" " xh="" xi="" nxh=0 nxi=0
while [ $# -gt 0 ]; do
  k=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
  case $k in --*) ;; -*) k="-$k";; esac
  case $k in
    --repo|--dir|--entra-json|--aws-json|--name|--extra-sha256|--extra-ip) ;;
    *) usage;;
  esac
  [ $# -ge 2 ] || usage
  case $k in
    --extra-sha256)
      v=$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]')
      printf '%s' "$v" | grep -Eq '^[0-9a-f]{64}$' || die "--extra-sha256 takes 64 hex characters"
      nxh=$((nxh + 1)); [ "$nxh" -le 20 ] || die "at most 20 --extra-sha256 values"
      xh="$xh$v:OPERATOR:extra hash you supplied
";;
    --extra-ip)
      printf '%s' "$2" | grep -Eq '^([0-9]{1,3}\.){3}[0-9]{1,3}$' || die "--extra-ip takes an IPv4 address"
      nxi=$((nxi + 1)); [ "$nxi" -le 20 ] || die "at most 20 --extra-ip values"
      xi="$xi $2";;
    *)
      case $seen in *" $k "*) usage;; esac
      seen="$seen$k "
      case $k in
        --repo) repo=$2;; --dir) dir=$2;; --entra-json) entra=$2;; --aws-json) aws=$2;; --name) name=$2;;
      esac;;
  esac
  shift 2
done
[ -n "$repo$dir$entra$aws" ] || usage
for d in "$repo" "$dir"; do [ -z "$d" ] || { [ -d "$d" ] && [ -r "$d" ] && [ -x "$d" ]; } || die "cannot read folder: $(clean "$d")"; done
for f in "$entra" "$aws"; do [ -z "$f" ] || readable "$f" || die "cannot read file: $(clean "$f")"; done
name=$(clean "$name")
HASHES="$HASHES
$xh"

found="" review="" notes="" scanned=0 ceiling=/
add() { case $1 in F) found="$found$2
";; R) review="$review$2
";; N) notes="$notes$2
";; esac; }

# Git: read-only commands only. Optional locks off, hooks and fsmonitor off, network protocols refused, no pager.
# GIT_CEILING_DIRECTORIES stops git from walking up into a parent repository when --repo is not one itself.
gitro() {
  GIT_CEILING_DIRECTORIES=$ceiling GIT_OPTIONAL_LOCKS=0 GIT_TERMINAL_PROMPT=0 GIT_CONFIG_NOSYSTEM=1 git --no-pager -c core.fsmonitor=false \
    -c core.hooksPath=/dev/null -c protocol.allow=never -C "$repo" "$@" 2>/dev/null
}
scan_git() {
  local refs subj n hs s lab
  if ! command -v git >/dev/null 2>&1; then add N "git is not installed; branch and commit checks skipped"; return; fi
  ceiling=$(cd "$repo" 2>/dev/null && pwd -P); ceiling=${ceiling%/*}; [ -n "$ceiling" ] || ceiling=/
  if ! gitro rev-parse --git-dir >/dev/null; then add N "git could not read the --repo folder (not a repository, or git refused it); branch and commit checks skipped"; return; fi
  scanned=1
  refs=$(gitro for-each-ref --format='%(refname)' | awk '
    /^refs\/heads\// { print substr($0, 12); next }
    /^refs\/remotes\// { print substr($0, 14); next }')
  while IFS= read -r r; do
    [ -n "$r" ] || continue
    lab=$(clean "$r")
    case $r in
      wfx-docs|*/wfx-docs) add F "branch $lab (probe branch wfx-docs) [THN]";;
      *) if printf '%s\n' "$r" | grep -Eq '(^|/)chore/[a-z0-9]{8}$'; then add R "branch $lab (matches chore/[a-z0-9]{8}; legitimate names can match) [THN]"
         elif printf '%s\n' "$r" | grep -Eq '(^|/)ci-check-[0-9]{4}$'; then add R "branch $lab (matches ci-check-[0-9]{4}; also seen in the October wave) [THN]"; fi;;
    esac
  done <<< "$(printf '%s\n' "$refs" | sort -u)"
  subj=$( { gitro log --all --no-color --format='%H%x09%s'; gitro log -g --all --no-color --format='%H%x09%s'; } | sort -u)
  for s in 'ci: refresh build cache|F' 'chore: add ci helper|F' 'chore: add dependency audit workflow|F' 'docs: dependency audit notes|F' 'ci: restore|R' 'ci: cleanup|R' 'chore: initial commit|R'; do
    lab=${s%|*}
    hs=$(printf '%s\n' "$subj" | awk -F'\t' -v m="$lab" '{ t = $2; sub(/[ \t\r]+$/, "", t) } t == m { print substr($1, 1, 12) }' | sort -u)
    [ -n "$hs" ] || continue
    n=$(printf '%s\n' "$hs" | wc -l | tr -d ' ')
    hs=$(printf '%s\n' "$hs" | head -n 3 | paste -sd ' ' -)
    if [ "${s#*|}" = F ]; then add F "commit subject '$lab' x$n ($hs) [THN]"
    else add R "commit subject '$lab' x$n ($hs) (generic wording; check the diff) [THN]"; fi
  done
}

hashof() { sha256sum < "$1" 2>/dev/null | awk '{ print $1 }'; }
scan_dir() { # root
  local root=$1 rel f bn h hit ip sz v
  scanned=1
  while IFS= read -r -d '' f; do
    rel=${f#"$root"}; rel=${rel#/}; bn=${f##*/}
    rel=$(clean "$rel")
    if printf '%s\n' "$rel" | grep -Eq '(^|/)\.github/workflows/build-audit-[a-z0-9]{6}\.ya?ml$'; then add F "file $rel (build-audit-xxxxxx workflow) [THN]"; fi
    if printf '%s\n' "$bn" | grep -Eq '^\.ci-[a-z0-9]{6}\.sh$'; then add F "file $rel (.ci-xxxxxx.sh helper script) [THN]"; fi
    case $rel in docs/wfx-audit.md|*/docs/wfx-audit.md) add F "file $rel (probe pull request file) [RESEARCH]";; esac
    case $bn in
      package.json)
        if grep -Eq '"postinstall"[[:space:]]*:[[:space:]]*"[^"]*\.ci-[a-z0-9]{6}\.sh' "$f" 2>/dev/null; then add F "file $rel has a postinstall hook that runs a .ci-xxxxxx.sh script [THN]"; fi;;
      composer.lock)
        v=$(awk '/"name"[[:space:]]*:[[:space:]]*"digitalelvis\/neuronai-studio"/ { p = 1; next }
                 p && /"version"[[:space:]]*:/ { s = $0; sub(/.*"version"[[:space:]]*:[[:space:]]*"/, "", s); sub(/".*/, "", s); print s; p = 0 }
                 p && /"name"[[:space:]]*:/ { p = 0 }' "$f" 2>/dev/null | sed 's/^v//' | sort -u)
        for x in $v; do
          case $x in
            3.14.3) add F "file $rel locks digitalelvis/neuronai-studio 3.14.3 (malicious release; pin 3.14.1 or earlier) [THN]";;
            3.14.2) add F "file $rel locks digitalelvis/neuronai-studio 3.14.2 (flagged as malware by Packagist; October wave files; pin 3.14.1 or earlier) [OURS]";;
            3.14.4) add F "file $rel locks digitalelvis/neuronai-studio 3.14.4 (released 12 Nov 07:50 UTC with a .ci-xxxxxx.sh postinstall stealer; pin 3.14.1 or earlier) [OURS]";;
          esac
        done;;
      go.sum|go.mod)
        for x in 'onuragtas/docker-env v1.0.278|THN' 'onuragtas/docker-env v1.0.280|THN' 'onuragtas/docker-env v1.0.282|RESEARCH' 'onuragtas/redock v1.0.278|RESEARCH' 'onuragtas/redock v1.0.280|RESEARCH' 'onuragtas/redock v1.0.282|RESEARCH' 'onuragtas/docker-env v1.0.284|OURS' 'onuragtas/redock v1.0.284|OURS' 'vkuznet/gocurl v0.0.0-20261111095532-8d5aad0cb0da|THN'; do
          v=${x%|*}
          if grep -Eq "(^|[[:space:]])github\.com/${v%% *}[[:space:]]+${v#* }(/go\.mod)?([[:space:]]|$)" "$f" 2>/dev/null; then
            add R "file $rel references github.com/$v (payload is in a workflow file only; Go builds do not run it; remove this version) [${x#*|}]"
          fi
        done;;
    esac
    case $bn in
      *.yml|*.yaml|*.sh|package.json)
        if grep -Eq 'WF_CALLBACK|WF_CTOKEN|WF_TG_CHAT' "$f" 2>/dev/null; then add F "file $rel contains wfx payload settings (WF_CALLBACK, WF_CTOKEN or WF_TG_CHAT) [RESEARCH]"; fi
        if grep -Fq -e '-1004340163187' "$f" 2>/dev/null; then add F "file $rel contains the wfx Telegram chat ID [RESEARCH]"; fi
        for ip in $EXFIL_IPS $xi; do
          if grep -Fq -e "$ip" "$f" 2>/dev/null; then
            case " $EXFIL_IPS " in *" $ip "*) add F "file $rel contains exfil address $ip [THN]";; *) add F "file $rel contains extra address $ip [OPERATOR]";; esac
          fi
        done
        sz=$(wc -c < "$f" 2>/dev/null | tr -d ' ')
        if [ -n "$sz" ] && [ "$sz" -le 4194304 ]; then
          h=$(hashof "$f")
          if [ -n "$h" ]; then
            hit=$(printf '%s\n' "$HASHES" | awk -F: -v h="$h" '$1 == h { print $2 "|" $3; exit }')
            if [ -n "$hit" ]; then add F "file $rel has SHA-256 ${h:0:16}... (${hit#*|}) [${hit%%|*}]"; fi
          fi
        fi;;
    esac
  done < <(find "$root" -maxdepth 12 -name .git -prune -o -type f \( -name '*.yml' -o -name '*.yaml' -o -name '*.sh' -o -name package.json -o -name composer.lock -o -name go.sum -o -name go.mod -o -name wfx-audit.md \) -print0 2>/dev/null | sort -z)
}

scan_entra() {
  local names n
  scanned=1
  names=$(tr -d '\r\n' < "$entra" | grep -oE '"name"[[:space:]]*:[[:space:]]*"wfx-ci-[^"]{0,64}"' | sed -E 's/^"name"[[:space:]]*:[[:space:]]*"//; s/"$//' | sort -u)
  while IFS= read -r n; do [ -n "$n" ] && add F "Entra federated identity credential $(clean "$n") (wfx-ci- prefix) [THN]"; done <<< "$names"
  n=$(tr -d '\r\n' < "$entra" | grep -oE '"issuer"[[:space:]]*:[[:space:]]*"https://token\.actions\.githubusercontent\.com/?"' | wc -l | tr -d ' ')
  [ "$n" -gt 0 ] && add N "$n federated identity credentials in the Entra export trust the GitHub OIDC issuer; confirm each one is yours and scoped to one repository and branch (OUR ADVICE)"
  if [ "$n" -eq 0 ] && ! tr -d '\r\n' < "$entra" | grep -Eq '"name"[[:space:]]*:'; then add N "no federated identity credential objects found in the Entra export"; fi
}

scan_aws() {
  local out
  scanned=1
  if grep -q '%22Principal%22' "$aws" 2>/dev/null; then add R "the AWS export holds URL-encoded policies that were not checked; decode them first (aws iam get-role prints them decoded)"; fi
  out=$(tr -d '\r\n' < "$aws" | awk '{ s = s $0 } END {
      n = split(s, seg, /"RoleName"/)
      for (i = 1; i <= n; i++) {
        t = seg[i]; role = "(no RoleName in the file)"
        if (i > 1) { r = t; if (match(r, /^[ \t]*:[ \t]*"[^"]*"/)) { r = substr(r, RSTART, RLENGTH); sub(/^[ \t]*:[ \t]*"/, "", r); sub(/"$/, "", r); role = "role " r } }
        if (t ~ /"Principal"[ \t]*:[ \t]*"\*"/ || t ~ /"AWS"[ \t]*:[ \t]*"\*"/ || t ~ /"AWS"[ \t]*:[ \t]*\[[^]]*"\*"/) print "F\t" role
        if (t ~ /token\.actions\.githubusercontent\.com/ && t !~ /token\.actions\.githubusercontent\.com:sub/) print "R\t" role
      }
    }')
  while IFS=$'\t' read -r k r; do
    [ -n "$k" ] || continue
    if [ "$k" = F ]; then add F "AWS $(clean "$r"): trust policy allows any AWS account (Principal \"*\"); check for an unexpected UpdateAssumeRolePolicy [THN]"
    else add R "AWS $(clean "$r"): trusts the GitHub OIDC issuer with no sub condition, so any repository can assume it (OUR ADVICE: scope to repo and branch)"; fi
  done <<< "$out"
}

[ -z "$repo" ] || { scan_git; scan_dir "$repo"; }
[ -z "$dir" ] || scan_dir "$dir"
[ -z "$entra" ] || scan_entra
[ -z "$aws" ] || scan_aws

echo "check-wfx-ci-stealer: offline, read-only check for the wfx GitHub Actions secret stealer (November 2026, no CVE)."
echo "SOURCES: [THN] published research, as reported by The Hacker News (12 Nov 2026); [RESEARCH ONLY, not on the THN page] published research that the THN page does not carry; [OURS] CybaVerse check of public registry data (12 Nov 2026); [OPERATOR] values you supplied."
emit() { printf '%s' "$2" | awk 'NF && !s[$0]++' | sed 's/\[RESEARCH\]$/[RESEARCH ONLY, not on the THN page]/' | while IFS= read -r l; do echo "$1: $name: $l"; done; }
emit FOUND "$found"
emit REVIEW "$review"
emit NOTE "$notes"
if [ -n "$found" ]; then
  echo "VERDICT: $name: INDICATORS FOUND: treat every secret, variable and cloud role this repository's workflows could reach as stolen, and the account that pushed the commits as compromised."
  echo "NEXT: $name: revoke the pushing account's tokens, OAuth grants, SSH keys and sessions and turn on 2FA; rotate all repository, environment and organization secrets; check Entra, AWS, GCP and Vault for the backdoors in the README; look for a missing workflow run number after the attacker's commit."
  echo "RESULT: INDICATORS FOUND"; exit 1
elif [ -n "$review" ]; then
  echo "VERDICT: $name: REVIEW: weak or pattern matches only; check each one by hand (diff, author, time from 10 Nov 2026)."
  echo "RESULT: REVIEW"; exit 5
elif [ "$scanned" = 1 ]; then
  echo "VERDICT: $name: NO INDICATORS FOUND in what was scanned. This is not proof: the attacker reverts commits and deletes workflow runs, and branches or runs that were never fetched are not visible here."
  echo "RESULT: NO INDICATORS FOUND"; exit 0
else
  echo "VERDICT: $name: UNKNOWN: nothing could be scanned."
  echo "RESULT: UNKNOWN"; exit 3
fi
