Code cleanup and New verify_data_quality() function to fail when domain info can not be retrieved
This commit is contained in:
parent
853ca8f9ea
commit
fb7ce3b4c2
2 changed files with 211 additions and 114 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
# Agent spec isn't directly relevant to code
|
||||
AGENTS.md
|
||||
.clang-format
|
||||
|
|
|
|||
302
isthislegit
302
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 <<EOF
|
||||
isthislegit? [v${VERSION}]
|
||||
Domain Impersonation & OSINT Triage Tool
|
||||
isthislegit? [v${VERSION}]
|
||||
Domain Impersonation & OSINT Triage Tool
|
||||
|
||||
Usage:
|
||||
isthislegit [OPTIONS] <legit_domain> <suspect_domain>
|
||||
|
|
@ -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: <legit_domain> <suspect_domain>." >&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: <legit_domain> <suspect_domain>." >&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 <<EOF
|
||||
{"tool": "isthislegit", "version": "$VERSION", "target": {"domain": "$(json_escape "$LEGIT_DOMAIN")", "creation_date": "$(json_escape "$TARGET_DATE")", "registrar": "$(json_escape "$TARGET_REG")", "cert_issuer": "$(json_escape "$TARGET_ISSUER")", "cert_class": "$(json_escape "$TARGET_CLASS")", "cert_sans": "$(json_escape "$TARGET_SANS")"}, "suspect": {"domain": "$(json_escape "$SUSPECT_DOMAIN")", "creation_date": "$(json_escape "$SUSPECT_DATE")", "registrar": "$(json_escape "$SUSPECT_REG")", "cert_issuer": "$(json_escape "$SUSPECT_ISSUER")", "cert_class": "$(json_escape "$SUSPECT_CLASS")", "cert_sans": "$(json_escape "$SUSPECT_SANS")", "age_days": ${SUSPECT_AGE_DAYS:-null}}, "risk_score": $RISK_SCORE, "findings": [$findings_json], "verdict": "$(json_escape "$VERDICT")"}
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
cat <<EOF
|
||||
_sthislegit? [v${VERSION}]
|
||||
Domain Impersonation & OSINT Triage Tool
|
||||
# Human-readable triage report.
|
||||
render_human() {
|
||||
cat <<EOF
|
||||
isthislegit? [v${VERSION}]
|
||||
Domain Impersonation & OSINT Triage Tool
|
||||
|
||||
[*] Comparing: $LEGIT_DOMAIN (Target) <---> $SUSPECT_DOMAIN (Suspect)
|
||||
|
||||
|
|
@ -417,19 +483,23 @@ cat <<EOF
|
|||
[!] RISK ANALYSIS (score: $RISK_SCORE):
|
||||
EOF
|
||||
|
||||
if [[ ${#FINDINGS[@]} -eq 0 ]]; then
|
||||
echo " [+] No significant risk indicators found."
|
||||
else
|
||||
for f in "${FINDINGS[@]}"; do
|
||||
echo " [!] $f"
|
||||
done
|
||||
fi
|
||||
if [[ ${#FINDINGS[@]} -eq 0 ]]; then
|
||||
echo " [+] No significant risk indicators found."
|
||||
else
|
||||
local f
|
||||
for f in "${FINDINGS[@]}"; do
|
||||
echo " [!] $f"
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "VERDICT: $VERDICT"
|
||||
echo ""
|
||||
echo ""
|
||||
echo "VERDICT: $VERDICT"
|
||||
echo ""
|
||||
}
|
||||
|
||||
if [[ "$VERBOSE" -eq 1 ]]; then
|
||||
# Raw RDAP JSON plus full TLS cert details for both domains.
|
||||
render_verbose() {
|
||||
local oids d pem rj
|
||||
echo "==================== VERBOSE ===================="
|
||||
echo "--- Target cert policy OIDs ($LEGIT_DOMAIN):"
|
||||
oids=$(get_cert_policy_oids "$LEGIT_DOMAIN")
|
||||
|
|
@ -456,4 +526,30 @@ if [[ "$VERBOSE" -eq 1 ]]; then
|
|||
fi
|
||||
echo ""
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# Main procedural flow
|
||||
# =====================================================================
|
||||
main() {
|
||||
parse_args "$@"
|
||||
collect_domain_data
|
||||
verify_data_quality
|
||||
score_age
|
||||
score_cert_downgrade
|
||||
score_registrar
|
||||
determine_verdict
|
||||
|
||||
if [[ "$JSON_OUT" -eq 1 ]]; then
|
||||
render_json
|
||||
return 0
|
||||
fi
|
||||
|
||||
render_human
|
||||
|
||||
if [[ "$VERBOSE" -eq 1 ]]; then
|
||||
render_verbose
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue