OSINT & Attack Surface Discovery
Definition
Section titled “Definition”OSINT and attack surface discovery (ASM) is the discipline of seeing your organization as the adversary sees it: collecting public and semi-public data (certificate transparency logs, DNS records, whois history, code-sharing sites, job postings, archives, social profiles, breach corpora) into a continuous inventory of everything exposed — domains, subdomains, IPs, services, people, code, and credentials — and diffing that inventory against what the organization believes it owns. The two sides of the discipline: offensive OSINT (T2’s recon — the target-selection and attack-surface-mapping phase adversaries run before any exploit) and defensive ASM (the same collection run by the owner, continuously, with remediation instead of exploitation as the output). The difference is a feedback loop: the adversary’s map is a snapshot; the defender’s must be a living baseline because the surface changes weekly (new subdomains, new SaaS integrations, a developer’s public repo, a forgotten staging host). The craft’s center: your attack surface is defined by what’s discoverable, not by what’s documented — and every gap between discovery and documentation is an area the adversary maps for free while your CMDB doesn’t know it exists.
Why It Matters
Section titled “Why It Matters”The modern perimeter is assembled from things nobody added to an inventory: the marketing team’s SaaS tool with SSO, the demo environment a contractor stood up, the subdomain that CNAMEs to a forgotten cloud bucket (BT18’s takeover class), the API key a developer pasted into a public gist. Intrusions increasingly start on assets the victim didn’t know they had — because asset discovery by the adversary (certificate transparency enumeration, subdomain bruteforcing, SaaS OAuth-grant scraping) is easier than ever while internal asset tracking hasn’t kept pace. The BT11 module’s coverage-decay lesson applies at estate scale: a vulnerability program scanning an incomplete inventory produces excellent metrics inside a shrinking map. ASM is the discipline that keeps the map honest — and its passive-first methodology (collect what’s published, avoid touching the target until the last mile) means the defensive version can run at full intensity without legal exposure or noise.
The failure modes are inventory-shaped. The snapshot program: a one-time attack-surface assessment, framed, admired, and stale within a month — ASM without continuity is a report, not a capability. The scope boundary error: discovering only what the DNS team knows about (corporate domains) while the acquisitions, product brands, regional variants, and developer-owned domains sit unmapped — the adversary enumerates by brand string, not by your domain list. Collection without attribution: 2,000 discovered assets with no owner, no criticality, and no remediation path — the swamp that makes the next discovery ignored. Credential blindness: code-search and paste-site monitoring (BT18’s watch class) skipped because “that’s threat intel’s job” — the leaked key is the highest-conversion finding OSINT produces. And passive-passivity: staying so exclusively passive that the active checks (is this host actually serving? does this takeover work?) never run — the inventory lists the asset but can’t answer whether it’s a live risk.
Detailed Explanation
Section titled “Detailed Explanation” COLLECTION SOURCES (passive-first, continuous) +---------------------------+ +---------------------------+ | CERTIFICATE TRANSPARENCY | | DNS / WHOIS | | every cert issued for | | zone enumeration, NS, MX, | | your brand-adjacent names | | subdomain records, whois | +---------------------------+ +---------------------------+ | CODE / PASTE / GISTS | | ARCHIVES / SCRAPES | | github/gist search, paste | | wayback snapshots, response| | sites, public repos | | body archives (old paths) | +---------------------------+ +---------------------------+ | PEOPLE / POSTINGS | | BREACH / CREDENTIAL CORPORA| | job posts (tech stack!), | | dump watching (BT18 join),| | conference talks, resumes | | combo lists vs your domain| +---------------------------+ +---------------------------+ | join, attribute, diff against CMDB v +------------------------------------------+ | ASM INVENTORY (asmfeed) | | asset, source, first_seen, owner, status | +--------------------+---------------------+ | NEW or CHANGED? v +------------------------------------------+ | TRIAGE: live? takeover? secret? person? | | -> BT11 (vuln scan), BT18 (brand abuse), | | BT15 (creds), BT10 (intel), fix queue | +------------------------------------------+Passive collection is the craft’s core discipline — every source is something the target (or a third party) publishes. Certificate transparency is the highest-yield single source: every TLS certificate issued for any name is logged publicly at issuance, so CT enumeration (via crt.sh-class services or log subscriptions) reveals subdomains — including ones that have never resolved publicly — days before they serve traffic. DNS gives structure (zone transfers if misconfigured, NS/MX/TXT records, subdomain enumeration via certificate records and search engines); whois history shows corporate relationships and acquisition footprints; archives (the Wayback Machine) reveal removed content — the staging site that was deleted but whose URLs and endpoints persist in snapshots, the old API that still responds. Code and paste search finds what developers leaked: keys, internal hostnames, credentials in commits (the gists and repos that outlive their authors’ employment). People and postings map the human surface: job postings disclose the tech stack (“experience with our Kubernetes and Okta environment”), conference talks disclose architecture, and resumes disclose tooling with dates.
Attribution and diffing turn collection into capability: every discovered asset gets a first-seen date, a source, an owner (best-effort — the DNS record’s internal contact, the SaaS’s billing owner), a criticality guess, and a status (live/dead/parked/unknown). The diff against the believed inventory is the program’s product: new assets (untracked reality), missing assets (inventory fiction), changed assets (new services on old hosts). The diff runs continuously — the weekly delta is the operating rhythm.
Active verification is the last mile, tightly scoped: does this host resolve and serve? (A light HTTP request to a host you believe is yours — consent obtained via ownership.) Is this takeover claim real? (BT18’s fingerprint check.) Does this leaked credential work? (Never — credential testing against your own IdP is BT15’s controlled rotation decision, not OSINT’s; the finding is “this credential is public,” the response is rotation.) The legal and ethical line — you own the asset, but scanning still follows policy — belongs in the program’s charter, and the passive-first default exists precisely so the questions that need legal review are few.
Deep dive: the attribution SLA — why ownerless inventory is failed collection
An asset without an owner is a finding that cannot die: nobody scans it (BT11 doesn’t know whose cadence applies), nobody patches it, nobody monitors it, and the next cycle’s diff re-flags it into the swamp that trains the team to ignore deltas. The attribution discipline is therefore an SLA, not a courtesy: every NEW asset gets a named owner within one business day, by a fixed hunt order — the certificate’s internal names and CSR fields (who requested it), DNS contact records, cloud-tag metadata, the SaaS billing owner, and finally the human chain (the engineer whose name appears in the deployed code’s commit strings). When the hunt exhausts, the asset enters the orphan register with a default rule: orphans get treated as hostile-by-default (blocked at the boundary, scanned at hostile intensity, and scheduled for takedown if unclaimed within the period) — because the burden of proof belongs to the asset, not the program. Programs without the orphan rule accumulate the swamp; programs with it convert attribution from archaeology into a routed ticket with a deadline.
Common Techniques
Section titled “Common Techniques”| Technique | What it does | Where it fails | Notes |
|---|---|---|---|
| CT-log enumeration | Every cert issued for brand names | Wildcard-cert blindness | Highest-yield passive |
| Subdomain permutation | Bruteforce + permutation discovery | DNS wildcard noise | Active; scope-gated |
| Code/paste search | Leaked keys, hostnames, creds | Search API rate/scope | Continuous watch |
| Archive mining | Removed content, old endpoints | Snapshot gaps | Staging-site class |
| Whois/brand mapping | Corporate relationship footprints | Acquisitions unmapped | Enumerate by brand string |
| Posting analysis | Tech stack, org structure | Over-reading posts | T2’s target-selection view |
| Breach-corpus watch | Org credentials in dumps | Signal vs rumor | Feeds BT15 rotation |
| Cloud-bucket enumeration | S3/blob naming-pattern discovery | Naming-convention leaks | Takeover adjacency (BT18) |
| SaaS/OAuth discovery | Third-party integrations found | SSO grant sprawl | Identity-plane surface |
| Inventory diffing | New/missing/changed vs CMDB | Diff without remediation | The weekly product |
Full Working Code
Section titled “Full Working Code”asmfeed — the ASM inventory engine: ingests discovery results from multiple source formats (CT-log CSV, DNS enumeration JSONL, code-search hits) into a normalized inventory (asset, type, source, first_seen, last_seen, owner, status), diffs against the known-inventory CSV (the CMDB export), and emits the delta report — new (untracked reality), missing (inventory fiction), changed — with triage routing hints per class.
#!/usr/bin/env python3"""asmfeed — attack-surface inventory + diff engine.
Inputs: discoveries: one or more CSVs with (asset, type, source) (CT export, DNS enum, code-search hits — normalized columns) known.csv: asset,owner,criticality (the CMDB/believed inventory)Output: inventory.csv (accumulated, first/last seen), delta report (NEW / MISSING / CHANGED) with triage routing per class.State: asm_state.json (first_seen persistence across runs)."""import argparseimport csvimport jsonimport sysfrom datetime import datefrom pathlib import Path
KNOWN_TYPES = {"domain", "subdomain", "ip", "url", "repo", "key"}
def fail(msg): sys.exit(f"fail-loud: {msg}")
def norm_asset(raw, atype): a = raw.strip().lower() if atype in ("domain", "subdomain") and a.endswith("."): a = a[:-1] return a
def load_discoveries(patterns): rows = [] for pat in patterns: matched = sorted(Path(".").glob(pat)) if not matched: fail(f"pattern matched nothing: {pat}") for f in matched: with f.open(encoding="utf-8", errors="replace") as fh: reader = csv.DictReader(fh) missing = [c for c in ("asset", "type", "source") if c not in (reader.fieldnames or [])] if missing: fail(f"{f}: missing columns {missing}") for r in reader: t = r["type"].strip().lower() if t not in KNOWN_TYPES: fail(f"{f}: unknown asset type {t!r} " f"(known: {sorted(KNOWN_TYPES)})") asset = norm_asset(r["asset"], t) if asset: rows.append({"asset": asset, "type": t, "source": r["source"].strip()}) if not rows: fail("zero discoveries — collection broke, not a quiet day") return rows
def load_state(path): if path.exists(): try: return json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: fail("state file corrupt — fix or remove asm_state.json") return {}
def main(): ap = argparse.ArgumentParser(description="ASM inventory + diff") ap.add_argument("discoveries", nargs="+", help="discovery CSVs/globs") ap.add_argument("--known", type=Path, required=True, help="believed inventory (asset,owner,criticality)") ap.add_argument("--state", type=Path, default=Path("asm_state.json")) ap.add_argument("--out", type=Path, default=Path("asm_out")) args = ap.parse_args()
known_rows = [] if not args.known.exists(): fail(f"known inventory not found: {args.known}") with args.known.open(encoding="utf-8", errors="replace") as fh: reader = csv.DictReader(fh) missing = [c for c in ("asset", "owner", "criticality") if c not in (reader.fieldnames or [])] if missing: fail(f"known inventory missing columns: {missing}") known_rows = list(reader) known = {r["asset"].strip().lower(): r for r in known_rows} if not known: fail("known inventory empty — an empty CMDB is not a clean estate")
discoveries = load_discoveries(args.discoveries) state = load_state(args.state) today = date.today().isoformat()
# merge discoveries into inventory state inventory = {} for d in discoveries: a = d["asset"] rec = inventory.setdefault(a, {"type": d["type"], "sources": set(), "last_seen": today}) rec["sources"].add(d["source"]) rec["last_seen"] = today if a not in state: rec["first_seen"] = today else: rec["first_seen"] = state.get(a, {}).get("first_seen", today)
new_assets = [a for a in inventory if a not in state and a not in known] returning = [a for a in inventory if a in state] # missing: in known inventory but absent from today's discoveries missing = [a for a in known if a not in inventory]
args.out.mkdir(parents=True, exist_ok=True) with (args.out / "inventory.csv").open("w", newline="", encoding="utf-8") as fh: w = csv.writer(fh) w.writerow(["asset", "type", "sources", "first_seen", "last_seen", "in_known"]) for a in sorted(inventory): r = inventory[a] w.writerow([a, r["type"], "|".join(sorted(r["sources"])), r["first_seen"], r["last_seen"], "yes" if a in known else "no"])
with (args.out / "delta_new.csv").open("w", newline="", encoding="utf-8") as fh: w = csv.writer(fh) w.writerow(["asset", "type", "sources", "triage_hint"]) for a in sorted(new_assets): r = inventory[a] hint = {"key": "BT15 rotation decision NOW", "repo": "code review — secrets/hostnames", "url": "BT18 verdict: live? takeover? abuse?", "subdomain": "live check -> BT11 scan -> owner hunt", "domain": "BT18 brand check; registrar review", "ip": "service discovery -> BT11 scan"}[r["type"]] w.writerow([a, r["type"], "|".join(sorted(r["sources"])), hint]) with (args.out / "delta_missing.csv").open("w", newline="", encoding="utf-8") as fh: w = csv.writer(fh) w.writerow(["asset", "owner", "criticality", "note"]) for a in sorted(missing): k = known[a] w.writerow([a, k["owner"], k["criticality"], "in CMDB, not discoverable — verify or correct"])
# persist state for next run's first-seen logic new_state = {a: {"first_seen": inventory[a]["first_seen"], "type": inventory[a]["type"]} for a in inventory} args.state.write_text(json.dumps(new_state, indent=1), encoding="utf-8")
print(f"discovered={len(inventory)} known={len(known)} " f"returning={len(returning)}") print(f"NEW (untracked): {len(new_assets)} -> delta_new.csv") for a in sorted(new_assets)[:8]: print(f" + {a} [{inventory[a]['type']}]") print(f"MISSING (CMDB fiction?): {len(missing)} -> delta_missing.csv") for a in sorted(missing)[:5]: print(f" - {a} owner={known[a]['owner']}") print(f"inventory: {args.out / 'inventory.csv'}") print("triage hints route: keys->BT15, urls/domains->BT18, " "subdomains->BT11")
if __name__ == "__main__": main()Code Walkthrough
Section titled “Code Walkthrough”The type vocabulary (lines 19-20) is deliberately small — six asset classes covering the OSINT yield — and load_discoveries (lines 31-52) fails loud on unknown types: a new source writing hostname instead of subdomain must break the run rather than fork the inventory’s type system. The state persistence (lines 58-66, 106-109) is the program’s memory: first_seen per asset survives across runs (JSON state), so “new” genuinely means new — the diff that makes the weekly delta a signal rather than a re-listing.
The three-way classification (lines 93-101) is the product: NEW (discovered today, absent from state and from the CMDB — untracked reality, the highest-priority class), returning (seen before — the baseline), and MISSING (in the CMDB, absent from discovery — inventory fiction: either the asset died (CMDB rot) or discovery missed it (coverage gap); both are findings, and the register records which owner to ask). The triage hints (lines 96-104) encode the routing doctrine per type: keys go to BT15’s rotation decision immediately (the highest-conversion finding), URLs and domains to BT18’s verdict pipeline, subdomains to live-check-then-BT11. The inventory CSV (lines 88-98) accumulates the full surface with provenance (sources per asset) — the artifact the weekly review reads and the acquisition-team questionnaire eventually fills.
How It Reaches The Target
Section titled “How It Reaches The Target”The program runs as a weekly loop with daily watches. Daily: code-search and paste-site monitors run against brand strings and known developer handles (the leak class has a hours-scale window — a key pasted at noon is forked by evening); breach-corpus watches (BT18’s join) flag org credentials. Weekly: CT enumeration, DNS discovery, and archive mining run; asmfeed diffs the merged discoveries against the CMDB; the delta report routes. The Tuesday meeting reads three lists: new assets (each getting an owner by end of day — the attribution discipline), missing assets (each verified as dead-or-undiscovered), and credential findings (each triggering BT15’s rotation decision within the hour).
The incident-shaped value arrives as every real ASM finding does: the CT log shows a certificate for staging-eu.mycorp.com issued two days ago to an internal name nobody owns in the CMDB; the live check finds a Jenkins instance with default branding; BT11’s scan runs (the owner, found via the cert’s internal name, confirms a contractor’s environment) and finds an unauthenticated admin panel — a critical finding on an asset that entered the inventory because the adversary’s own reconnaissance would have found it anyway. The remediation (takedown or onboard-with-controls) closes the loop, and the pattern (contractors provision assets outside the CMDB) becomes a process fix — which is ASM’s compounding value: each cycle makes the next cycle’s map more complete and the organization’s self-knowledge harder for the adversary to out-run.
Exploitation Chain
Section titled “Exploitation Chain”- Collect — passive sources continuous: CT, DNS, code/paste, archives, postings, breach corpora
- Normalize — six-type inventory with sources, first-seen, provenance (asmfeed state)
- Diff — against the believed inventory: NEW (untracked reality), MISSING (CMDB fiction), changed
- Attribute — owners hunted per asset; unattributed assets get a name by end of day
- Triage — routing per class: keys→BT15, brand surfaces→BT18, hosts→BT11, people→T2 awareness
- Verify — active checks scoped: live?, takeover?, serving what? (BT18 fingerprints)
- Remediate — fix queue with owners: takedown, onboard-with-controls, rotation, process fix
- Compound — weekly delta tightens the map; the adversary’s free reconnaissance stops being free to them
Building Your Own Tool
Section titled “Building Your Own Tool”CT enumeration is the highest-yield passive source, and ctenum wraps it for the program: given a brand domain, it queries a CT-log aggregator (crt.sh’s JSON endpoint [verify current API terms and rate limits]) for all certificates matching the domain, extracts unique name values, filters wildcard-vs-exact, and emits the discovery CSV in asmfeed’s format — the first pipeline stage of the weekly loop.
Design decisions
Section titled “Design decisions”JSON endpoint over screen-scraping: crt.sh exposes %25.DOMAIN%25 JSON output [verify current endpoint behavior] — structured data beats HTML parsing and respects the service. Dedupe on the name across certificates (the same subdomain appears in many certs; the inventory wants names, not cert counts). Wildcard handling: *.corp.mycorp.com entries are recorded as wildcards (a separate class — they attest breadth without enumerating leaves) while exact names flow to the inventory. Issuer and date retention: first-issued dates become the timeline signal (a cert issued last week for a name nobody owns is the urgent class — BT18’s evil-twin adjacency), so the output carries first_issued per name. Rate-limit discipline: a single polite query per run, cached to the state file; hammering a free public service is how programs lose access.
Full source
Section titled “Full source”#!/usr/bin/env python3"""ctenum — certificate-transparency enumeration for one brand domain.
Usage: python3 ctenum.py mycorp.com [--out ct_discovery.csv]Queries the public CT aggregator (crt.sh JSON) [verify terms/rate limits],dedupes names, separates wildcards, emits asmfeed-format discovery CSV."""import argparseimport csvimport jsonimport sysimport urllib.requestfrom collections import defaultdictfrom datetime import datetimefrom pathlib import Path
ENDPOINT = "https://crt.sh/?q=%25.{domain}&output=json"
def main(): ap = argparse.ArgumentParser(description="CT enumeration") ap.add_argument("domain", help="brand domain, e.g. mycorp.com") ap.add_argument("--out", default="ct_discovery.csv") ap.add_argument("--timeout", type=int, default=60) args = ap.parse_args()
domain = args.domain.strip().lower().lstrip(".") url = ENDPOINT.format(domain=domain) try: req = urllib.request.Request( url, headers={"User-Agent": "asm-ctenum/1 (defensive inventory)"}) with urllib.request.urlopen(req, timeout=args.timeout) as resp: data = json.loads(resp.read().decode("utf-8", "replace")) except Exception as e: sys.exit(f"fail-loud: CT query failed ({e}) — check network, " "endpoint terms, and rate limits")
names = {} # name -> first_issued (earliest) wildcards = set() for entry in data: for raw in str(entry.get("name_value", "")).splitlines(): n = raw.strip().lower().lstrip("*.") if not n or not n.endswith(domain) and not n.endswith("." + domain) \ and n != domain: continue issued = str(entry.get("entry_timestamp", ""))[:10] if raw.strip().startswith("*."): wildcards.add(raw.strip().lower()) if n not in names or (issued and issued < names[n]): names[n] = issued
if not names: sys.exit(f"fail-loud: zero names for {domain!r} — is the domain " "right, and does it have public certificates?")
rows = sorted(names.items(), key=lambda kv: kv[1] or "") with open(args.out, "w", newline="", encoding="utf-8") as fh: w = csv.writer(fh) w.writerow(["asset", "type", "source", "first_issued"]) for n, issued in rows: w.writerow([n, "subdomain" if n != domain else "domain", "ct-log", issued]) for wc in sorted(wildcards): w.writerow([wc, "domain", "ct-log-wildcard", ""])
fresh = [n for n, i in rows if i and _days_since(i) <= 7] print(f"names={len(rows)} wildcards={len(wildcards)} -> {args.out}") print(f"issued in last 7 days: {len(fresh)}") for n in fresh[:8]: print(f" FRESH: {n} ({names[n]})") print("next: feed to asmfeed as discoveries; FRESH names triage first " "(new infrastructure or evil-twin adjacency — BT18)")
def _days_since(iso_day): try: d = datetime.strptime(iso_day, "%Y-%m-%d") return (datetime.utcnow() - d).days except ValueError: return 9999
if __name__ == "__main__": main()Walkthrough
Section titled “Walkthrough”The name filter (lines 37-45) keeps only names that belong to the brand’s zone (suffix match on the domain) — CT entries for unrelated names sharing a substring are the classic noise source, and the suffix rule removes them without a substring match’s false positives. Earliest-issued tracking (lines 46-50) gives every name its birthdate — the timeline signal that turns “we found staging-eu.mycorp.com” into “we found a name first certified four days ago” (the urgency class). Wildcards (lines 43-44, 57-58) separate into their own class: *.corp.mycorp.com certifies unlimited leaves without enumerating them, which changes the triage (a wildcard is inventory breadth, not a specific asset). The fresh-name highlight (lines 63-68) is the report’s headline — names certified within the week, triaged first, because new certificates are new infrastructure (yours untracked, or the adversary’s evil twin).
Rate-limit and terms discipline (the User-Agent, the single query, the [verify] note) is not decorative: free public aggregators are the program’s shared infrastructure, and the defensive OSINT program that hammers them loses the privilege for everyone — passive collection’s ethics are operational requirements.
Compilation and test procedure
Section titled “Compilation and test procedure”weekly CT enumeration for the brand
Section titled “weekly CT enumeration for the brand”$ python3 ctenum.py mycorp.com —out ct_discovery.csv names=412 wildcards=3 -> ct_discovery.csv issued in last 7 days: 5 FRESH: staging-eu.mycorp.com (2026-10-11) FRESH: vpn-new.mycorp.com (2026-10-12) … next: feed to asmfeed as discoveries; FRESH names triage first
merge with DNS-enum and code-search results, diff against the CMDB
Section titled “merge with DNS-enum and code-search results, diff against the CMDB”$ python3 asmfeed.py “ct_discovery.csv” “dns_enum.csv” “code_hits.csv”
—known cmdb_export.csv
discovered=688 known=640 returning=641
NEW (untracked): 47 -> delta_new.csv
- staging-eu.mycorp.com [subdomain]
- AKIAIOSFODNN7EXAMPLE-host [key] MISSING (CMDB fiction?): 3 -> delta_missing.csv inventory: asm_out/inventory.csv triage hints route: keys->BT15, urls/domains->BT18, subdomains->BT11
Detection considerations
Section titled “Detection considerations”ASM is reconnaissance telemetry for the defender, but its mirrors run on the adversary’s side — and the delta between the two maps is the risk. Two detection joins: first, ASM findings feed BT11 (new hosts enter the scan scope the day they’re discovered — the vulnerability program’s map stops shrinking), BT18 (brand-adjacent names enter the watch), and BT15 (credential findings trigger rotation decisions); second, the adversary’s reconnaissance is detectable — CT-enumeration-then-probe patterns against your subdomain space (the same names probed in sequence from the same source) are the T2 pre-attack signature, and BT2’s rules can catch the scanning phase. The program’s own queries (CT, DNS-over-HTTPS to public resolvers) should originate from the program’s infrastructure with declared user-agents — your reconnaissance shouldn’t be mistaken for (or hide among) the adversary’s.
Improvements to add
Section titled “Improvements to add”- Add DNS-record verification to ctenum (resolve each fresh name immediately — live/dead in the discovery CSV).
- Add code-search source adapters (GitHub/Gist search APIs [verify current terms]) writing asmfeed-format rows.
- Track certificate issuers (Let’s Encrypt velocity from new infra = the staging-class signal).
- Add SaaS/OAuth-grant discovery from the IdP’s audit log (the identity-plane surface — admin-consented third-party apps).
- Wire delta_new.csv directly into ticketing with owner-hunt assignments (the attribution SLA).
Detection — Defender View
Section titled “Detection — Defender View”| Signal | Source | ID | Notes |
|---|---|---|---|
| New assets (untracked) | asmfeed delta | weekly | Attribution SLA |
| Fresh CT names | ctenum | daily | 7-day urgency class |
| Leaked credentials | Code/paste watch | per finding | BT15 rotation NOW |
| CMDB fiction | delta_missing | weekly | Inventory truth |
| Wildcard certs | ctenum | per cycle | Breadth signal |
| Takeover candidates | BT18 join | weekly | Dangling surface |
| Recon-from-outside patterns | BT2 rules | per event | T2 pre-attack phase |
| SaaS OAuth sprawl | IdP audit | monthly | Identity-plane ASM |
| Attribution completeness | asmfeed | weekly | Owner-per-asset SLA |
| Map convergence | CMDB vs ASM diff | quarterly | The program metric |
Evasion Techniques
Section titled “Evasion Techniques”Adversaries evade your OSINT just as you evade detection — and the same techniques apply in both directions. Infrastructure separation: the adversary’s staging infrastructure never touches their attribution cluster (separate registrars, hosting, and personas per campaign) — your ASM’s brand-string enumeration still finds it, which is why brand-adjacent CT watching outranks whois-attribution hunting. Consent-gate avoidance: passive collection (CT, DNS, archives) is unbypassable — the adversary can’t stop you reading public logs, which is precisely why the defensive program anchors there. Name-space camouflage: lookalike and homoglyph names evade exact-match watches (BT18’s containment-scoring covers it); your own forgotten assets evade nothing — the adversary finds them with the same tools. Bait canaries (adversary-side): decoy subdomains and honeytokens detect your reconnaissance — a defensive program probing assets it doesn’t own meets legal exposure; the ownership-verification step (is this ours? check the cert’s internal names, the whois, the billing) precedes any active contact. SaaS invisibility: the shadow-IT class (OAuth grants, unsanctioned SaaS) never appears in DNS at all — IdP-audit ASM (admin-consented apps, inactive-but-privileged integrations) is the only lens that sees it, and it’s the fastest-growing surface class. Archive amnesia: adversaries check the Wayback Machine for your removed content — your ASM mining the same archives for the same endpoints is the defensive mirror, and the old-staging-host-still-responding class is why.
⚠️ What caught me: I ran our first proper ASM cycle confident the CMDB was decent — 640 assets, owned, scanned. The CT enumeration came back with 688 names, and 47 were new: a staging environment in a region we don’t operate in (a contractor’s “temporary” cloud account, running unpatched Jenkins with a default-credentials admin panel), three trade-show demo domains nobody had reconciled after the acquisition, and one AWS access key sitting in a public gist since 2022 — still valid, still able to read a bucket of customer CSVs. The adversary’s recon would have found all four in an afternoon. The CMDB wasn’t wrong so much as it was complete only for what IT remembered — and the internet remembers everything. Since then, discovery-before-inventory is the program’s first principle: the map is built from what’s observable, and the CMDB gets corrected against reality, never the reverse.
Lab Practice
Section titled “Lab Practice”Run a full ASM cycle against a lab domain you own. Enumerate: run ctenum on your lab domain (which has real certificates if you’ve issued any); note the fresh and wildcard classes. Expand: add a DNS-discovery source (your zone file export normalized to asmfeed format) and a synthetic code-hit row. Diff: build a deliberately incomplete known.csv (missing two assets you own) and run asmfeed — confirm both the NEW detection and the MISSING (fiction) detection fire. Attribute: for each NEW asset, hunt the owner (cert internal names, DNS contacts) and time yourself — that time is your attribution SLA baseline. Triage: route one asset through each hint class (a live check, a BT11 scan, a rotation decision rehearsal). Debrief: which source found what nothing else did — and what does that imply for your real program’s source budget?
Interview Questions
Section titled “Interview Questions”- Technical: Walk the passive collection sources by yield — which finds what nothing else can, and why does CT-log watching anchor the program?
- Technical: Explain
asmfeed’s three-way classification (NEW/returning/MISSING) and why “MISSING from discovery but in the CMDB” is itself a finding. - Scenario: A fresh CT name (
staging-eu.mycorp.com) resolves to a Jenkins instance with default credentials, and the cert’s internal name points to a contractor. Walk the first two hours: attribution, scan, containment, and the process fix. - Scenario: Your program finds a 2022-dated AWS access key in a public gist, still valid. Walk the decision: rotation, blast-radius analysis, and what you do about the bucket it could read.
- Conceptual: Why is “the CMDB gets corrected against reality, never the reverse” the program’s first principle?
- Deep-dive: Design the ASM program for a company with three brands, two acquisitions, and heavy SaaS usage: sources, cadence, attribution workflow, and the joins into BT11/BT15/BT18.
- Practical: You have one analyst-day per week for ASM. Which sources do you run, what cadence do you accept, and what do you explicitly declare out of scope?
Related Modules
Section titled “Related Modules”BT18 (Digital Risk & Brand Protection) · BT11 (Vulnerability Management) · BT16 (Cloud Detection & Response) · T2 (Initial Access Emulation) · BT10 (Threat Intelligence Operations)