From 9a33508b1dc75879391ab7408731c7f72d30c641 Mon Sep 17 00:00:00 2001 From: tke Date: Fri, 25 Sep 2026 15:31:29 +0200 Subject: [PATCH] feat(security): add giac_lookup tool and update README catalog --- README.md | 1 + tools/security/giac_lookup.py | 409 ++++++++++++++++++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100755 tools/security/giac_lookup.py diff --git a/README.md b/README.md index 8cec336..05b075e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Format: `path | goal | usage`. This section is intentionally compact so `what` c ### Security +- `tools/security/giac_lookup.py` | goal: search GIAC/SANS certification directory by name or analyst ID and generate certification/training cost reports | usage: `python3 tools/security/giac_lookup.py "John Doe"` - `tools/security/scan_vt.py` | goal: check file hashes against VirusTotal | usage: `python3 tools/security/scan_vt.py sample.bin` - `tools/security/imphash.py` | goal: calculate PE import hashes for malware triage | usage: `python3 tools/security/imphash.py file.exe` - `tools/security/scapy_arp.py` | goal: scan a local network with ARP requests | usage: `python3 tools/security/scapy_arp.py` diff --git a/tools/security/giac_lookup.py b/tools/security/giac_lookup.py new file mode 100755 index 0000000..8a01727 --- /dev/null +++ b/tools/security/giac_lookup.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 +"""giac_lookup.py – GIAC Certified Holder Directory lookup tool (static SSR parser, no browser). + +Usage: + giac_lookup John Doe + giac_lookup Doe + giac_lookup 123456 + giac_lookup Smith --json + +Network: + Respects standard environment variables (http_proxy, https_proxy, no_proxy) + or explicit proxy via `--proxy `. Requires direct or proxied internet egress. + +Dependencies: + Python standard library only. +""" +import argparse +from datetime import date +import html as htmlmod +import json +import os +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +BASE = "https://www.giac.org" +UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36") + +HEADERS = { + "User-Agent": UA, + "Accept": ("text/html,application/xhtml+xml,application/xml;q=0.9," + "image/avif,image/webp,*/*;q=0.8"), + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "identity", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Upgrade-Insecure-Requests": "1", +} + + +def http_get(url, proxy=None, timeout=40, retries=3): + """Execute HTTP GET request using configured headers, retries, and proxy resolution.""" + def _open(px): + handlers = [urllib.request.ProxyHandler( + {k: px for k in ("http", "https")})] if px else [] + opener = urllib.request.build_opener(*handlers) + req = urllib.request.Request(url, headers=HEADERS) + with opener.open(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", "ignore") + + proxy = proxy if proxy is not None else ( + os.environ.get("https_proxy") or + os.environ.get("HTTPS_PROXY") or + os.environ.get("http_proxy") or + os.environ.get("HTTP_PROXY")) + + last_err = None + for attempt in range(retries): + try: + return _open(proxy) + except urllib.error.HTTPError as e: + last_err = e + if e.code in (403, 407, 429): + break + time.sleep(1.5 * (attempt + 1)) + except Exception as e: # noqa: BLE001 + last_err = e + time.sleep(1.5 * (attempt + 1)) + raise RuntimeError( + f"GET request failed for {url} " + f"(proxy={'configured' if proxy else 'direct'}): {last_err}") + + +def is_blocked(html_text): + """Detect WAF/Incapsula challenge or block responses.""" + markers = ( + "Request unsuccessful. Incapsula incident ID", + "You are browsing this site with a prohibited", + "blocked your request", + ) + low = html_text.lower() + return any(m.lower() in low for m in markers) + + +# ---------- Progress Logging ---------- + +_QUIET = False + + +def _info(msg): + """Write informational log to stderr so stdout remains clean for piping.""" + if not _QUIET: + print(msg, file=sys.stderr, flush=True) + + +def set_quiet(q): + global _QUIET + _QUIET = q + + +# ---------- Parsing ---------- + +TAG_TEXT = re.compile(r"<[^>]+>") + + +def _clean(s): + s = TAG_TEXT.sub(" ", s) + s = htmlmod.unescape(s) + return re.sub(r"\s+", " ", s).strip() + + +CARD_SPLIT = re.compile(r'data-testid="certification-holder-card"[^>]*>') + + +def parse_card(card_html): + title_m = re.search(r"]*>(.*?)", card_html, re.S) + title = _clean(title_m.group(1)) if title_m else None + code = None + if title: + cm = re.search(r"\(([A-Z0-9]{2,6})\)\s*$", title) + if cm: + code = cm.group(1) + flat = _clean(card_html) + + def field(label): + m = re.search(label + r"\s*:\s*([0-9]{4}-[0-9]{2}-[0-9]{2})", flat) + return m.group(1) if m else None + + num_m = re.search(r"Analyst Number\s*:\s*(\d+)", flat) + return { + "title": title, + "code": code, + "analyst_number": num_m.group(1) if num_m else None, + "obtained": field(r"Obtained"), + "expiry": field(r"Expiry"), + } + + +def _section(html_text, heading): + start = html_text.find(heading) + if start < 0: + return "" + nxt = [html_text.find(h, start + len(heading)) + for h in ("Inactive Certifications", "Subscribe to GIAC") + if html_text.find(h, start + len(heading)) > start] + end = min(nxt) if nxt else len(html_text) + return html_text[start:end] + + +def parse_detail(html_text): + name_m = re.search(r']*>(.*?)', html_text, re.S) + name = _clean(name_m.group(1)) if name_m else None + flat = _clean(html_text) + id_m = re.search(r"Analyst ID\s*:\s*(\d+)", flat) + since_m = re.search(r"Certified since\s*:\s*([A-Za-z]+\s+\d{4})", flat) + active = _cards_in(html_text, "Active Certifications") + inactive = _cards_in(html_text, "Inactive Certifications") + return { + "name": name, + "analyst_id": id_m.group(1) if id_m else None, + "certified_since": since_m.group(1) if since_m else None, + "active": active, + "inactive": inactive, + } + + +def _cards_in(html_text, heading): + section = _section(html_text, heading) + out = [] + for seg in CARD_SPLIT.split(section)[1:]: + card = parse_card(seg) + if card["title"]: + out.append(card) + return out + + +def parse_search(html_text): + """Parse directory search results into list of {analyst_id, name, path}.""" + rows = re.findall( + r']*href="(/certified-professional/[^"]+)"[^>]*>(.*?)', + html_text, re.S) + out = [] + seen = set() + for href, inner in rows: + m = re.match(r"/certified-professional/(.+)/(\d+)$", href) + if not m: + continue + id_num = m.group(2) + name = _clean(inner) + name = re.sub(r"^\d+\s+", "", name).strip() + key = (id_num, name) + if key in seen: + continue + seen.add(key) + out.append({"analyst_id": id_num, "name": name, "path": href}) + return out + + +# ---------- API Calls ---------- + +def search(query, proxy=None): + _info(f"→ Searching for '{query}' ...") + q = urllib.parse.quote(query, safe="") + url = f"{BASE}/certified-professional?certified_holder%5Bquery%5D={q}" + html_text = http_get(url, proxy) + if is_blocked(html_text): + raise RuntimeError("Incapsula/WAF block encountered. Please try again later.") + if "certified-professional/" not in html_text: + _info("→ No matches found.") + return [] + results = parse_search(html_text) + _info(f"→ {len(results)} match(es) found.") + return results + + +def person(slug_id, proxy=None): + _info(f"→ Loading profile {slug_id} ...") + url = f"{BASE}/certified-professional/{slug_id}" + html_text = http_get(url, proxy) + if is_blocked(html_text): + raise RuntimeError("Incapsula/WAF block encountered on profile page.") + p = parse_detail(html_text) + _info(f"→ {len(p['active'])} active / {len(p['inactive'])} inactive certifications.") + return p + + +# ---------- Cost Estimation ---------- + +DEFAULT_FIRST_EUR = float(os.environ.get("GIAC_FIRST_PRICE_EUR", "8090")) +DEFAULT_RENEWAL_USD = 499.0 +DEFAULT_DISCOUNT_USD = 249.0 +DEFAULT_FX = float(os.environ.get("GIAC_USD_TO_EUR", "0.88")) +NORMAL_YEARS = 4.0 +COTERM_THRESHOLD = 9 + + +def _iso_date(s): + y, m, d = (int(x) for x in s.split("-")) + return date(y, m, d) + + +def estimate_costs(p, first_eur=None, renewal_usd=DEFAULT_RENEWAL_USD, + discount_usd=DEFAULT_DISCOUNT_USD, fx=None): + first_eur = DEFAULT_FIRST_EUR if first_eur is None else first_eur + fx = DEFAULT_FX if fx is None else fx + renewal_eur = renewal_usd * fx + discount_eur = discount_usd * fx + all_certs = p["active"] + p["inactive"] + first_total_eur = len(all_certs) * first_eur + + recerts = [] + for c in p["active"]: + if c["obtained"] and c["expiry"]: + try: + years = (_iso_date(c["expiry"]) - _iso_date(c["obtained"])).days / 365.25 + except ValueError: + continue + cycles = max(0, int(round(years / NORMAL_YEARS)) - 1) if years > NORMAL_YEARS + 0.15 else 0 + if cycles: + recerts.append({"title": c["title"], "code": c["code"], "cycles": cycles}) + + recert_events = sum(r["cycles"] for r in recerts) + low_eur = first_total_eur + recert_events * discount_eur + high_eur = first_total_eur + recert_events * renewal_eur + return { + "first_count": len(all_certs), + "first_unit_eur": first_eur, + "first_total_eur": first_total_eur, + "recerts": recerts, + "recert_events": recert_events, + "low_eur": low_eur, + "high_eur": high_eur, + "fx": fx, + "coterm_note": len(p["active"]) >= COTERM_THRESHOLD, + } + + +def print_estimate(est): + """Print one-line summary estimated expenditure.""" + if not est: + return + if est["recert_events"]: + tag = (f"{est['low_eur']:,.0f} - {est['high_eur']:,.0f} EUR " + f"(initial certifications: {est['first_count']}x {est['first_unit_eur']:,.0f} EUR + " + f"{est['recert_events']} renewals, multi-discount vs standard rate)") + else: + tag = f"{est['low_eur']:,.0f} EUR (initial certifications: {est['first_count']}x {est['first_unit_eur']:,.0f} EUR)" + if est["coterm_note"]: + tag += " · 9+ active certifications: co-termination applies" + print() + print(f"Estimated historical investment: ~{tag}") + + +# ---------- Output Formatting ---------- + +def fmt_date(iso): + if not iso: + return "-" + y, m, d = iso.split("-") + return f"{d}.{m}.{y}" + + +def print_search(results): + if not rows_print(results): + return + print(f"{'ID':>8} Name") + print("-" * 60) + for r in results: + print(f"{r['analyst_id']:>8} {r['name']}") + print(f"\n{len(results)} match(es)") + + +def rows_print(results): + if not results: + print("No matches found.") + return False + return True + + +def print_report(p): + print(f"GIAC Certification Status: {p['name'] or '?'}") + print(f"Analyst ID: {p['analyst_id'] or '?'} · " + f"Certified since: {p['certified_since'] or '?'}") + print("Source: giac.org Certified Professional Directory " + f"(retrieved {time.strftime('%Y-%m-%d')})") + for heading, label in (("active", "Active Certifications"), + ("inactive", "Inactive Certifications (Expired)")): + certs = p[heading] + print(f"\n### {label} ({len(certs)})\n") + if not certs: + print("- None") + continue + w = max(len(c["title"] or "") for c in certs) + print(f"{'Certification':<{w}} Analyst No. Obtained Expires") + print("-" * (w + 42)) + for c in certs: + print(f"{c['title'] or '?':<{w}} {c['analyst_number'] or '-':>11} " + f"{fmt_date(c['obtained'])} {fmt_date(c['expiry'])}") + + print_estimate(estimate_costs(p)) + + +def resolve_and_report(query, proxy): + results = search(query, proxy) + if not results: + print(f"No matches found for '{query}'.") + return None + if len(results) == 1: + r = results[0] + else: + print(f"{len(results)} matches for '{query}':\n") + for i, r in enumerate(results, 1): + print(f" [{i}] {r['analyst_id']:>7} {r['name']}") + print() + try: + sel = input("Select number (Enter=1, 'q'=quit): ").strip() + except EOFError: + sel = "1" + if sel.lower() == "q": + return None + idx = int(sel) - 1 if sel else 0 + r = results[idx] + _info(f"→ Selected: {r['name']} (ID {r['analyst_id']})") + slug = r["path"].split("/certified-professional/", 1)[1] + p = person(slug, proxy) + p["name"] = p["name"] or r["name"] + p["analyst_id"] = p["analyst_id"] or r["analyst_id"] + print_report(p) + return p + + +# ---------- CLI Entrypoint ---------- + +def main(): + ap = argparse.ArgumentParser( + prog="giac_lookup", + description="Lookup GIAC certifications by name, surname, or analyst ID.") + ap.add_argument("query", nargs="+", + help="Name, surname, or Analyst ID (e.g. 'John Doe', 'Smith', '123456')") + ap.add_argument("--json", action="store_true", + help="Output raw search results as JSON") + ap.add_argument("--search-only", "-s", action="store_true", + help="Only list matching holders without fetching full certification details") + ap.add_argument("--proxy", default=None, + help="Optional proxy override (defaults to environment variables).") + ap.add_argument("--quiet", "-q", action="store_true", + help="Suppress progress output on stderr (for scripting/piping).") + args = ap.parse_args() + set_quiet(args.quiet) + + query = " ".join(args.query).strip() + proxy = args.proxy + + if args.json: + results = search(query, proxy) + print(json.dumps(results, indent=2, ensure_ascii=False)) + elif args.search_only: + print_search(search(query, proxy)) + else: + resolve_and_report(query, proxy) + + +if __name__ == "__main__": + main()