Skip to content

Threat Intelligence Operations

Threat intelligence operations is the discipline of running intelligence as an operational function inside a security program — not a feed subscription, not an analyst with a dashboard, but a managed cycle: Direction (priority intelligence requirements), Collection (feeds, sharing communities, vendor reporting, your own incident data), Processing (normalization, deduplication, grading), Analysis (correlation, clustering, attribution with humility), Dissemination (the right product to the right consumer at the right latency), and Feedback (did the intelligence change a decision?). The distinction that separates operations from hoarding: data is a raw IOC list from a feed; intelligence is that list scored, contextualized, mapped to the adversary’s behavior, and delivered to someone who can act on it before it matters. David Bianco’s Pyramid of Pain is the operating heuristic — hashes are trivial for an adversary to change, IPs are cheap, domains are moderate, network and host artifacts sting, tools hurt, and TTPs (behaviors) hurt most — so a mature operation consumes behavioral intelligence first and treats bulk IOC feeds as the noise floor, not the product. The craft’s center: every artifact the team produces must trace back to a requirement someone defined on purpose.

A SOC without intelligence operations is permanently reactive: it sees only what its sensors happen to catch, and every campaign ends the same way — the report lands after the incident. A SOC with a real intelligence function gets three structural advantages. First, early warning: collection aimed at your sector and stack surfaces the attacker infrastructure, tooling, and behaviors likely to reach you before the first phish arrives, which converts hunting (BT7) from random walks into requirement-driven sweeps. Second, decision speed: when an alert fires on intel-sourced infrastructure, the analyst inherits context — cluster, motivations, typical follow-on behavior — and triage time drops from hours to minutes. Third, allocation: intelligence tells leadership which adversaries actually target organizations like yours, which is the only honest input to detection prioritization (BT9) and emulation planning (T1).

The failure modes are just as structural. Raw feed ingestion without grading produces FP storms that burn analyst trust — after the tenth bogus block of a shared CDN IP, the watchlist gets ignored, and the ignored watchlist is worse than none. Unmanaged IOC lifetimes rot silently: an IP observed for three days in 2023 is not intelligence in 2026, it is a liability. And intelligence that never reaches a decision-maker — reports nobody reads, indicators nobody loads — is cost with no return. Volume is the ambush: a single mid-tier feed can emit thousands of indicators a day, and unfiltered, that firehose drowns the signal your PIRs actually asked for.

+---------------------+ PIRs, collection plan
Leadership ---| 1. DIRECTION |<---------------------+
(risk owner) +---------------------+ |
| requirements shape collection |
v |
+---------------------+ +---------------------+ |
| 2. COLLECTION | | 3. PROCESSING | |
| feeds / TAXII / MISP|---->| normalize, dedupe, | |
| vendor reports | | grade (A-F / 1-6), | |
| your own DFIR (BT5) | | decay-score, gate | |
+---------------------+ +---------------------+ |
| scored, contextualized |
v |
+---------------------+ +---------------------+ |
| 4. ANALYSIS |---->| 5. DISSEMINATION | |
| cluster, correlate, | | watchlist -> SIEM | |
| ATT&CK-map, assess | | hunt pkg -> BT7 | |
| confidence | | flash -> SOC, brief | |
+---------------------+ +----------+----------+ |
| consumed |
v |
+---------------------+ |
| 6. FEEDBACK |----------+
| sightings, FP rates,|
| decisions changed |
+---------------------+

Direction is where programs fail first. Without Priority Intelligence Requirements — “which ransomware crews target our sector’s ERP stack?”, “what initial-access TTPs are rotating this quarter?” — collection degenerates into subscribing to everything and reading nothing. PIRs are written by the risk owner, reviewed quarterly, and each one names its consumers and its decision: if we learned X, who would do what differently?

Collection spans four sources: commercial and free feeds (bulk indicators, high volume, low context), curated sharing communities (MISP nodes, ISAC/ISAO traffic — sector-relevant and pre-vetted), vendor reporting (rich TTP narratives, but delayed and marketing-filtered), and — the source most teams underrate — your own incidents (BT5’s forensics produce IOCs and TTPs with zero relevance risk). Standards handle the plumbing: STIX 2.1 models the objects (indicator, malware, intrusion-set, relationship), TAXII 2.1 moves collections over HTTPS, and platforms like MISP or OpenCTI manage the graph and sharing groups.

Processing is where raw becomes ranked. Each indicator gets an Admiralty-style grade — source reliability A (completely reliable) through F (unreliable), information credibility 1 (confirmed) through 6 (improbable) — plus corroboration credit for independent sightings, then an exponential decay score tuned per type: an IP’s half-life is days, a hash’s is months, because adversaries rotate infrastructure far faster than tooling. The gate then routes by score: watchlist (auto-load to SIEM), hunt list (BT7 sweeps), archive (context only).

Analysis turns scored artifacts into assessed behavior: clustering related IOCs into campaigns (M15 does this on the malware side; operations does it on the infrastructure and narrative side), mapping clusters to ATT&CK techniques, and stating confidence honestly. Attribution deserves special humility: the same cluster routinely carries multiple vendor names — APT28 / Fancy Bear / Forest Blizzard is one well-documented example — so track clusters of behavior, publish the mapping, and never let a name imply courtroom certainty.

Deep dive: why the Pyramid of Pain still holds

Bianco’s 2013 model survives because it encodes an economic truth: changing indicators costs the adversary effort proportional to the pyramid level. Swapping a hash costs a recompile; swapping infrastructure costs registrar time and hosting fees; abandoning a tool costs their entire development and familiarity investment; changing TTPs means retraining operators and re-earning their own operational security habits. Modern infrastructure-as-a-service and CDN fronting have pushed the network levels slightly cheaper than the model implies — bulletproof hosting and domain-fronting rotate IPs cheaply — which is exactly why decay scores must be type-tuned and behavioral detections (TTP-level) must anchor the program. The pyramid is not a collection taxonomy; it is a budget of pain per dollar of adversary effort.

TechniqueWhat it doesConsumerValue
PIR definitionAnchors collection to decisions, not curiosityLeadership, whole teamKills feed fatigue
Admiralty grading (A-F/1-6)Scores source reliability × information credibilityProcessing pipelineRank before ingest
IOC decay modelingExponential half-life per type (IP days, hash months)Watchlist gateStops stale-indicator FPs
Feed curation & gatingAccept/deny sources by measured FP rateIntel opsTrust earned per source
Campaign clusteringGroups IOCs/behavior into named activity setsAnalysis, M15 joinContext beats noise
ATT&CK mapping of reportsVendor narrative → technique matrix rowsBT9, T1Coverage joins
Hunt-package generationIntel → hypothesis + dataset + query for BT7Threat huntingIntel becomes action
Sighting feedback loop“We saw it” flows back to source/sharing groupCommunity, feedsConfidence compounds
False-flag screeningWatch for borrowed TTPs, time-zone anomalies, language artifactsAnalysisAttribution hygiene
Strategic productsQuarterly threat landscape vs. your stackLeadershipBudget-level decisions

iocgate — an IOC lifecycle gate: ingests feed files (JSON-lines or CSV), normalizes and deduplicates, grades with Admiralty reliability × credibility, applies corroboration credit and per-type exponential decay, then routes indicators into watchlist (SIEM), huntlist (BT7) and archive buckets. Deterministic given --as-of, so you can replay a week of feeds and prove the gate behaved.

#!/usr/bin/env python3
"""iocgate — IOC lifecycle gate: ingest, dedupe, grade, decay, route.
Feed line format (JSONL) or CSV columns:
value, source, grade (Admiralty A1..F6), first_seen (ISO date), comment
Score = (reliability x credibility) x (1 + corroboration) x age-decay
Route: score >= 0.70 watchlist, >= 0.40 huntlist, else archive.
"""
import argparse
import csv
import json
import math
import sys
from datetime import date, datetime
from pathlib import Path
HALF_LIFE = {"ip": 7.0, "domain": 14.0, "url": 10.0,
"sha256": 365.0, "md5": 180.0} # days, per IOC type
RELIABILITY = {"A": 1.00, "B": 0.85, "C": 0.70,
"D": 0.50, "E": 0.30, "F": 0.10}
CREDIBILITY = {1: 1.00, 2: 0.90, 3: 0.80,
4: 0.60, 5: 0.40, 6: 0.20}
HEX = set("0123456789abcdef")
WATCH_MIN, HUNT_MIN = 0.70, 0.40
def classify(value: str):
v = value.strip().lower()
if len(v) == 64 and set(v) <= HEX:
return "sha256"
if len(v) == 32 and set(v) <= HEX:
return "md5"
parts = v.split(".")
if len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
return "ip"
if v.startswith(("http://", "https://")):
return "url"
if len(parts) >= 2 and all(p and p.isalnum() or p == "-" for p in parts):
return "domain"
return None
def parse_grade(raw: str):
g = raw.strip().upper()
if len(g) == 2 and g[0] in RELIABILITY and g[1] in "123456":
return g
return None
def parse_date(raw: str):
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(raw.strip()[:19], fmt).date()
except ValueError:
continue
return None
def score_ioc(grade: str, first_seen: date, sources: set, as_of: date, ioc_type: str):
rel = RELIABILITY[grade[0]]
cred = CREDIBILITY[int(grade[1])]
corroborators = max(0, len(sources) - 1) # extra independent sources
boost = min(0.45, 0.15 * corroborators) # capped corroboration credit
age_days = max(0.0, (as_of - first_seen).days)
decay = math.pow(0.5, age_days / HALF_LIFE[ioc_type])
return round(min(1.0, rel * cred * (1.0 + boost)) * decay, 3)
def load_feed(path: Path):
rows, errors = [], []
text = path.read_text(encoding="utf-8", errors="replace")
if path.suffix.lower() == ".csv":
reader = csv.DictReader(line for line in text.splitlines() if line.strip())
for n, row in enumerate(reader, 2):
rows.append((n, {k: (row.get(k) or "") for k in
("value", "source", "grade", "first_seen", "comment")}))
else:
for n, line in enumerate(text.splitlines(), 1):
line = line.strip()
if not line or line.startswith("#"):
continue
try:
obj = json.loads(line)
except json.JSONDecodeError as e:
errors.append(f"{path.name}:{n}: bad JSON ({e})")
continue
rows.append((n, {k: str(obj.get(k, "") or "") for k in
("value", "source", "grade", "first_seen", "comment")}))
return rows, errors
def main():
ap = argparse.ArgumentParser(description="IOC lifecycle gate")
ap.add_argument("feeds", nargs="+", type=Path, help="feed files (.jsonl / .csv)")
ap.add_argument("--as-of", default=date.today().isoformat(),
help="evaluation date YYYY-MM-DD (default: today)")
ap.add_argument("--out", type=Path, default=Path("iocgate_out"),
help="output directory (default ./iocgate_out)")
args = ap.parse_args()
as_of = parse_date(args.as_of)
if as_of is None:
sys.exit(f"fail-loud: cannot parse --as-of {args.as_of!r}")
pool = {} # (type,value) -> record
bad = []
for feed in args.feeds:
if not feed.exists():
sys.exit(f"fail-loud: feed not found: {feed}")
rows, errors = load_feed(feed)
bad.extend(errors)
for lineno, row in rows:
ioc_type = classify(row["value"])
grade = parse_grade(row["grade"])
seen = parse_date(row["first_seen"])
src = row["source"].strip() or feed.stem
if ioc_type is None:
bad.append(f"{feed.name}:{lineno}: unclassifiable value {row['value']!r}")
continue
if grade is None:
bad.append(f"{feed.name}:{lineno}: bad grade {row['grade']!r}")
continue
if seen is None:
bad.append(f"{feed.name}:{lineno}: bad first_seen {row['first_seen']!r}")
continue
key = (ioc_type, row["value"].strip().lower())
rec = pool.setdefault(key, {"sources": set(), "grades": [], "seen": seen})
rec["sources"].add(src)
rec["grades"].append(grade)
rec["seen"] = min(rec["seen"], seen) # earliest observation wins
watch, hunt, archive = [], [], []
for (ioc_type, value), rec in sorted(pool.items()):
best = max(rec["grades"]) # most favorable grade on record
sc = score_ioc(best, rec["seen"], rec["sources"], as_of, ioc_type)
out = {"type": ioc_type, "value": value, "score": sc,
"grade": best, "first_seen": rec["seen"].isoformat(),
"sources": sorted(rec["sources"])}
if sc >= WATCH_MIN:
watch.append(out)
elif sc >= HUNT_MIN:
hunt.append(out)
else:
archive.append(out)
args.out.mkdir(parents=True, exist_ok=True)
for name, bucket in (("watchlist", watch), ("huntlist", hunt), ("archive", archive)):
with (args.out / f"{name}.csv").open("w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=["type", "value", "score",
"grade", "first_seen", "sources"])
w.writeheader()
for item in bucket:
w.writerow({**item, "sources": "|".join(item["sources"])})
print(f"as-of {as_of} ingest={len(pool)} "
f"watch={len(watch)} hunt={len(hunt)} archive={len(archive)}")
print(f"rejected lines: {len(bad)}")
for b in bad[:10]:
print(" ", b)
if not pool and not bad:
sys.exit("fail-loud: no indicators ingested — check feed format")
if __name__ == "__main__":
main()

The type table (lines 20-26) encodes the operational judgment: half-lives differ by an order of magnitude between ip (7 days) and sha256 (365 days) because infrastructure rotates faster than tooling — these constants are your environment’s tuning knobs, not universal truths. classify() (lines 32-44) tests in strict order — hex hashes first (length + alphabet), then dotted-quad IP with range validation, then URL scheme, then domain shape — and returns None for anything ambiguous, refusing to guess rather than polluting the watchlist.

parse_grade() (lines 47-52) enforces the two-character Admiralty shape (A1..F6); anything else is rejected loudly. parse_date() (lines 55-62) accepts the four formats feeds actually use, because normalization lives here, not in the consumer. score_ioc() (lines 65-72) is the heart: reliability × credibility gives the base, each independent source beyond the first adds 15% corroboration capped at +45% (many feeds syndicate the same upstream data — corroboration must mean independence), then math.pow(0.5, age_days / half_life) applies exponential decay, and the product is clamped to 1.0.

load_feed() (lines 75-101) dispatches on extension and fails soft per line but loud in aggregate — bad lines accumulate in errors with file:line context and are printed at the end, so a malformed feed degrades gracefully without silently vanishing. main() (lines 104-160) deduplicates by (type, value): sources merge into a set for corroboration, grades keep the most favorable for scoring, and first_seen keeps the earliest observation — because age should be measured from when the indicator was first seen, not when you last saw it. The three buckets write as CSV (lines 143-152) ready for SIEM reference-set loads, and the fail-loud exit (line 160) fires when the run ingested nothing at all — an empty watchlist must mean “no intel today”, never “broken pipeline nobody noticed”.

The pipeline runs end-to-end like this. A vendor publishes a report on a phishing crew targeting your sector; the IOC appendix lands in your MISP node (or pasted into a ticket — accept both). iocgate ingests it alongside the standing feeds: the 40 domains the vendor cites arrive with grade B2, corroboration fires because three domains overlap with last week’s URLhaus pull, and the watchlist CSV loads into the SIEM as a reference set joined against Sysmon EID 22 (DNS query) and EID 3 (network connection) telemetry. Nine days later the first sighting hits — a workstation resolving one of the domains — and the alert carries intel context into BT1’s triage queue: cluster name, typical follow-on behavior (credential phishing, then mailbox rules), recommended next queries.

The sighting flows back out: loaded into MISP as a sighting on the attribute, it raises corroboration for every other consumer in the sharing group, and the feed owner’s confidence in their own collection improves — intelligence compounds when feedback loops close. Meanwhile the report’s TTP narrative goes to BT9 for Sigma coverage checks (do we detect the mailbox-rules behavior? which rule?) and to BT7 as a hunt package: hypothesis, dataset scope, baseline. Six weeks later the same domains have decayed below the watch threshold and drop into archive — automatically, no FP storm, and the reference set shrinks on the same schedule. Report to watchlist to alert to sighting to decay: the intelligence cycle compressed into artifacts you can audit.

  1. Direction — risk owner writes PIRs: sector-relevant adversaries, initial-access rotation, ransomware pre-staging behaviors
  2. Collection — MISP/TAXII pulls, vendor reports, ISAC traffic, and DFIR-derived IOCs from your own BT5 casework
  3. Processing — iocgate normalizes, dedupes, Admiralty-grades, decay-scores, and routes: watchlist / huntlist / archive
  4. Analysis — cluster the scored IOCs into campaigns, ATT&CK-map the narratives, state confidence honestly
  5. Dissemination — SIEM reference sets, BT7 hunt packages, SOC flash alerts, quarterly leadership brief
  6. Detection — watchlist join fires on Sysmon EID 22/3 telemetry; alert enters BT1 triage with intel context attached
  7. Feedback — sighting reported to source and sharing group; FP rate per feed measured; PIRs re-scored quarterly

The gate is half the operation; the other half is turning reports into coverage. report2attack reads any raw intel report (paste the text), extracts ATT&CK technique references — explicit T1566.001-style IDs plus a named-behavior mapping for the phrases reports actually use — and diffs the result against your detection inventory (the CSV BT9’s coveragegap exports) to emit a Navigator-ready layer JSON and a gap report.

Regex extraction first, ML never: T-codes are unambiguous, and the named-behavior table covers the twenty phrases that carry 80% of report prose (“LSASS memory”, “scheduled task”, “spearphishing attachment”). Every mention counts — frequency is a weak proxy for emphasis, but it is honest and debuggable. Coverage status comes only from the inventory’s deployed rows; planned and hunting statuses are reported separately so the gap list drives BT9 authoring priorities, not false confidence. The Navigator JSON uses the documented layer schema with scores 0-100 so the layer renders on the standard gradient; the version strings must match your Navigator instance.

#!/usr/bin/env python3
"""report2attack — extract ATT&CK techniques from an intel report and diff
against the detection inventory. Emits Navigator layer JSON + gap report."""
import argparse
import csv
import json
import re
import sys
from collections import Counter
from pathlib import Path
TCODE = re.compile(r"\bT\d{4}(?:\.\d{3})?\b")
# phrase -> technique (lowercase phrase, ATT&CK enterprise IDs)
NAMED = {
"spearphishing attachment": "T1566.001",
"lsass": "T1003.001",
"scheduled task": "T1053.005",
"scheduled tasks": "T1053.005",
"powershell": "T1059.001",
"rundll32": "T1218.011",
"new service": "T1543.003",
"create service": "T1543.003",
"registry run key": "T1547.001",
"run key": "T1547.001",
"defense evasion: obfuscation": "T1027",
}
def extract(text: str):
counts = Counter()
for m in TCODE.findall(text):
counts[m.upper()] += 1
low = text.lower()
for phrase, tech in NAMED.items():
n = low.count(phrase)
if n:
counts[tech] += n
return counts
def load_inventory(path: Path):
if not path.exists():
sys.exit(f"fail-loud: inventory not found: {path}")
deployed, other = set(), {}
with path.open(encoding="utf-8") as fh:
for row in csv.DictReader(fh):
tid = (row.get("technique_id") or "").strip().upper()
if not tid:
continue
status = (row.get("status") or "").strip().lower()
if status == "deployed":
deployed.add(tid)
else:
other.setdefault(tid, status)
return deployed, other
def main():
ap = argparse.ArgumentParser(description="report -> ATT&CK layer + gap report")
ap.add_argument("report", type=Path, help="intel report as plain text")
ap.add_argument("--inventory", type=Path, required=True,
help="BT9 coveragegap CSV (technique_id,rule_name,status)")
ap.add_argument("--name", default="report extraction",
help="layer name (default: report extraction)")
ap.add_argument("--out", type=Path, default=Path("r2a_out"))
args = ap.parse_args()
text = args.report.read_text(encoding="utf-8", errors="replace")
counts = extract(text)
if not counts:
sys.exit("fail-loud: no techniques extracted — is this a security report?")
deployed, other = load_inventory(args.inventory)
techniques = []
for tid, mentions in counts.most_common():
sc = min(100, 40 + 20 * min(3, mentions))
techniques.append({"techniqueID": tid, "score": sc})
layer = {
"name": args.name,
"versions": {"attack": "14", "navigator": "4.9.1", "layer": "4.5"},
"domain": "enterprise-attack",
"description": f"techniques extracted from {args.report.name}",
"filters": {"platforms": ["Windows"]},
"techniques": techniques,
"gradient": {"colors": ["#ff6666", "#ffe766", "#8ec843"],
"minValue": 0, "maxValue": 100},
}
args.out.mkdir(parents=True, exist_ok=True)
(args.out / "layer.json").write_text(json.dumps(layer, indent=2), encoding="utf-8")
gaps, covered, planned = [], [], []
for tid, _m in counts.items():
if tid in deployed:
covered.append(tid)
elif tid in other:
planned.append((tid, other[tid]))
else:
gaps.append(tid)
with (args.out / "gap_report.csv").open("w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow(["technique_id", "mentions", "coverage"])
for tid, mentions in counts.most_common():
state = ("deployed" if tid in deployed
else other.get(tid, "GAP — no coverage entry"))
w.writerow([tid, mentions, state])
print(f"extracted {len(counts)} techniques from {args.report.name}")
print(f" covered : {len(covered)} -> {', '.join(covered[:8]) or '-'}")
print(f" planned : {len(planned)}")
print(f" gaps : {len(gaps)} -> {', '.join(gaps[:8]) or '-'}")
print(f"layer JSON : {args.out / 'layer.json'} (import into ATT&CK Navigator)")
if gaps:
print("next: hand the gap list to BT9 authoring, highest-mention first")
if __name__ == "__main__":
main()

TCODE (line 12) matches sub-technique syntax explicitly — T1053.005 captures whole, plain T1053 captures alone — and the \b anchors stop T1566.001 from matching inside longer identifiers. NAMED (lines 15-27) maps the prose phrases vendors actually use; keep it honest — each entry must be a real technique ID, and the phrase list is your dialect of report-speak. extract() (lines 29-38) counts explicit codes first, then phrase hits on lowercased text, and returns a Counter for natural emphasis order.

The score model (line 66) is deliberately transparent: 40 base + 20 per mention capped at three mentions (100 max). It is a ranking, not a probability — the Navigator layer just needs relative emphasis to render the gradient. load_inventory() (lines 41-55) splits the BT9 export into deployed (coverage credit) and other (status preserved for the report), skipping rows without a technique ID. The gap classification (lines 82-89) is three-way — covered, planned-with-status, and true gaps — because “planned” is authoring backlog (BT9’s queue), while “gap” means nobody has ever considered this technique: the most dangerous category, and the one the Navigator layer’s red end highlights. Fail-loud on zero extraction (line 63) guards the classic silent failure: pointing the tool at a PDF’s binary bytes.

extract text from a vendor PDF report (any text extraction works)

Section titled “extract text from a vendor PDF report (any text extraction works)”

$ pdftotext vendor_q3_report.pdf report.txt

run against your BT9 detection inventory export

Section titled “run against your BT9 detection inventory export”

$ python3 report2attack.py report.txt —inventory detections.csv —name “vendor Q3” extracted 14 techniques from vendor_q3_report.pdf covered : 6 -> T1566.001, T1059.001, T1053.005, T1003.001, T1218.011, T1547.001 planned : 2 gaps : 6 -> T1027, T1543.003, … layer JSON : r2a_out/layer.json (import into ATT&CK Navigator)

verify the layer JSON parses and techniques are valid IDs

Section titled “verify the layer JSON parses and techniques are valid IDs”

$ python3 -c “import json;d=json.load(open(‘r2a_out/layer.json’));print(len(d[‘techniques’]),‘techniques’)” 14 techniques

sanity: no extraction on non-report input must fail loudly

Section titled “sanity: no extraction on non-report input must fail loudly”

$ echo “quarterly earnings summary” > notreport.txt $ python3 report2attack.py notreport.txt —inventory detections.csv; echo “exit=$?” fail-loud: no techniques extracted — is this a security report? exit=1

The tool outputs intelligence products, not detections — but its gap report feeds BT9’s authoring backlog, and any technique that graduates to a Sigma rule inherits the report citation as rule metadata, closing the provenance loop (intel → rule → validation → BT20 metric). Watch two failure modes operationally: first, report bias — vendors over-report what their product sees, so a Navigator layer built from one vendor’s output skews your roadmap toward their visibility; mitigate by extracting across at least three independent sources. Second, mention inflation — a technique named twelve times in one report is not twelve observations; the capped score model exists precisely so one loud report cannot dominate a quarter’s roadmap.

  1. Ingest MITRE ATT&CK STIX bundles to validate every extracted ID against the live enterprise matrix and flag revoked/deprecated techniques.
  2. Add relationship extraction — “malware X uses technique Y” sentences — to attribute techniques to named clusters in the output.
  3. Accept multiple reports per run and emit per-source layers plus a merged view, making source bias visible.
  4. Persist run history (report hash → extraction) so repeated extractions are deduped and drift is visible.
  5. Emit Sigma skeleton stubs for the top-N gaps directly, pre-filled with technique metadata, to shorten BT9’s authoring loop.
SignalSourceIDNotes
Intel watchlist DNS hitSysmonEID 22Reference-set join, highest-value
Watchlist network connSysmonEID 3Pair with EID 22 for confidence
Watchlist process hitSysmonEID 1Hash-based matches
Watchlist service installWindows Security7045Tooling markers
Feed FP rate per sourceiocgate + BT1per feedGating input, reviewed monthly
Sighting feedback countMISP sighting APIper clusterLoop-closing metric
Report-to-rule latencyBT10→BT9daysDissemination speed
Gap list burn-downreport2attack → BT9per quarterAuthoring priorities
Stale-expiry rateiocgate archiveper cycleDecay tuning check
PIR coverageDirection reviewquarterlyRequirements honesty

Adversaries run counter-intelligence deliberately. Infrastructure rotation is industrialized: domains behind CDN fronting and domain-generation algorithms invalidate IP/domain IOCs on a schedule tuned to defender collection cycles — if they observe weekly feed refreshes, staging hosts live six days. Living off trusted services hides staging inside shared infrastructure defenders fear to block: payloads behind legitimate URL shorteners, file-sharing CDNs, and cloud content hosts, where a block costs more in FPs than the indicator is worth. False-flagging pollutes attribution: operators borrow another cluster’s tooling, plant foreign-language artifacts, or shift activity into the target’s local business hours to corrupt the time-zone analysis researchers rely on. Researcher canaries work the other direction — actors seed decoy infrastructure and watch who touches it, burning undercover collection. And sophisticated crews score their own exposure: before deploying staging infrastructure they check it against VirusTotal and popular blocklists, exactly the way iocgate scores yours, and rebuild anything already carrying a detection verdict.

⚠️ What caught me: I loaded a feed’s entire daily export — 4,200 indicators, no grading, straight into the SIEM reference set — and by afternoon we had blocked a shared CDN edge IP that 60 legitimate customer sites used. The block “worked” for the eleven minutes before the rollback, and it cost me two weeks of analyst trust; after that, the watchlist was treated as optional reading. The fix was embarrassingly simple: iocgate’s decay score cut the ingest to 240 indicators, of which 31 hit watch grade — and the same week’s catch rate went up, because analysts finally believed the list. Feeds don’t fail from lack of data; they fail from lack of discrimination.

Build a two-week intelligence operation in miniature. Setup: subscribe to two free feeds with different characters — abuse.ch URLhaus (URLs, high churn) and Feodo Tracker (C2 IPs, medium churn) [verify current export endpoints at time of lab] — plus one vendor report converted to text. Direction: write three PIRs first; every artifact below must trace to one. Processing: run iocgate over a week of accumulated feed pulls with --as-of set per day, watch scores decay, and tune half-lives until the archive churn looks sane (target: watchlist ≤ 10% of raw ingest). Dissemination: load the watchlist into your SIEM as a reference set, then report2attack the vendor report against a sample detection inventory CSV (five rows is enough) and import the Navigator layer. Feedback: for every synthetic sighting you inject, log which PIR it served and measure feed FP rates for the week — one feed will embarrass itself. Debrief: which feed earned permanent collection status, and what grade did your own incident-derived IOCs earn?

  • Technical: Walk the six-phase intelligence cycle and name the phase most programs skip — then defend why feedback is the phase that makes the rest honest.
  • Technical: How does iocgate’s scoring model combine Admiralty grading, corroboration, and decay — and why is corroboration credit capped at +45%?
  • Scenario: A feed you’ve trusted for a year starts emitting indicators that correlate with your own benign asset inventory. Walk the decision: how fast do you gate it, and what evidence do you show the vendor?
  • Conceptual: Explain the Pyramid of Pain and why CDN fronting pushed the network levels cheaper — and what that shift implies for where a program anchors its effort.
  • Conceptual: Why is attribution published as cluster tracking rather than courtroom proof? Use a documented multi-name cluster as your example.
  • Deep-dive: Design the intelligence function for a 40-analyst SOC: PIR process, collection plan with budgets, the processing pipeline, dissemination products per audience, and the feedback metrics.
  • Practical: Your watchlist generated 40 FPs last week and triage is ignoring intel alerts. Diagnose in the first two hours — what do you measure first, and what do you gate?

BT7 (Threat Hunting) · BT9 (Detection Engineering & Sigma) · BT18 (Digital Risk & Brand Protection) · BT20 (Detection Program Metrics & Maturity) · M15 (Threat Intelligence and Campaign Clustering)