feat(security): add giac_lookup tool and update README catalog
This commit is contained in:
1 parent
25780cd6e8
commit
9a33508b1d
2 files changed
+410
No files matched your search
@@ -55,6 +55,7 @@ Format: `path | goal | usage`. This section is intentionally compact so `what` c
|
|||||||
|
|
||||||
### Security
|
### 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/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/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`
|
- `tools/security/scapy_arp.py` | goal: scan a local network with ARP requests | usage: `python3 tools/security/scapy_arp.py`
|
||||||
|
|||||||
Executable
+409
@@ -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 <url>`. 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"<h3[^>]*>(.*?)</h3>", 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'<h1[^>]*>(.*?)</h1>', 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'<a[^>]*href="(/certified-professional/[^"]+)"[^>]*>(.*?)</a>',
|
||||||
|
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()
|
||||||
Reference in new issue
Block a user