"""Fetch, normalize, and split Layer 2 evaluation/training datasets. Downloads datasets for two classification tasks: - Prompt Injection (Model A): Gandalf, DeepSet, HackAPrompt, BIPIA, Enron - Malicious Intent (Model B): SpamAssassin Ham/Spam, Nazario Phishing, Fraudulent Email, Enron Local-First Strategy: For each dataset, the script checks ``data/raw/`` for manually downloaded files BEFORE attempting any network fetch. This is essential for gated HuggingFace datasets (HackAPrompt, BIPIA) that require authentication, and for very large datasets (Enron) that can OOM if loaded eagerly. All records are normalized into a common JSONL schema: {id, source, task, label, text_body, html_body} Stratified train/val/test splits (70/15/15) are generated per task, stratified by (source, label) to ensure proportional representation. This script never synthesizes content. All data comes from real sources. """ from __future__ import annotations import argparse import email import email.policy import hashlib import io import json import logging import random import tarfile from collections import Counter from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable, Iterator import pandas as pd import requests from sklearn.model_selection import train_test_split from tqdm import tqdm logger = logging.getLogger("fetch_datasets_l2") DEFAULT_OUTPUT_DIR = Path("data/l2_datasets") DEFAULT_RAW_DIR = Path("data/raw") DEFAULT_SEED = 42 # --- HuggingFace dataset identifiers --- GANDALF_ID = "Lakera/gandalf_ignore_instructions" DEEPSET_PI_ID = "deepset/prompt-injections" HACKAPROMPT_ID = "hackaprompt/hackaprompt-dataset" BIPIA_ID = "MAlmasabi/Indirect-Prompt-Injection-BIPIA-GPT" ENRON_ID = "SuccessfulCrab/enron" # --- SpamAssassin public corpus --- SPAMASSASSIN_BASE = "https://spamassassin.apache.org/old/publiccorpus" SPAMASSASSIN_FILES = { "ham": [ "20030228_easy_ham.tar.bz2", "20030228_easy_ham_2.tar.bz2", "20030228_hard_ham.tar.bz2", ], "spam": [ "20030228_spam.tar.bz2", "20030228_spam_2.tar.bz2", ], } # --- Nazario phishing corpus --- NAZARIO_URL = "https://monkey.org/~jose/phishing/phishing3.mbox" # Split ratios TRAIN_RATIO = 0.70 VAL_RATIO = 0.15 TEST_RATIO = 0.15 # ============================================================ # Local-First File Registry # ============================================================ # Maps each source to a list of candidate filenames (checked in order) # in the data/raw/ directory. The first matching file wins. LOCAL_FILE_REGISTRY: dict[str, list[str]] = { "gandalf": [ "gandalf.parquet", "gandalf.csv", "gandalf.jsonl", ], "deepset": [ # HuggingFace auto-download names "deepset_train.parquet", "deepset_test.parquet", "train-00000-of-00001-9564e8b05b4757ab.parquet", "test-00000-of-00001-701d16158af87368.parquet", # Manual download names "deepset.parquet", "deepset.csv", "deepset.jsonl", ], "hackaprompt": [ "hackaprompt.parquet", "hackaprompt.csv", "hackaprompt.jsonl", ], "bipia": [ "bipia.jsonl", "dataset_for_huggingface.jsonl", "bipia.parquet", "bipia.csv", ], "enron": [ "enron.parquet", "enron.csv", "enron.jsonl", ], "nazario": [ "nazario.mbox", "phishing3.mbox", ], "fraudulent": [ "fraudulent_email.csv", "fraudulent_email.jsonl", "fraudulent_email.parquet", ], } # Chunk size for reading large CSVs to prevent OOM CSV_CHUNK_SIZE = 10_000 @dataclass(frozen=True) class FetchConfig: """Runtime configuration for L2 dataset fetching.""" output_dir: Path raw_dir: Path seed: int timeout: int hf_token: str | None strict: bool # Per-source caps gandalf_count: int deepset_count: int hackaprompt_count: int bipia_count: int enron_pi_count: int enron_mi_count: int spamassassin_ham_count: int spamassassin_spam_count: int nazario_count: int fraudulent_count: int skip: set[str] = field(default_factory=set) # ============================================================ # Shared utilities # ============================================================ def write_jsonl(path: Path, records: Iterable[dict[str, Any]]) -> int: """Write records to JSONL, creating parent directories. Args: path: Output JSONL path. records: Records to write. Returns: Number of records written. """ count = 0 path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as handle: for record in records: handle.write( json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n" ) count += 1 logger.info("Wrote %s records to %s", count, path) return count def load_jsonl(path: Path) -> list[dict[str, Any]]: """Read all records from a JSONL file. Args: path: JSONL file path. Returns: List of parsed JSON objects. """ records = [] with path.open("r", encoding="utf-8", errors="replace") as handle: for line in handle: line = line.strip() if line: records.append(json.loads(line)) return records def reservoir_sample( rows: Iterable[dict[str, Any]], sample_count: int, rng: random.Random, description: str, ) -> list[dict[str, Any]]: """Uniformly sample from an iterable without loading all rows first. Uses Algorithm R (Vitter, 1985) for O(k) memory regardless of stream size. This is critical for large HF streaming datasets like Enron (500K+ rows) to avoid OOM. Args: rows: Iterable of records. sample_count: Maximum number of records to sample. rng: Random number generator. description: Label for progress bar. Returns: Sampled records, shuffled. """ sample: list[dict[str, Any]] = [] for seen, row in enumerate(tqdm(rows, desc=description, unit="rows"), start=1): if len(sample) < sample_count: sample.append(row) continue j = rng.randint(1, seen) if j <= sample_count: sample[j - 1] = row if len(sample) < sample_count: logger.warning( "%s only yielded %s/%s records", description, len(sample), sample_count ) rng.shuffle(sample) return sample def load_hf_dataset( dataset_id: str, config: FetchConfig, split: str = "train" ) -> Iterable[dict[str, Any]]: """Load a HuggingFace dataset as a stream. Always uses streaming=True to prevent loading the entire dataset into RAM. This is the ONLY way to safely handle large datasets like Enron (500K+ rows, 1.8GB+ in memory). Args: dataset_id: HuggingFace dataset identifier. config: Fetch configuration. split: Dataset split to load. Returns: Iterable of dataset rows (streamed, not materialized). """ from datasets import load_dataset return load_dataset( dataset_id, split=split, streaming=True, token=config.hf_token, ) def first_present(row: dict[str, Any], *keys: str) -> Any: """Return the first non-empty value from a row. Args: row: Data row. *keys: Keys to search in order. Returns: First non-empty value, or None. """ for key in keys: value = row.get(key) if value not in (None, ""): return value return None def looks_like_html(value: str) -> bool: """Return whether a body appears to contain HTML markup. Args: value: Text to check. Returns: True if the text contains HTML tags. """ lowered = value.lower() return " None: """Handle source download/load failures. Args: source: Source name. exc: Exception that occurred. strict: Whether to raise on errors. """ message = f"Could not fetch {source}: {exc}" if strict: raise RuntimeError(message) from exc logger.error(message) def stable_id(source: str, text: str) -> str: """Generate a stable deterministic ID from source and content. Args: source: Source name. text: Content to hash. Returns: ID string in format "source-hash8". """ digest = hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()[:8] return f"{source}-{digest}" def build_record( record_id: str, source: str, task: str, label: int, text_body: str, html_body: str, ) -> dict[str, Any]: """Build a normalized L2 record. Args: record_id: Unique identifier. source: Dataset source name. task: "prompt_injection" or "malicious_intent". label: 0 (benign) or 1 (malicious/injection). text_body: Plain-text body. html_body: HTML body (empty if not available). Returns: Normalized record dict. """ return { "id": record_id, "source": source, "task": task, "label": label, "text_body": text_body[:50000], # Cap at 50KB to prevent bloat "html_body": html_body[:100000], # Cap at 100KB } # ============================================================ # Local-First file resolution # ============================================================ def find_local_files(source: str, raw_dir: Path) -> list[Path]: """Find all locally available files for a given dataset source. Searches the raw_dir for files matching the LOCAL_FILE_REGISTRY entries for the given source. Returns all matches (some datasets like DeepSet have separate train/test files). Args: source: Dataset source name (key in LOCAL_FILE_REGISTRY). raw_dir: Directory to search for local files. Returns: List of existing file paths, in registry order. """ candidates = LOCAL_FILE_REGISTRY.get(source, []) found = [] for filename in candidates: path = raw_dir / filename if path.exists(): found.append(path) return found def load_local_tabular( path: Path, max_rows: int | None = None ) -> Iterator[dict[str, Any]]: """Load rows from a local parquet, CSV, or JSONL file as an iterator. Uses chunked reading for CSVs to prevent OOM on large files. Parquet files are read in full (they're columnar and memory-mapped). Args: path: Path to the file. max_rows: Optional maximum number of rows to yield. Yields: Row dicts from the file. """ suffix = path.suffix.lower() yielded = 0 if suffix == ".parquet": df = pd.read_parquet(path) for _, row in df.iterrows(): if max_rows and yielded >= max_rows: return yield row.to_dict() yielded += 1 elif suffix == ".csv": # Chunked reading to prevent OOM on large CSVs for chunk in pd.read_csv( path, chunksize=CSV_CHUNK_SIZE, encoding="utf-8", on_bad_lines="skip", ): for _, row in chunk.iterrows(): if max_rows and yielded >= max_rows: return yield row.to_dict() yielded += 1 elif suffix == ".jsonl": with path.open("r", encoding="utf-8", errors="replace") as handle: for line in handle: if max_rows and yielded >= max_rows: return line = line.strip() if line: yield json.loads(line) yielded += 1 else: raise ValueError(f"Unsupported file format: {suffix}") # ============================================================ # Prompt Injection dataset fetchers # ============================================================ def fetch_gandalf(config: FetchConfig) -> list[dict[str, Any]]: """Fetch Gandalf Ignore Instructions dataset. Contains layered, multi-step injection bypass attempts from the Lakera Gandalf challenge. Strategy: local file first, then HuggingFace streaming fallback. Args: config: Fetch configuration. Returns: List of normalized records. """ rng = random.Random(config.seed) def normalize_row(idx: int, row: dict[str, Any]) -> dict[str, Any] | None: """Normalize a single Gandalf row into L2 schema.""" text = first_present(row, "text", "prompt", "instruction", "input") if not isinstance(text, str) or not text.strip(): return None return build_record( record_id=f"gandalf-{idx}", source="gandalf", task="prompt_injection", label=1, text_body=text.strip(), html_body="", ) # Local-first: check data/raw/ for pre-downloaded files local_files = find_local_files("gandalf", config.raw_dir) if local_files: logger.info("Loading Gandalf from local file: %s", local_files[0]) rows = load_local_tabular(local_files[0]) records = _normalize_stream(rows, normalize_row, config.gandalf_count, rng) if records: return records logger.warning("Local Gandalf file yielded 0 records; falling back to HF") # Network fallback: HuggingFace streaming try: dataset = load_hf_dataset(GANDALF_ID, config) except Exception as exc: handle_source_error("Gandalf", exc, config.strict) return [] def iter_records() -> Iterator[dict[str, Any]]: for idx, row in enumerate(dataset): record = normalize_row(idx, row) if record: yield record return reservoir_sample(iter_records(), config.gandalf_count, rng, "Gandalf") def fetch_deepset(config: FetchConfig) -> list[dict[str, Any]]: """Fetch DeepSet prompt-injections dataset. Contains curated injection vs. benign text pairs with labels. This is one of the few PI datasets with both positive AND negative samples. Strategy: local parquet files first (supports separate train/test files), then HuggingFace streaming fallback. Args: config: Fetch configuration. Returns: List of normalized records. """ rng = random.Random(config.seed) def normalize_row(idx: int, row: dict[str, Any]) -> dict[str, Any] | None: """Normalize a single DeepSet row into L2 schema.""" text = first_present(row, "text", "prompt", "input") if not isinstance(text, str) or not text.strip(): return None label_raw = row.get("label", None) if label_raw is None: return None label = int(label_raw) if label not in (0, 1): return None return build_record( record_id=f"deepset-{idx}", source="deepset", task="prompt_injection", label=label, text_body=text.strip(), html_body="", ) # Local-first: DeepSet may have separate train/test parquet files local_files = find_local_files("deepset", config.raw_dir) if local_files: logger.info( "Loading DeepSet from %d local file(s): %s", len(local_files), [f.name for f in local_files], ) all_rows: list[dict[str, Any]] = [] for lf in local_files: for row in load_local_tabular(lf): all_rows.append(row) records = _normalize_stream( iter(all_rows), normalize_row, config.deepset_count, rng, ) if records: return records logger.warning("Local DeepSet files yielded 0 records; falling back to HF") # Network fallback try: dataset = load_hf_dataset(DEEPSET_PI_ID, config) except Exception as exc: handle_source_error("DeepSet", exc, config.strict) return [] def iter_records() -> Iterator[dict[str, Any]]: for idx, row in enumerate(dataset): record = normalize_row(idx, row) if record: yield record return reservoir_sample(iter_records(), config.deepset_count, rng, "DeepSet") def fetch_hackaprompt_l2(config: FetchConfig) -> list[dict[str, Any]]: """Fetch HackAPrompt adversarial payloads for L2 evaluation. This is a GATED dataset on HuggingFace requiring HF_TOKEN. The local-first path is critical here: if hackaprompt.parquet exists in data/raw/, it is used directly without network access. Strategy: local parquet first, then HuggingFace streaming fallback. Args: config: Fetch configuration. Returns: List of normalized records. """ rng = random.Random(config.seed) def normalize_row(idx: int, row: dict[str, Any]) -> dict[str, Any] | None: """Normalize a single HackAPrompt row into L2 schema.""" payload = first_present(row, "user_input", "prompt") if not isinstance(payload, str) or not payload.strip(): return None return build_record( record_id=f"hackaprompt-{idx}", source="hackaprompt", task="prompt_injection", label=1, text_body=payload.strip(), html_body="", ) # Local-first: hackaprompt.parquet is 601K rows — use reservoir sampling local_files = find_local_files("hackaprompt", config.raw_dir) if local_files: logger.info("Loading HackAPrompt from local file: %s", local_files[0]) rows = load_local_tabular(local_files[0]) records = _normalize_stream( rows, normalize_row, config.hackaprompt_count, rng, ) if records: return records logger.warning( "Local HackAPrompt file yielded 0 records; falling back to HF" ) # Network fallback (requires HF_TOKEN for this gated dataset) try: dataset = load_hf_dataset(HACKAPROMPT_ID, config) except Exception as exc: handle_source_error("HackAPrompt", exc, config.strict) return [] def iter_records() -> Iterator[dict[str, Any]]: for idx, row in enumerate(dataset): record = normalize_row(idx, row) if record: yield record return reservoir_sample( iter_records(), config.hackaprompt_count, rng, "HackAPrompt" ) def fetch_bipia_l2(config: FetchConfig) -> list[dict[str, Any]]: """Fetch BIPIA email-context indirect injection payloads. These are specifically crafted for email context, making them highly relevant for our use case. This is a GATED dataset. Strategy: local JSONL/parquet first (dataset_for_huggingface.jsonl is the expected name), then HuggingFace streaming fallback. Args: config: Fetch configuration. Returns: List of normalized records. """ rng = random.Random(config.seed) def normalize_row(idx: int, row: dict[str, Any]) -> dict[str, Any] | None: """Normalize a single BIPIA row into L2 schema.""" payload = first_present( row, "context", "email", "text", "prompt", "payload" ) if not isinstance(payload, str) or not payload.strip(): return None # Skip explicitly benign-labeled rows if row.get("label") in (0, "0", False, "benign"): return None return build_record( record_id=f"bipia-{idx}", source="bipia", task="prompt_injection", label=1, text_body=payload.strip(), html_body="", ) # Local-first: BIPIA JSONL (70K rows) — use reservoir sampling local_files = find_local_files("bipia", config.raw_dir) if local_files: logger.info("Loading BIPIA from local file: %s", local_files[0]) rows = load_local_tabular(local_files[0]) records = _normalize_stream(rows, normalize_row, config.bipia_count, rng) if records: return records logger.warning("Local BIPIA file yielded 0 records; falling back to HF") # Network fallback (requires HF_TOKEN for this gated dataset) try: dataset = load_hf_dataset(BIPIA_ID, config) except Exception as exc: handle_source_error("BIPIA", exc, config.strict) return [] def iter_records() -> Iterator[dict[str, Any]]: for idx, row in enumerate(dataset): record = normalize_row(idx, row) if record: yield record return reservoir_sample(iter_records(), config.bipia_count, rng, "BIPIA") def fetch_enron_negative( config: FetchConfig, task: str, count: int ) -> list[dict[str, Any]]: """Fetch Enron emails as negative (benign) samples for a given task. CRITICAL: The Enron dataset is ~500K rows and ~1.8GB in RAM if loaded eagerly. This function MUST use either: - Local file with streaming iteration (load_local_tabular) - HuggingFace streaming=True with reservoir sampling Never call load_dataset() without streaming=True for Enron. Args: config: Fetch configuration. task: Task name ("prompt_injection" or "malicious_intent"). count: Number of samples to fetch. Returns: List of normalized negative records. """ rng = random.Random(config.seed + hash(task)) source_tag = f"enron_{task[:2]}" def normalize_row(idx: int, row: dict[str, Any]) -> dict[str, Any] | None: """Normalize a single Enron row into L2 schema.""" message = first_present(row, "message", "text", "body", "email") if not isinstance(message, str) or not message.strip(): return None html_body = message if looks_like_html(message) else "" return build_record( record_id=f"{source_tag}-{idx}", source=source_tag, task=task, label=0, text_body=message.strip(), html_body=html_body, ) # Local-first: check for pre-downloaded Enron file local_files = find_local_files("enron", config.raw_dir) if local_files: logger.info( "Loading Enron (%s) from local file: %s", task, local_files[0] ) rows = load_local_tabular(local_files[0]) records = _normalize_stream(rows, normalize_row, count, rng) if records: return records logger.warning( "Local Enron file yielded 0 records for %s; falling back to HF", task, ) # Network fallback: MUST use streaming=True to prevent OOM try: dataset = load_hf_dataset(ENRON_ID, config) except Exception as exc: handle_source_error(f"Enron ({task})", exc, config.strict) return [] def iter_records() -> Iterator[dict[str, Any]]: for idx, row in enumerate(dataset): record = normalize_row(idx, row) if record: yield record return reservoir_sample(iter_records(), count, rng, f"Enron ({task})") # ============================================================ # Shared normalization helper # ============================================================ def _normalize_stream( rows: Iterable[dict[str, Any]], normalize_fn: Any, cap: int, rng: random.Random, ) -> list[dict[str, Any]]: """Normalize rows through a function and reservoir-sample to cap. Combines normalization and sampling in a single streaming pass to avoid materializing the entire dataset in memory. Args: rows: Raw row iterator. normalize_fn: Callable(idx, row) -> normalized record or None. cap: Maximum number of records to keep. rng: Random number generator for sampling. Returns: List of normalized, sampled records. """ def iter_normalized() -> Iterator[dict[str, Any]]: for idx, row in enumerate(rows): record = normalize_fn(idx, row) if record is not None: yield record return reservoir_sample(iter_normalized(), cap, rng, "local-file") # ============================================================ # Malicious Intent dataset fetchers # ============================================================ def _parse_mbox_email(raw_bytes: bytes) -> tuple[str, str]: """Parse a raw email into text_body and html_body. Preserves HTML body when present in multipart MIME messages. This is critical for Layer 1 evaluation, which needs the original HTML to detect hidden content, CSS hiding, and other structural attacks. Args: raw_bytes: Raw email bytes. Returns: Tuple of (text_body, html_body). """ try: msg = email.message_from_bytes(raw_bytes, policy=email.policy.default) except Exception: # Fall back to string decoding if email parsing fails text = raw_bytes.decode("utf-8", errors="replace") return text, "" text_body = "" html_body = "" if msg.is_multipart(): for part in msg.walk(): content_type = part.get_content_type() try: payload = part.get_content() except Exception: continue if not isinstance(payload, str): continue if content_type == "text/plain" and not text_body: text_body = payload elif content_type == "text/html" and not html_body: html_body = payload else: try: payload = msg.get_content() except Exception: payload = raw_bytes.decode("utf-8", errors="replace") if isinstance(payload, str): content_type = msg.get_content_type() if content_type == "text/html": html_body = payload else: text_body = payload # If we only have HTML, derive text as fallback (keep html_body intact) if html_body and not text_body: text_body = html_body return text_body, html_body def fetch_spamassassin(config: FetchConfig) -> list[dict[str, Any]]: """Fetch SpamAssassin public corpus (ham + spam). Downloads tar.bz2 archives from Apache and parses individual email files. Ham emails are labeled 0 (benign), spam as 1 (malicious). HTML body is preserved from MIME multipart messages. Args: config: Fetch configuration. Returns: List of normalized records. """ records: list[dict[str, Any]] = [] for label_name, archives in SPAMASSASSIN_FILES.items(): label = 0 if label_name == "ham" else 1 cap = ( config.spamassassin_ham_count if label == 0 else config.spamassassin_spam_count ) archive_records: list[dict[str, Any]] = [] for archive_name in archives: url = f"{SPAMASSASSIN_BASE}/{archive_name}" try: logger.info("Downloading SpamAssassin %s ...", archive_name) response = requests.get(url, timeout=config.timeout) response.raise_for_status() except requests.RequestException as exc: handle_source_error( f"SpamAssassin {archive_name}", exc, config.strict ) continue try: with tarfile.open( fileobj=io.BytesIO(response.content), mode="r:bz2" ) as tar: for member in tar.getmembers(): if not member.isfile(): continue name = member.name.split("/")[-1] if name.startswith(".") or name in ("cmds", "README"): continue try: raw = tar.extractfile(member) if raw is None: continue raw_bytes = raw.read() except Exception: continue text_body, html_body = _parse_mbox_email(raw_bytes) if not text_body.strip(): continue source_tag = f"spamassassin_{label_name}" archive_records.append( build_record( record_id=stable_id(source_tag, text_body), source=source_tag, task="malicious_intent", label=label, text_body=text_body.strip(), html_body=html_body, ) ) except Exception as exc: handle_source_error( f"SpamAssassin archive {archive_name}", exc, config.strict ) # Sample down to cap rng = random.Random(config.seed) if len(archive_records) > cap: rng.shuffle(archive_records) archive_records = archive_records[:cap] logger.info( "SpamAssassin %s: %s records (cap=%s)", label_name, len(archive_records), cap, ) records.extend(archive_records) return records def fetch_nazario(config: FetchConfig) -> list[dict[str, Any]]: """Fetch Nazario phishing corpus. Downloads phishing emails from Jose Nazario's public collection. These are real phishing emails, labeled as malicious intent positive. HTML body is preserved from MIME multipart messages. Strategy: local mbox file first, then HTTP download fallback. Args: config: Fetch configuration. Returns: List of normalized records. """ raw_content: bytes | None = None # Local-first: check for pre-downloaded mbox local_files = find_local_files("nazario", config.raw_dir) if local_files: logger.info("Loading Nazario from local file: %s", local_files[0]) raw_content = local_files[0].read_bytes() # Network fallback if raw_content is None: try: logger.info("Downloading Nazario phishing corpus...") response = requests.get(NAZARIO_URL, timeout=config.timeout) response.raise_for_status() raw_content = response.content except requests.RequestException as exc: handle_source_error("Nazario phishing corpus", exc, config.strict) return [] records: list[dict[str, Any]] = [] raw_emails = raw_content.split(b"\nFrom ") for idx, raw in enumerate(raw_emails): if idx > 0: raw = b"From " + raw text_body, html_body = _parse_mbox_email(raw) if not text_body.strip() or len(text_body.strip()) < 20: continue records.append( build_record( record_id=f"nazario-{idx}", source="nazario", task="malicious_intent", label=1, text_body=text_body.strip(), html_body=html_body, ) ) rng = random.Random(config.seed) if len(records) > config.nazario_count: rng.shuffle(records) records = records[: config.nazario_count] logger.info("Nazario: %s records", len(records)) return records def fetch_fraudulent_email(config: FetchConfig) -> list[dict[str, Any]]: """Fetch fraudulent email (419 scam) dataset. Strategy: local CSV/JSONL first, then HuggingFace mirror fallback. Args: config: Fetch configuration. Returns: List of normalized records. """ rng = random.Random(config.seed) def normalize_row(idx: int, row: dict[str, Any]) -> dict[str, Any] | None: """Normalize a single fraudulent email row.""" text = first_present( row, "text", "body", "email", "message", "content" ) if not isinstance(text, str) or not text.strip(): return None label_raw = row.get("label", None) if label_raw is not None: label = int(label_raw) if label not in (0, 1): label = 1 else: label = 1 # Entire dataset is fraudulent return build_record( record_id=f"fraudulent-{idx}", source="fraudulent_email", task="malicious_intent", label=label, text_body=text.strip(), html_body="", ) # Local-first: check for pre-downloaded CSV/JSONL local_files = find_local_files("fraudulent", config.raw_dir) if local_files: logger.info( "Loading Fraudulent Email from local file: %s", local_files[0] ) rows = load_local_tabular(local_files[0]) records = _normalize_stream( rows, normalize_row, config.fraudulent_count, rng, ) if records: return records logger.warning( "Local fraudulent email file yielded 0 records; falling back to HF" ) # Network fallback: try known HuggingFace mirrors hf_ids = [ "ealvaradob/phishing-dataset", "talby/fraudulent-email", ] for hf_id in hf_ids: try: dataset = load_hf_dataset(hf_id, config) def iter_records() -> Iterator[dict[str, Any]]: for idx, row in enumerate(dataset): record = normalize_row(idx, row) if record: yield record records = reservoir_sample( iter_records(), config.fraudulent_count, rng, f"Fraudulent ({hf_id})", ) if records: logger.info( "Loaded %s fraudulent email records from %s", len(records), hf_id, ) return records except Exception as exc: logger.warning("Could not load %s: %s", hf_id, exc) continue handle_source_error( "Fraudulent email corpus", RuntimeError("No accessible local file or HF mirror found"), config.strict, ) return [] # ============================================================ # Split generation # ============================================================ def create_stratified_splits( records: list[dict[str, Any]], task: str, output_dir: Path, seed: int, ) -> dict[str, int]: """Create stratified train/val/test splits for one task. Stratifies by (source, label) to ensure each source contributes proportionally to all three splits. Args: records: All records for this task. task: Task name for file naming. output_dir: Directory for output JSONL files. seed: Random seed for reproducibility. Returns: Dict with split names and record counts. """ if not records: logger.warning("No records for task %s; skipping split creation", task) return {} # Create stratification key strat_keys = [f"{r['source']}_{r['label']}" for r in records] # Check minimum samples per stratum for stratification key_counts = Counter(strat_keys) min_count = min(key_counts.values()) if min_count < 3: logger.warning( "Task %s: Some strata have < 3 samples. " "Falling back to label-only stratification.", task, ) strat_keys = [str(r["label"]) for r in records] try: # First split: train vs. (val+test) train_records, valtest_records, _, valtest_strat = train_test_split( records, strat_keys, test_size=(VAL_RATIO + TEST_RATIO), random_state=seed, stratify=strat_keys, ) # Second split: val vs. test (50/50 of the remaining) relative_test_size = TEST_RATIO / (VAL_RATIO + TEST_RATIO) val_records, test_records = train_test_split( valtest_records, test_size=relative_test_size, random_state=seed, stratify=valtest_strat, ) except ValueError: logger.warning( "Stratified split failed for task %s; using random split", task ) rng = random.Random(seed) shuffled = list(records) rng.shuffle(shuffled) n = len(shuffled) n_train = int(n * TRAIN_RATIO) n_val = int(n * VAL_RATIO) train_records = shuffled[:n_train] val_records = shuffled[n_train : n_train + n_val] test_records = shuffled[n_train + n_val :] task_dir = output_dir / task counts = {} for split_name, split_records in [ ("train", train_records), ("val", val_records), ("test", test_records), ]: n = write_jsonl(task_dir / f"{split_name}.jsonl", split_records) counts[split_name] = n for split_name, split_records in [ ("train", train_records), ("val", val_records), ("test", test_records), ]: source_dist = Counter(r["source"] for r in split_records) label_dist = Counter(r["label"] for r in split_records) logger.info( "%s/%s: %s records | labels=%s | sources=%s", task, split_name, len(split_records), dict(label_dist), dict(source_dist), ) return counts # ============================================================ # Manifest and reporting # ============================================================ def write_manifest( config: FetchConfig, raw_counts: dict[str, int], split_counts: dict[str, dict[str, int]], load_methods: dict[str, str], ) -> None: """Write fetch and split provenance manifest. Args: config: Fetch configuration. raw_counts: Per-source record counts before splitting. split_counts: Per-task split counts. load_methods: Per-source load method ("local" or "network"). """ manifest = { "zero_synthesis": True, "seed": config.seed, "raw_dir": str(config.raw_dir), "sources": { "gandalf": GANDALF_ID, "deepset": DEEPSET_PI_ID, "hackaprompt": HACKAPROMPT_ID, "bipia": BIPIA_ID, "enron": ENRON_ID, "spamassassin": SPAMASSASSIN_BASE, "nazario": NAZARIO_URL, }, "raw_counts": raw_counts, "load_methods": load_methods, "split_counts": split_counts, "split_ratios": { "train": TRAIN_RATIO, "val": VAL_RATIO, "test": TEST_RATIO, }, } manifest_path = config.output_dir / "dataset_manifest.json" manifest_path.write_text( json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8" ) logger.info("Wrote manifest to %s", manifest_path) # ============================================================ # CLI # ============================================================ def parse_args() -> argparse.Namespace: """Parse command-line arguments.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR) parser.add_argument( "--raw-dir", type=Path, default=DEFAULT_RAW_DIR, help=( "Directory containing manually downloaded dataset files. " "Checked BEFORE any network fetch. Default: data/raw/" ), ) parser.add_argument("--seed", type=int, default=DEFAULT_SEED) parser.add_argument("--timeout", type=int, default=120) parser.add_argument("--hf-token", default=None) parser.add_argument("--strict", action="store_true") parser.add_argument("--skip", action="append", default=[]) # Per-source caps parser.add_argument("--gandalf-count", type=int, default=2000) parser.add_argument("--deepset-count", type=int, default=1000) parser.add_argument("--hackaprompt-count", type=int, default=3000) parser.add_argument("--bipia-count", type=int, default=2000) parser.add_argument("--enron-pi-count", type=int, default=5000) parser.add_argument("--enron-mi-count", type=int, default=5000) parser.add_argument("--spamassassin-ham-count", type=int, default=4000) parser.add_argument("--spamassassin-spam-count", type=int, default=3000) parser.add_argument("--nazario-count", type=int, default=2000) parser.add_argument("--fraudulent-count", type=int, default=2000) return parser.parse_args() def build_config(args: argparse.Namespace) -> FetchConfig: """Build immutable fetch configuration from CLI args. Args: args: Parsed CLI arguments. Returns: Frozen FetchConfig dataclass. """ return FetchConfig( output_dir=args.output_dir, raw_dir=args.raw_dir, seed=args.seed, timeout=args.timeout, hf_token=args.hf_token, strict=args.strict, gandalf_count=args.gandalf_count, deepset_count=args.deepset_count, hackaprompt_count=args.hackaprompt_count, bipia_count=args.bipia_count, enron_pi_count=args.enron_pi_count, enron_mi_count=args.enron_mi_count, spamassassin_ham_count=args.spamassassin_ham_count, spamassassin_spam_count=args.spamassassin_spam_count, nazario_count=args.nazario_count, fraudulent_count=args.fraudulent_count, skip=set(args.skip), ) def main() -> None: """Fetch all L2 datasets, normalize, and create splits.""" logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") args = parse_args() config = build_config(args) config.output_dir.mkdir(parents=True, exist_ok=True) config.raw_dir.mkdir(parents=True, exist_ok=True) # Log local-first status logger.info("Raw directory: %s", config.raw_dir.resolve()) for source, filenames in LOCAL_FILE_REGISTRY.items(): found = find_local_files(source, config.raw_dir) if found: logger.info(" [LOCAL] %s: %s", source, [f.name for f in found]) else: logger.info(" [NETWORK] %s: no local files found", source) # Track which method was used per source load_methods: dict[str, str] = {} # --- Prompt Injection datasets --- pi_records: list[dict[str, Any]] = [] pi_raw_counts: dict[str, int] = {} pi_fetchers: dict[str, Any] = { "gandalf": lambda: fetch_gandalf(config), "deepset": lambda: fetch_deepset(config), "hackaprompt": lambda: fetch_hackaprompt_l2(config), "bipia": lambda: fetch_bipia_l2(config), "enron_pi": lambda: fetch_enron_negative( config, "prompt_injection", config.enron_pi_count ), } for name, fetcher in pi_fetchers.items(): if name in config.skip: logger.info("Skipping %s", name) continue try: # Check if local files exist for load_method tracking local_key = name.replace("_pi", "").replace("_mi", "") has_local = bool(find_local_files(local_key, config.raw_dir)) records = fetcher() pi_raw_counts[name] = len(records) pi_records.extend(records) load_methods[name] = "local" if has_local and records else "network" logger.info("Fetched %s: %s records", name, len(records)) except Exception as exc: handle_source_error(name, exc, config.strict) pi_raw_counts[name] = 0 load_methods[name] = "failed" # --- Malicious Intent datasets --- mi_records: list[dict[str, Any]] = [] mi_raw_counts: dict[str, int] = {} mi_fetchers: dict[str, Any] = { "spamassassin": lambda: fetch_spamassassin(config), "nazario": lambda: fetch_nazario(config), "fraudulent_email": lambda: fetch_fraudulent_email(config), "enron_mi": lambda: fetch_enron_negative( config, "malicious_intent", config.enron_mi_count ), } for name, fetcher in mi_fetchers.items(): if name in config.skip: logger.info("Skipping %s", name) continue try: local_key = name.replace("_mi", "").replace("_email", "") has_local = bool(find_local_files(local_key, config.raw_dir)) records = fetcher() mi_raw_counts[name] = len(records) mi_records.extend(records) load_methods[name] = "local" if has_local and records else "network" logger.info("Fetched %s: %s records", name, len(records)) except Exception as exc: handle_source_error(name, exc, config.strict) mi_raw_counts[name] = 0 load_methods[name] = "failed" # --- Write raw combined JSONL (before splitting) --- raw_out = config.output_dir / "raw" if pi_records: write_jsonl(raw_out / "prompt_injection_all.jsonl", pi_records) if mi_records: write_jsonl(raw_out / "malicious_intent_all.jsonl", mi_records) # --- Create stratified splits --- split_counts = {} if pi_records: logger.info( "Creating prompt_injection splits (%s total records)...", len(pi_records), ) split_counts["prompt_injection"] = create_stratified_splits( pi_records, "prompt_injection", config.output_dir, config.seed ) if mi_records: logger.info( "Creating malicious_intent splits (%s total records)...", len(mi_records), ) split_counts["malicious_intent"] = create_stratified_splits( mi_records, "malicious_intent", config.output_dir, config.seed ) # --- Summary --- raw_counts = {**pi_raw_counts, **mi_raw_counts} write_manifest(config, raw_counts, split_counts, load_methods) print("\n=== L2 Dataset Fetch Summary ===") print(f"\nRaw directory: {config.raw_dir.resolve()}") print(f"\nPrompt Injection: {len(pi_records)} total records") for source, count in pi_raw_counts.items(): method = load_methods.get(source, "?") print(f" {source}: {count} [{method}]") print(f"\nMalicious Intent: {len(mi_records)} total records") for source, count in mi_raw_counts.items(): method = load_methods.get(source, "?") print(f" {source}: {count} [{method}]") print("\nSplit counts:") print(json.dumps(split_counts, indent=2)) if __name__ == "__main__": main()