diff --git a/.gitignore b/.gitignore index 639b256..bf2b576 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ # Agent spec isn't directly relevant to code AGENTS.md +.clang-format diff --git a/isthislegit b/isthislegit index 8b24126..0d1efa9 100755 --- a/isthislegit +++ b/isthislegit @@ -9,11 +9,16 @@ # # Requires: curl, openssl, jq, dig (plus standard GNU coreutils: date, grep, sed) # Target shell: GNU Bash (uses arrays, [[ ]] — not strict POSIX sh). +# Code style: LLVM-derived (see .clang-format) — 4-space indents, no tabs, +# same-line opening braces, lone `else`/`elif` lines. set -u VERSION="1.0.0" +# ===================================================================== +# Global state +# ===================================================================== VERBOSE=0 JSON_OUT=0 LEGIT_DOMAIN="" @@ -32,10 +37,13 @@ cleanup() { } trap cleanup EXIT INT TERM +# ===================================================================== +# Usage banner +# ===================================================================== usage() { cat < @@ -51,6 +59,9 @@ Examples: EOF } +# ===================================================================== +# Environment and input validation +# ===================================================================== check_dependencies() { local deps=("curl" "openssl" "jq" "dig") local missing=0 @@ -95,6 +106,9 @@ check_dns() { fi } +# ===================================================================== +# TLS certificate collectors (all backed by one cached PEM per domain) +# ===================================================================== # Fetch the leaf cert PEM once per domain; cache to tmpdir. # Returns path to PEM on stdout; empty string on failure (port 443 closed etc). fetch_cert_pem() { @@ -201,6 +215,9 @@ get_cert_class() { fi } +# ===================================================================== +# RDAP registration collectors (all backed by one cached JSON per domain) +# ===================================================================== # Query RDAP once per domain; cache raw JSON to tmpdir. rdap_query() { local domain="$1" @@ -280,111 +297,159 @@ json_escape() { printf '%s' "$s" } -# ---- argument parsing ---- -while [[ $# -gt 0 ]]; do - case "$1" in - -v|--verbose) VERBOSE=1; shift ;; - -j|--json) JSON_OUT=1; shift ;; - -h|--help) usage; exit 0 ;; - --) shift; break ;; - -*) echo "[!] Error: Unknown option '$1'. Use -h for help." >&2; exit 1 ;; - *) break ;; - esac -done +# ===================================================================== +# CLI front-end: argument parsing and input validation +# ===================================================================== -if [[ $# -ne 2 ]]; then - usage >&2 - echo "" >&2 - echo "[!] Error: Expected exactly 2 domains: ." >&2 - exit 1 -fi +# Parse flags and positional domains; sets VERBOSE, JSON_OUT, +# LEGIT_DOMAIN and SUSPECT_DOMAIN globals. Exits on misuse. +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + -v|--verbose) VERBOSE=1; shift ;; + -j|--json) JSON_OUT=1; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + -*) echo "[!] Error: Unknown option '$1'. Use -h for help." >&2; exit 1 ;; + *) break ;; + esac + done -LEGIT_DOMAIN=$(normalize_domain "$1") -SUSPECT_DOMAIN=$(normalize_domain "$2") + if [[ $# -ne 2 ]]; then + usage >&2 + echo "" >&2 + echo "[!] Error: Expected exactly 2 domains: ." >&2 + exit 1 + fi -if ! valid_domain_syntax "$LEGIT_DOMAIN"; then - echo "[!] Error: Invalid domain syntax: '$1'." >&2 - exit 1 -fi -if ! valid_domain_syntax "$SUSPECT_DOMAIN"; then - echo "[!] Error: Invalid domain syntax: '$2'." >&2 - exit 1 -fi + LEGIT_DOMAIN=$(normalize_domain "$1") + SUSPECT_DOMAIN=$(normalize_domain "$2") -check_dependencies -check_dns "$LEGIT_DOMAIN" -check_dns "$SUSPECT_DOMAIN" + if ! valid_domain_syntax "$LEGIT_DOMAIN"; then + echo "[!] Error: Invalid domain syntax: '$1'." >&2 + exit 1 + fi + if ! valid_domain_syntax "$SUSPECT_DOMAIN"; then + echo "[!] Error: Invalid domain syntax: '$2'." >&2 + exit 1 + fi +} -# ---- data collection ---- -TARGET_DATE=$(get_registration_date "$LEGIT_DOMAIN") -TARGET_REG=$(get_registrar "$LEGIT_DOMAIN") -TARGET_ISSUER=$(get_cert_issuer "$LEGIT_DOMAIN") -TARGET_SANS=$(get_cert_sans "$LEGIT_DOMAIN") -TARGET_CLASS=$(get_cert_class "$LEGIT_DOMAIN") +# ===================================================================== +# Data collection: DNS pre-checks, then RDAP + TLS per domain +# ===================================================================== -SUSPECT_DATE=$(get_registration_date "$SUSPECT_DOMAIN") -SUSPECT_REG=$(get_registrar "$SUSPECT_DOMAIN") -SUSPECT_ISSUER=$(get_cert_issuer "$SUSPECT_DOMAIN") -SUSPECT_SANS=$(get_cert_sans "$SUSPECT_DOMAIN") -SUSPECT_CLASS=$(get_cert_class "$SUSPECT_DOMAIN") +# Run NXDOMAIN triage and populate all TARGET_*/SUSPECT_* globals. +collect_domain_data() { + check_dependencies + check_dns "$LEGIT_DOMAIN" + check_dns "$SUSPECT_DOMAIN" -SUSPECT_AGE_DAYS=$(domain_age_days "$SUSPECT_DATE") + TARGET_DATE=$(get_registration_date "$LEGIT_DOMAIN") + TARGET_REG=$(get_registrar "$LEGIT_DOMAIN") + TARGET_ISSUER=$(get_cert_issuer "$LEGIT_DOMAIN") + TARGET_SANS=$(get_cert_sans "$LEGIT_DOMAIN") + TARGET_CLASS=$(get_cert_class "$LEGIT_DOMAIN") -# ---- risk scoring ---- + SUSPECT_DATE=$(get_registration_date "$SUSPECT_DOMAIN") + SUSPECT_REG=$(get_registrar "$SUSPECT_DOMAIN") + SUSPECT_ISSUER=$(get_cert_issuer "$SUSPECT_DOMAIN") + SUSPECT_SANS=$(get_cert_sans "$SUSPECT_DOMAIN") + SUSPECT_CLASS=$(get_cert_class "$SUSPECT_DOMAIN") + + SUSPECT_AGE_DAYS=$(domain_age_days "$SUSPECT_DATE") +} + +# ===================================================================== +# Risk scoring (spec section 4.4) +# ===================================================================== RISK_SCORE=0 FINDINGS=() -# Age check: < 90 days => +40 (spec section 4.4). -if [[ -n "$SUSPECT_AGE_DAYS" ]]; then - if [[ "$SUSPECT_AGE_DAYS" -lt 30 ]]; then - RISK_SCORE=$((RISK_SCORE + 40)) - AGE_FLAG=" [CRITICAL: Domain created < 30 days ago]" - FINDINGS+=("HIGH RISK: Suspect domain created recently ($SUSPECT_DATE).") - elif [[ "$SUSPECT_AGE_DAYS" -lt 90 ]]; then - RISK_SCORE=$((RISK_SCORE + 40)) - AGE_FLAG=" [WARNING: Domain created < 90 days ago]" - FINDINGS+=("HIGH RISK: Suspect domain created recently ($SUSPECT_DATE).") +# Fail closed: a verdict is meaningless without RDAP dates and TLS certs +# for BOTH domains, and scoring gaps default to +0 (i.e. "legitimate"). +# Abort instead of printing a verdict built on missing data. +verify_data_quality() { + if [[ "$TARGET_DATE" == "Unknown / Lookup Failed" ]]; then + echo "[!] Error: RDAP lookup failed for target '$LEGIT_DOMAIN' (network timeout or unsupported TLD). Please check connectivity and try again." >&2 + exit 1 + fi + if [[ "$SUSPECT_DATE" == "Unknown / Lookup Failed" ]]; then + echo "[!] Error: RDAP lookup failed for suspect '$SUSPECT_DOMAIN' (network timeout or unsupported TLD). Please check connectivity and try again." >&2 + exit 1 + fi + if [[ "$TARGET_ISSUER" == "Unknown" && "$TARGET_CLASS" == "Unknown" ]]; then + echo "[!] Error: TLS certificate fetch failed for target '$LEGIT_DOMAIN' (port 443 unreachable or handshake failed). Please check connectivity and try again." >&2 + exit 1 + fi + if [[ "$SUSPECT_ISSUER" == "Unknown" && "$SUSPECT_CLASS" == "Unknown" ]]; then + echo "[!] Error: TLS certificate fetch failed for suspect '$SUSPECT_DOMAIN' (port 443 unreachable or handshake failed). Please check connectivity and try again." >&2 + exit 1 + fi +} + +# Age check: suspect created < 90 days => +40. +score_age() { + if [[ -n "$SUSPECT_AGE_DAYS" ]]; then + if [[ "$SUSPECT_AGE_DAYS" -lt 30 ]]; then + RISK_SCORE=$((RISK_SCORE + 40)) + AGE_FLAG=" [CRITICAL: Domain created < 30 days ago]" + FINDINGS+=("HIGH RISK: Suspect domain created recently ($SUSPECT_DATE).") + elif [[ "$SUSPECT_AGE_DAYS" -lt 90 ]]; then + RISK_SCORE=$((RISK_SCORE + 40)) + AGE_FLAG=" [WARNING: Domain created < 90 days ago]" + FINDINGS+=("HIGH RISK: Suspect domain created recently ($SUSPECT_DATE).") + else + AGE_FLAG="" + fi else AGE_FLAG="" + if [[ "$SUSPECT_DATE" == "Unknown / Lookup Failed" ]]; then + FINDINGS+=("UNKNOWN: Could not determine suspect domain age ($SUSPECT_DATE).") + fi fi -else - AGE_FLAG="" - if [[ "$SUSPECT_DATE" == "Unknown / Lookup Failed" ]]; then - FINDINGS+=("UNKNOWN: Could not determine suspect domain age ($SUSPECT_DATE).") - fi -fi +} # Cert validation downgrade: target EV/OV but suspect DV => +30. -if [[ ("$TARGET_CLASS" == "EV" || "$TARGET_CLASS" == "OV") && "$SUSPECT_CLASS" == "DV" ]]; then - RISK_SCORE=$((RISK_SCORE + 30)) - FINDINGS+=("DOWNGRADE: Target uses $TARGET_CLASS ($TARGET_ISSUER) while Suspect uses DV automated cert ($SUSPECT_ISSUER).") -elif [[ "$TARGET_ISSUER" != "$SUSPECT_ISSUER" && "$TARGET_ISSUER" != "Unknown" && "$SUSPECT_ISSUER" != "Unknown" ]]; then - # Same-class issuer difference is display-only (avoids false positives - # when both sides legitimately use different DV CAs). - FINDINGS+=("NOTE: Certificate Issuers differ ($TARGET_ISSUER vs $SUSPECT_ISSUER) but validation levels are comparable ($TARGET_CLASS vs $SUSPECT_CLASS).") -fi +# Same-class issuer differences are display-only (no points). +score_cert_downgrade() { + if [[ ("$TARGET_CLASS" == "EV" || "$TARGET_CLASS" == "OV") && "$SUSPECT_CLASS" == "DV" ]]; then + RISK_SCORE=$((RISK_SCORE + 30)) + FINDINGS+=("DOWNGRADE: Target uses $TARGET_CLASS ($TARGET_ISSUER) while Suspect uses DV automated cert ($SUSPECT_ISSUER).") + elif [[ "$TARGET_ISSUER" != "$SUSPECT_ISSUER" && "$TARGET_ISSUER" != "Unknown" && "$SUSPECT_ISSUER" != "Unknown" ]]; then + # Avoids false positives when both sides legitimately use different DV CAs. + FINDINGS+=("NOTE: Certificate Issuers differ ($TARGET_ISSUER vs $SUSPECT_ISSUER) but validation levels are comparable ($TARGET_CLASS vs $SUSPECT_CLASS).") + fi +} # Registrar mismatch => +15 (skipped when either side is unknown). -if [[ "$TARGET_REG" != "Unknown" && "$SUSPECT_REG" != "Unknown" ]]; then - if [[ "${TARGET_REG,,}" != "${SUSPECT_REG,,}" ]]; then - RISK_SCORE=$((RISK_SCORE + 15)) - FINDINGS+=("MISMATCH: Registrars do not align ($TARGET_REG vs $SUSPECT_REG).") +score_registrar() { + if [[ "$TARGET_REG" != "Unknown" && "$SUSPECT_REG" != "Unknown" ]]; then + if [[ "${TARGET_REG,,}" != "${SUSPECT_REG,,}" ]]; then + RISK_SCORE=$((RISK_SCORE + 15)) + FINDINGS+=("MISMATCH: Registrars do not align ($TARGET_REG vs $SUSPECT_REG).") + fi fi -fi +} -# Verdict thresholds. -if [[ "$RISK_SCORE" -ge 40 ]]; then - VERDICT="LIKELY PHISHING / IMPERSONATION ATTEMPT" -elif [[ "$RISK_SCORE" -ge 15 ]]; then - VERDICT="SUSPICIOUS — MANUAL REVIEW RECOMMENDED" -else - VERDICT="LIKELY LEGITIMATE" -fi +# Map the total score onto a verdict. +determine_verdict() { + if [[ "$RISK_SCORE" -ge 40 ]]; then + VERDICT="LIKELY PHISHING / IMPERSONATION ATTEMPT" + elif [[ "$RISK_SCORE" -ge 15 ]]; then + VERDICT="SUSPICIOUS — MANUAL REVIEW RECOMMENDED" + else + VERDICT="LIKELY LEGITIMATE" + fi +} -# ---- output ---- -if [[ "$JSON_OUT" -eq 1 ]]; then - findings_json="" - first=1 +# ===================================================================== +# Output renderers +# ===================================================================== + +# Machine-readable summary for scripting. +render_json() { + local findings_json="" first=1 f for f in "${FINDINGS[@]}"; do if [[ "$first" -eq 1 ]]; then first=0; else findings_json+=", "; fi findings_json+="\"$(json_escape "$f")\"" @@ -392,12 +457,13 @@ if [[ "$JSON_OUT" -eq 1 ]]; then cat < $SUSPECT_DOMAIN (Suspect) @@ -417,19 +483,23 @@ cat <