"""conduent-breach-timeline-from-the-filings.py Rebuilds the public timeline of Conduent's January 2025 cyber event from the dated records in datasets/conduent-breach-timeline-from-the-filings.csv (SEC filings, notice letters, and the breach lists of California, Oregon, Washington, Texas and HHS), and prints the intervals, counts and costs quoted in the article. Optionally lists any SEC registrant's incident-related filings from EDGAR, which is the first step in reading a vendor's breach from its own disclosures. Inputs: the CSV in this site's datasets/ folder (default), or --csv PATH. Network: none by default. --edgar CIK fetches https://data.sec.gov/submissions/CIK##########.json once. The SEC asks automated clients to identify themselves, so --ua is required with it, for example --ua "Acme Security Team security@acme.test". Run: python conduent-breach-timeline-from-the-filings.py python conduent-breach-timeline-from-the-filings.py --edgar 1677703 --since 2025-01-01 --ua "..." Requires: Python 3.9+, standard library only. """ from __future__ import annotations import argparse import csv import datetime as dt import json import os import sys import urllib.request HERE = os.path.dirname(os.path.abspath(__file__)) DEFAULT_CSV = os.path.join(HERE, "..", "datasets", "conduent-breach-timeline-from-the-filings.csv") # U.S. federal holidays in 2025 and 2026 (OPM holiday schedules). Form 8-K's four-business-day # clock skips weekends and days the Commission is closed. FEDERAL_HOLIDAYS = { "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", } def load(path: str) -> list[dict]: with open(path, encoding="utf-8", newline="") as f: return list(csv.DictReader(f)) def day(s: str) -> dt.date: return dt.date.fromisoformat(s) def business_days(start: dt.date, end: dt.date, holidays=FEDERAL_HOLIDAYS) -> int: """Business days after `start`, up to and including `end` (weekends and holidays skipped).""" n, d = 0, start while d < end: d += dt.timedelta(days=1) if d.weekday() < 5 and d.isoformat() not in holidays: n += 1 return n def first(rows: list[dict], kind: str) -> dict: return next(r for r in rows if r["kind"] == kind) def of_kind(rows: list[dict], kind: str) -> list[dict]: return [r for r in rows if r["kind"] == kind] def report(rows: list[dict]) -> None: start = day(first(rows, "access_start")["date"]) found = day(first(rows, "discovery")["date"]) cover = day(first(rows, "8k_cover_date")["date"]) filed = day(first(rows, "8k_filed")["date"]) notices = sorted(day(r["date"]) for r in of_kind(rows, "notice_filing_ca")) settle = day(first(rows, "settlement_disclosed")["date"]) print("Timeline (day counts from the start of access and from discovery)") for r in rows: if r["date_precision"] != "day": print(f" {r['date']:<10} {'':>15} {r['record']}: {r['detail']}") continue d = day(r["date"]) print(f" {r['date']} +{(d - start).days:>4} / {(d - found).days:+5} {r['record']}: {r['detail']}") print("\nIntervals") print(f" access began -> discovered: {(found - start).days} days") print(f" discovered -> 8-K cover date: {(cover - found).days} days") print(f" 8-K cover date -> 8-K filed: {(filed - cover).days} calendar days, " f"{business_days(cover, filed)} business days") print(f" discovered -> 8-K filed: {(filed - found).days} days") print(f" 8-K filed -> first California notice: {(notices[0] - filed).days} days") print(f" discovered -> first California notice: {(notices[0] - found).days} days") print(f" discovered -> last California notice: {(notices[-1] - found).days} days " f"({len(notices)} filings)") print(f" access began -> last California notice: {(notices[-1] - start).days} days") print(f" discovered -> settlement 8-K: {(settle - found).days} days") oregon = int(first(rows, "count_initial_total")["value"]) wa_first = int(first(rows, "count_wa_initial")["value"]) wa_added = [int(r["value"]) for r in of_kind(rows, "count_wa_added")] texans = int(first(rows, "count_tx_texans")["value"]) tx_total = int(first(rows, "count_tx_total_undisplayed")["value"]) ocr = int(first(rows, "count_ocr")["value"]) ocr_next = int(first(rows, "count_ocr_next_largest")["value"]) wa_total = wa_first + sum(wa_added) counts = [ ("Oregon DOJ, initial report", oregon, ""), ("Washington AG, first report", wa_first, ""), (f"Washington AG, {len(wa_added)} supplemental letters", sum(wa_added), ""), ("Washington AG, cumulative", wa_total, f"{wa_total / wa_first:.2f}x the first report"), ("Texas AG, revised listing (Texans only)", texans, f"{texans - oregon:+,} vs the Oregon report"), ("HHS OCR, individuals affected", ocr, f"{ocr / oregon:.2f}x the Oregon report"), ("HHS OCR, next-largest open case", ocr_next, f"Conduent's is {ocr / ocr_next:.2f}x this"), ("Texas AG data, total field (not displayed)", tx_total, f"{(tx_total - ocr) / ocr:+.2%} vs HHS"), ] print("\nCounts") for label, value, note in counts: print(f" {label:<42}{value:>12,} {note}".rstrip()) print("\nMoney (USD, as of each period end)") charged = first(rows, "usd_charged") print(f" {charged['date']} charged {int(charged['value']):>12,}") for r in of_kind(rows, "usd_disbursed_cumulative"): print(f" {r['date']} disbursed {int(r['value']):>12,}") def edgar_incident_filings(cik: str, since: str, ua: str, items=("1.05", "8.01")) -> list[dict]: """8-K and 8-K/A filings carrying any of `items`, plus every 10-Q and 10-K, filed on or after `since`.""" url = "https://data.sec.gov/submissions/CIK%010d.json" % int(cik) req = urllib.request.Request(url, headers={"User-Agent": ua, "Accept-Encoding": "identity"}) with urllib.request.urlopen(req, timeout=60) as resp: recent = json.load(resp)["filings"]["recent"] out = [] for i, form in enumerate(recent["form"]): if recent["filingDate"][i] < since or form not in ("8-K", "8-K/A", "10-Q", "10-K"): continue if form in ("8-K", "8-K/A") and not set(recent["items"][i].split(",")) & set(items): continue acc = recent["accessionNumber"][i] out.append({"filed": recent["filingDate"][i], "accepted_utc": recent["acceptanceDateTime"][i], "form": form, "items": recent["items"][i], "report_date": recent["reportDate"][i], "url": f"https://www.sec.gov/Archives/edgar/data/{int(cik)}/{acc.replace('-', '')}/" f"{recent['primaryDocument'][i]}"}) return sorted(out, key=lambda r: r["accepted_utc"]) def main() -> None: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) ap.add_argument("--csv", default=DEFAULT_CSV) ap.add_argument("--edgar", metavar="CIK", help="list this registrant's incident-related filings from EDGAR") ap.add_argument("--since", default="2025-01-01") ap.add_argument("--items", default="1.05,8.01", help="8-K items to keep (add 7.01 for furnished press releases)") ap.add_argument("--ua", help="your name and contact address, sent as the User-Agent (SEC policy)") a = ap.parse_args() sys.stdout.reconfigure(encoding="utf-8") if a.edgar: if not a.ua: ap.error("--edgar needs --ua with your name and a contact address") for f in edgar_incident_filings(a.edgar, a.since, a.ua, tuple(a.items.split(","))): print(f"{f['filed']} {f['form']:<5} items={f['items'] or '-':<10} report_date={f['report_date'] or '-':<10} {f['url']}") print("Item 8.01 carries many kinds of news; open each filing before treating it as incident-related.") return report(load(a.csv)) if __name__ == "__main__": main()