"""item-1-05-cyber-incident-reports-2025-2026.py - summarise the Form 8-K Item 1.05 cyber incident reports of 1 January 2025 to 9 October 2026, and refresh the list of such filings from EDGAR. What it does Default (no network): reads the shipped CSV, one row per filing read for the article (43 Item 1.05 filings plus the 5 earlier reports filed under other items), and prints every summary the article gives: the population, the business days from cover date to filing, discovery to filing, the materiality decisions and their reasons, the impact statements by year, what the 32 first reports disclose, and the amendment record. --edgar: asks EDGAR full-text search for 8-K filings that contain any of the search phrases between --since and --until, and lists every hit whose EDGAR item list includes 1.05. That is the first pass of building the set; the article's set also checked each filer's EDGAR submissions record, and every filing was read by hand. Inputs --csv PATH the dataset (default: item-1-05-cyber-incident-reports-2025-2026.csv beside this file, then ../datasets/) --edgar query EDGAR instead of summarising the CSV --since/--until date range for --edgar (YYYY-MM-DD) --ua TEXT required with --edgar: the SEC asks automated clients to identify themselves with a name and a contact address, e.g. "Acme Security Team security@acme.test" Run python item-1-05-cyber-incident-reports-2025-2026.py python item-1-05-cyber-incident-reports-2025-2026.py --edgar --since 2026-10-01 --until 2026-10-31 \ --ua "Acme Security Team security@acme.test" Standard library only (Python 3.9+). Published by Password Password with the article of 10 October 2026. """ import argparse import csv import datetime as dt import json import os import statistics import sys import time import urllib.parse import urllib.request HERE = os.path.dirname(os.path.abspath(__file__)) NAME = "item-1-05-cyber-incident-reports-2025-2026.csv" # US federal holidays, 2025 and 2026, from OPM's published schedules (2026-07-03 is the observed Independence Day) HOLIDAYS = {dt.date.fromisoformat(d) for d in ( "2025-01-01", "2025-01-20", "2025-02-17", "2025-05-26", "2025-06-19", "2025-07-04", "2025-09-01", "2025-10-13", "2025-11-11", "2025-11-27", "2025-12-25", "2026-01-01", "2026-01-19", "2026-02-16", "2026-05-25", "2026-06-19", "2026-07-03", "2026-09-07", "2026-10-12", "2026-11-11", "2026-11-26", "2026-12-25")} PHRASES = ("Item 1.05", "Material Cybersecurity Incidents", "cybersecurity incident", "ransomware", "threat actor", "unauthorized third party", "cyberattack", "cybersecurity event", "cyber incident") CODES = ["data_taken", "personal_data", "disruption", "law_enforcement", "outside_experts", "insurance", "third_party", "access_route", "amend_promise", "prior_report", "actor_named"] def business_days(a: str, b: str) -> int: """Business days after day a, up to and including day b: weekends and federal holidays do not count.""" d, end, n = dt.date.fromisoformat(a), dt.date.fromisoformat(b), 0 while d < end: d += dt.timedelta(days=1) if d.weekday() < 5 and d not in HOLIDAYS: n += 1 return n def days(a: str, b: str) -> int: return (dt.date.fromisoformat(b) - dt.date.fromisoformat(a)).days def load(path: str) -> list[dict]: with open(path, encoding="utf-8", newline="") as f: return list(csv.DictReader(f)) def summarise(rows: list[dict]) -> None: item105 = [r for r in rows if "1.05" in r["items"].split(",")] firsts = [r for r in rows if r["role"].startswith("first_report")] own = [r for r in firsts if r["business_days_cover_to_filed"]] print("Population") print(f" Item 1.05 filings: {len(item105)} ({sum(r['form'] == '8-K' for r in item105)} 8-K, " f"{sum(r['form'] == '8-K/A' for r in item105)} 8-K/A) from {len({r['cik'] for r in item105})} companies") print(f" incidents first reported under Item 1.05: {len(firsts)} " f"(2025: {sum(r['filed'] < '2026' for r in firsts)}, 2026 to 9 October: {sum(r['filed'] >= '2026' for r in firsts)})") print("\nCover date to filing, in business days (reports with their own cover date)") bd = [] for r in own: n = business_days(r["cover_date"], r["filed"]) if str(n) != r["business_days_cover_to_filed"]: sys.exit(f"business-day count disagrees with the dataset for {r['company']}") bd.append(n) for k in sorted(set(bd)): print(f" {k} business days: {bd.count(k):2d} " + "#" * bd.count(k)) print(f" within four: {sum(x <= 4 for x in bd)} of {len(bd)}; median {statistics.median(bd)}") disc = sorted(((days(r["discovery_date"], r["filed"]), r["company"]) for r in firsts if r["discovery_date"]), reverse=True) print(f"\nDiscovery to filing, calendar days ({len(disc)} reports give a discovery day)") print(f" median {statistics.median(d for d, _ in disc)}; longest: " + ", ".join(f"{c} {d}" for d, c in disc[:5])) print("\nMateriality decisions stated in words") dec = [r for r in firsts if r["materiality_decision"]] dated = [r for r in dec if r["materiality_decision"][:2] == "20"] print(f" {len(dec)} reports ({sum(r['filed'] < '2026' for r in dec)} in 2025); {len(dated)} give the day, and " f"{sum(r['materiality_decision'] == r['cover_date'] for r in dated)} of those put that day on the cover") for reason in ("data", "operations", "consequences", "none"): print(f" reason given, {reason}: {sum(r['decision_reason'] == reason for r in dec)}") for r in dated: lead = f"+{days(r['discovery_date'], r['materiality_decision'])} d" if r["discovery_date"] else "no day given" print(f" {r['company']:30s} discovered {r['discovery_date'] or '-':10s} decided " f"{r['materiality_decision']} ({lead}), filed {r['filed']} " f"(+{business_days(r['materiality_decision'], r['filed'])} business day(s))") print("\nWhat the report says about material impact on results") for y in ("2025", "2026"): ys = [r for r in firsts if r["filed"][:4] == y] print(f" {y}: " + ", ".join(f"{k} {sum(r['material_impact'] == k for r in ys)}" for k in ("stated", "not determined", "not expected"))) print(f"\nWhat the {len(firsts)} first reports disclose") print(f" {'discovery day':22s} {sum(bool(r['discovery_date']) for r in firsts):2d}") for k in CODES: print(f" {k.replace('_', ' '):22s} {sum(r[k] == 'yes' for r in firsts):2d}") print(f" {'ransomware':22s} {sum(r['ransomware'] == 'yes' for r in firsts):2d} (+{sum(r['ransomware'] == 'vendor' for r in firsts)} at a vendor)") for k in ("start_of_access", "people_count", "cost_figure"): print(f" {k.replace('_', ' '):22s} {sum(bool(r[k]) for r in firsts):2d}") print("\nAmendments (8-K/A under Item 1.05)") orig = [r for r in firsts if r["role"] in ("first_report", "first_report_after_other_item")] amend = [r for r in rows if r["role"] == "amendment"] for r in orig: mine = sorted(a["filed"] for a in amend if a["company"] == r["company"]) if mine: print(f" {r['company']:30s} {len(mine)} amendment(s), the first {days(r['filed'], mine[0])} days after") print(f" {len({a['company'] for a in amend})} of {len(orig)} incidents reported in an original Item 1.05 8-K " f"have an amendment; {sum(r['amend_promise'] == 'yes' for r in firsts)} first reports promised one") def edgar_item105_filings(since: str, until: str, ua: str, phrases=PHRASES) -> list[dict]: """8-K and 8-K/A filings from EDGAR full-text search whose item list includes 1.05, one per accession.""" found = {} for phrase in phrases: start = 0 while True: query = urllib.parse.urlencode({"q": f'"{phrase}"', "dateRange": "custom", "startdt": since, "enddt": until, "forms": "8-K", "from": start}) req = urllib.request.Request("https://efts.sec.gov/LATEST/search-index?" + query, headers={"User-Agent": ua, "Accept-Encoding": "identity"}) with urllib.request.urlopen(req, timeout=60) as resp: hits = json.load(resp)["hits"] for h in hits["hits"]: s = h["_source"] if "1.05" in (s.get("items") or []): cik = int(s["ciks"][0]) found[s["adsh"]] = {"filed": s["file_date"], "form": s["form"], "cover": s["period_ending"], "company": s["display_names"][0], "items": ",".join(s["items"]), "index": f"https://www.sec.gov/Archives/edgar/data/{cik}/" f"{s['adsh'].replace('-', '')}/"} start += len(hits["hits"]) time.sleep(0.5) # stay far below the SEC's ten requests a second if not hits["hits"] or start >= hits["total"]["value"]: break return sorted(found.values(), key=lambda f: (f["filed"], f["company"])) def main() -> None: ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("--csv") ap.add_argument("--edgar", action="store_true") ap.add_argument("--since", default="2025-01-01") ap.add_argument("--until", default=dt.date.today().isoformat()) ap.add_argument("--ua") a = ap.parse_args() if a.edgar: if not a.ua or "@" not in a.ua: sys.exit("--edgar needs --ua with a name and a contact address (the SEC's fair-access policy)") for f in edgar_item105_filings(a.since, a.until, a.ua): print(f"{f['filed']} {f['form']:6s} cover {f['cover']} items {f['items']:14s} {f['company']}") print(f" {f['index']}") return path = a.csv or next((p for p in (os.path.join(HERE, NAME), os.path.join(HERE, "..", "datasets", NAME)) if os.path.exists(p)), None) if not path: sys.exit(f"{NAME} not found: pass --csv") summarise(load(path)) if __name__ == "__main__": main()