Verification Policy
Defends the pipeline's published entries against hallucination, vendor hype, fake-news patterns common in CTI feeds, and silent drift over time.
Two-source rule, with carve-outs
Default: every claim must be corroborated by ≥2 independent reputable sources before an entry publishes (verification: multi-source). Reputable means a publisher present in sources/sources.json with status: "active" and a NATO Admiralty reliability of at least C (A/B/C — i.e. not D/E/F), or a previously unseen publisher with a clearly verifiable editorial track record (in which case the agent also proposes them as a candidate source). Independence is about first-hand observation, not count — six rewrites of one wire story are one source.
National-CERT carve-out (verification: single-source-national-cert): when a high-reliability (Admiralty A / B) national CERT or government cybersecurity authority is the primary disclosing party for its own jurisdiction or for an advisory it owns, single-source is acceptable.
<!-- ORG-PROFILE:BEGIN org-certs -->
<!-- GENERATED from config/org-profile.yaml — do not edit by hand; edit the config and run: python3 tools/compose_prompts.py --write -->
National-CERT single-source carve-out list — a high-reliability (Admiralty A / B) national CERT / government cybersecurity authority acting as the primary disclosing party for its own jurisdiction or an advisory it owns is acceptable as a single source: NCSC-CH, GovCERT.ch, CERT-EU, ENISA, BSI, ANSSI/CERT-FR, NCSC-UK, NCSC-NL, CISA, CCN-CERT, AGID-CSIRT-IT, CERT.at, CERT-PL. The list is deployment-configurable (national_certs in config/org-profile.yaml); treat it as the trust bar, illustrative rather than exhaustive for same-tier authorities.
<!-- ORG-PROFILE:END org-certs -->
The reasoning: these organisations are the authoritative source for advisories they issue. Their commentary on someone else's disclosure still requires the standard two-source rule.
Victim-own-disclosure carve-out (verification: single-source-victim): a victim's own regulatory filing (SEC 8-K, regulator notice) or public statement about its own incident is authoritative for what it discloses, even before independent press coverage exists.
Everything else single-source is verification: single-source with a sourcing_note naming the situation — the renderer surfaces the badge so the reader sees the reduced confidence.
Contradictions (verification: contradicted) are surfaced in the run record's verification notes AND on the entry, never silently resolved by picking a side.
Fake-news patterns to defend against
Ransomware leak-site claims
Frequently inflated; sometimes wholly fabricated. Some groups list victims they breached only superficially, list re-extorted victims twice, or list organisations they never touched as "marketing".
Rule: never publish a leak-site claim as fact unless the named victim has confirmed (or pointedly declined to confirm), or a high-reliability (Admiralty A / B) journalist with original sourcing has corroborated. Mirror-data-only (ransomware.live / ransomlook.io) is an observation that the group claimed X, not that X is true. Phrase accordingly or drop.
Hallucinated CVE numbers
Sub-agents (and humans) sometimes invent CVE identifiers that look plausible but do not exist, or transpose digits.
Rule: verify any CVE entering an entry's cves[] or body resolves on https://nvd.nist.gov/vuln/detail/CVE-YYYY-NNNNN or MITRE. If it does not resolve and no official equivalent exists, the CVE is dropped and the underlying claim re-checked or removed.
AI-generated security blogspam
Sites with anonymous "authors", AI-generated stock images, and confidently stated wrong details. They aggregate other people's reporting, sometimes invert facts, and do not respond to corrections.
Rule: no named author byline, no editorial-standards page, no corrections track record, LLM-pattern prose → discovery only; trace to primary. Never cite as primary or corroborating.
Vendor press releases dressed as research
Rule: separate the technical claim from the product pitch. The campaign claim is includable if it stands on its own; the product-efficacy claim is not.
Months-old news as "new"
Rule: check the original event date, not the article date — that is what the entry's event_date field records. First English coverage of a weeks-old foreign-press story is still timely; the third re-statement of three-week-old news is not.
Sweeping attribution claims
Rule: only accept attribution from organisations with a track record and a stake in being right (frontline IR vendors, national authorities, peer-reviewed research). From anyone else, attribute the claim, not the actor: "ESET reports the campaign matches the TTPs of X" — never "X is behind it". Entity links (entities:) follow the same rule — link the actor the source names, with the claim attributed in the body.
Telegram / X-only sourcing
Rule: never publish a claim sourced only from a Telegram channel, X/Twitter post, Mastodon toot, or LinkedIn update. Discovery only; trace to a publication.
Recency sanity check
Every entry reflects events inside the run's gap-derived recency window (window_hours — see prompts/cti-run.md PD-7; developing stories up to developing_window_hours). Older material qualifies only as: a material new development on covered ground (→ update_of entry, delta only); a vendor advisory freshly relevant (e.g. quietly KEV-listed today); or a national-CERT publication today referencing prior activity. In every case event_date states the original date so the reader is never misled.
Quality-gate checklist (agent self-check before the mechanical gate)
- [ ] Every claim has an inline link to a source fetched this run; every URL in
sources[]was actually fetched and matches its claim. - [ ] Zero IOCs anywhere (hashes, IPs, attacker domains/URLs, rule code).
- [ ] Zero vanity metrics.
- [ ] No in-window duplication (incl. earlier runs today): repeats are
update_ofentries with a material delta or dropped. - [ ] Every entry's
verificationvalue is correct:multi-sourceneeds ≥2 independent sources; carve-outs named insourcing_note. - [ ] Classification set on EVERY entry — never zero ratings: every non-triage-kind entry carries a valid Admiralty
classification(reliability A–F tracking the cited source's own letter; credibility 1–6 tracking corroboration, assessed independently); triage-kind entries carryorg_triage+classification: nullwhen a triage scheme is configured, and the Admiralty block like every other kind when none is (check_run.pyFAILs a missing rating from v3.18). - [ ] CVE identifiers verified against NVD/MITRE;
cves[]records complete (type/vector/auth/status). - [ ]
evidence[]present and verbatim on every critical-priority and exploited-status entry — each quote a contiguous substring of a fetched page (no ellipsis splices, no re-hedging; two passages = two records). - [ ]
techniques[]carries every ATT&CK id the body maps and nothing the body doesn't describe — and is never empty on athreat/incident/vulnerabilityentry (the access/exploitation vector is always mappable;check_run.pyFAILs it from v3.18); ids woven into prose at the behavior they name (no bare ID lists);affected_products[]names only products the cited sources name; any**Triage:**discriminator follows from the cited mechanism (omitted, never invented, when the sources give no basis). - [ ] All entity references resolve in
entities/registry.yaml(aliases checked); new entities registered with sourced definitions. - [ ]
prioritycalibrated (critical ⇔ immediate_action bar; high ⇔ TL;DR-worthy); every entry clears the strict relevance/actionability gate (no count target or ceiling). - [ ]
actions[]clears the do-now bar (prompts/cti-run.mdPhase 4 §actions[]): only concrete, finding-derived, start-now tasks; no generic advice, no body restatement, no in-window duplicates; empty where nothing qualifies. - [ ] Deep-dive treatment reserved for an item that earns it; category rotation applied.
- [ ] Run record complete: telemetry, verification counters, notes with drops / single-source / contradictions / parseable lines.
- [ ]
python3 tools/check_run.py "$RUN_ID"exits 0 BEFORE the verification sub-agent is spawned — the verifier reads output whose schema / URLs / taxonomy / dedup are already mechanically clean. - [ ] Phase 5.7 verification sub-agent ran ≥1 iteration (≥2 for a CLEAN publish) covering both URL truth and editorial quality; confirmed CLEAN (two consecutive CLEAN verdicts on two different models) within ≤8 iterations, or residuals / a
confirmation_waivedreason logged in the run record. - [ ] No
sources[]URL on the hard-blocked pattern list (NVD/MITRE/cve.org per-CVE pages, homepages, category landings, advisory indexes — enforced bytools/check_run.py). - [ ] Every
sources[]URL returns HTTP 200 on a live HEAD/GET at commit time (ledger-cached fetches trusted).
Phase 5.7 — Final verification sub-agent (URL truth + editorial quality)
After entries and the run record are written, state is updated, and tools/check_run.py exits 0 (the cheap mechanical gate runs first), an independent verification sub-agent reads the run's output end to end — every new entry (frontmatter AND body) plus the run record — as a hostile, technically-fluent SOC reader with no memory of how the run was assembled. The gate to publish is a confirmed CLEAN — two consecutive iterations, on two different models, both returning verdict CLEAN (v3.23; the 8-iteration cap — raised from 5 in v3.27 — and the low-residual early exit are the fail-open safety valves). A single model's CLEAN never publishes alone: the rotation puts the confirmation pass on the other model, so one model's blind spot is never the last word.
Truth gate. Every URL fetched; every claim cross-checked against its linked source; every named entity traced to a fetched source; every evidence[] quote confirmed verbatim; frontmatter ⇔ body agreement (a summary claiming more than the body's sources support is a hallucination with a notification blast radius).
Editorial-quality gate. Relevance to the profiled organization; primary-source strength (vendor PSIRT / research lab / regulator / victim first — NVD/CERT second-tier); priority calibration; action-item discipline (actions[] do-now bar); correct update-vs-new decisions; vendor-marketing tells; fake-news patterns; contradictions; clarity for a Tier 2 responder; missed angles.
The verifier's finding categories (F1–F18), report format, and compact-summary contract live in .claude/agents/cti-verification.md. Verifier-model rotation: odd iterations spawn cti-verification (Opus default), even iterations cti-verification-alt (Sonnet default) — byte-identical operational prompts, different model pins — so model-specific blind spots are caught across iterations; even iterations receive the prior iteration's findings + applied remediations so the alternate model verifies fixes instead of flip-flopping. The rotation also carries the double-CLEAN gate: the confirming iteration is by construction a different model, so every CLEAN publish rests on two independent models agreeing.
Iterative refinement loop (double-CLEAN to publish; cap 8 — fail-open safety valve, not goal)
Remediation per finding type, re-run tools/check_run.py, fresh re-spawn — the full loop, decision rules (incl. the CLEAN-confirmation pass) and remediation table live in prompts/cti-run.md Phase 5.7. Up to 3 follow-up cti-research sub-agents per iteration for Needs more research / Missed angles. Iteration counters and per-iteration findings land in the run record's verification block — the Ops dashboard at /ops/ and the per-run pages at /runs/<run-id>/ surface cap-breaches and unconfirmed-CLEAN waivers.
Operator review pattern
Periodically (e.g. weekly), a human operator reviews:
git log -- sources/sources.jsonfor demotions and candidates; promote or revert as warranted.- Recent run records' verification-notes bodies for recurring single-source patterns or repeated drops (a missing source, or a quality issue with an existing one).
entities/registry.yamlgrowth for duplicate or drifting entity definitions.- The Ops dashboard's verification panel for cap-breach and residual trends.