ThreatLens · ZEIT3118 Capstone

Extraction Engine

The extraction engine turns a CTI report (PDF or plain text) into a typed set of candidate indicators of compromise, plus document metadata. It works in two stages that are kept strictly apart:

Verification classifies; it never extracts. Every value in the output was found by the regex stage at a known offset, so a verifier cannot invent an indicator, and the start/end offsets can be trusted.

Two engines, one front end

There are two engines, chosen per run as llm (the default) or python. They are two modules, but not two separate extractors:

ioc_extract_llm_fs.py                         ioc_extract_python.py
┌──────────────────────────────────────┐
│ DocumentReader     (read PDF / text) │
│ MetadataExtractor  (title, date, …)  │ ◀──── imports ─── steps 1–3 and the
│ CTIExtractor       (regex candidates)│                   domain model
│ IoCExtractionResult (output object)  │
├──────────────────────────────────────┤      ┌───────────────────────────┐
│ DocumentProfiler + LLMVerifier       │      │ HeuristicVerifier         │
│ (Ollama)                             │      │ (local regex rules)       │
└──────────────────────────────────────┘      └───────────────────────────┘

ioc_extract_llm_fs.py owns the shared front end: reading, metadata, candidate extraction, the domain model (Extraction, Verification, IoCExtractionResult, …). ioc_extract_python.py imports all of that and adds only its own verifier. The consequences:

llm — ioc_extract_llm_fs.pypython — ioc_extract_python.py
VerifierLLMVerifier: an Ollama-hosted model reads each candidate against a written rubricHeuristicVerifier: keyword and structure rules around each candidate
Document profileYes: one DocumentProfiler call per report (structure summary + scope)No
Context usedThe candidate's ±300-character excerpt, the document metadata and the structure summaryA ±140-character window inside that excerpt, plus the document metadata
ReasonsThe model's own reason and evidence quote for each decisionA fixed reason string for whichever rule fired
NeedsA reachable Ollama server with the model pulledNothing: no network, no settings files
SpeedMinutes per report (mean 4m 50s in the ex4 run)Seconds per report
result.modelThe verifier model, e.g. qwen3.5:9bpython-heuristics
result.scopeA ScopeAssessmentNone
RoleThe default, and the engine every evaluation run usesA deterministic fallback for when no LLM is available

The engines are alternatives, not a pipeline: a single run uses exactly one of them. They meet in two places. If a report already in the database is ingested again with the other engine, its source is marked extraction_engine = 'llm+python' instead of being overwritten. And the quality checker can grade how far the two engines agree, but only when given both engines' JSON from its own command line (see Hand-off).

Pipeline

report (.pdf via PyMuPDF, or .txt/.md/.log/.csv/.json)
      │
      ▼
1  DocumentReader.load        text + SHA-256 of the file bytes + embedded PDF metadata
      │
      ▼
2  MetadataExtractor          title, authors, cti_source, source_url, publish_date
      │                       (conservative heuristics; each field records its method)
      ▼
3  CTIExtractor.extract       regex candidates, each with a ±300-char context excerpt
      │
      ├──────────── llm engine ────────────┐     ┌──── python engine ────┐
      ▼                                     │     ▼                       │
4  DocumentProfiler (1 LLM call)            │  4  HeuristicVerifier       │
      │  structure summary + scope          │     (per candidate)       │
      ▼                                     │                           │
5  LLMVerifier (batched LLM calls)          │                           │
      │  verdict, confidence, reason,       │                           │
      │  evidence per candidate             │                           │
      ▼                                     │                           │
6  _assess_scope                            │                           │
      │                                     │                           │
      ▼                                     ▼                           ▼
7  IoCExtractionResult  ── metadata, items (candidate + verification), model, scope

Steps 1–3: the shared front end

Steps 4–6: LLM verification

Step 4 (python engine): heuristic verification

HeuristicVerifier checks rules in a fixed order and stops at the first that fires:

  1. intelligence entities → context_entity (or rejected in pure reference context);
  2. behaviour descriptions → context_entity;
  3. reference wording just before the value, or a match on the report's own source → rejected;
  4. per-type rules for e-mail addresses, hashes, host artefacts and network observables, using an IOC-section heading, malicious-context words (MALICIOUS_CONTEXT_RE), malicious actions (MALICIOUS_ACTION_RE), defanging and benign wording;
  5. otherwise uncertain.

Why regex alone isn't enough, and why the LLM is the default

Left unchecked, the candidate stage produces plenty of noise: a citation URL, a vendor's own domain, an ATT&CK name that looks like a cryptonym, a victim's domain in a targets table, a signed binary mentioned only because malware abused it. Whether such a value is an IoC depends on what the surrounding sentence, or the report's structure, says. Matching the string itself cannot settle it.

The python verifier narrows the gap with keyword rules one layer up, but with the same limits:

The LLM reads the excerpt against a written rubric and the document profile, and explains each decision. That is why it is the default; the python engine is there for when no LLM is available.

The verdict is final within the engine

analyse_document() attaches each verifier decision to its candidate unchanged. Nothing in the extraction engine adjusts, blends or second-guesses a verdict. Downstream, the quality checker can still reject a candidate on structural or STIX grounds whatever the verdict, but it does not re-run verification.

Where it is called from

Every caller goes through one function per engine, analyse_document():

Entry pointCalls the engine viaWhat happens to the result
threatlens (CLI)cli.pySummary printed; with -o, written as JSON. No database.
Dashboard Ingest page (file queue and "Download from URL")ingest_jobs.py starts python -m threatlens.dashboard.ingest_worker, one process per reportQuality checks, written to the active database, rescored
threatlens-ingestingest_worker.py, run directly in the terminalSame as the Ingest page
.dev_tools/run_ex_pipeline.py (evaluation runs)ingest_worker.run(), per report in a batchSame as the Ingest page
python -m threatlens.extractors.ioc_extract_llm_fs / …ioc_extract_pythonEach module's own main()Developer tool: JSON output, with extra flags (--no-llm, --keep-duplicates, per-setting overrides). The llm module reads threatlens.conf (or --config FILE) like the CLI, with each flag overriding its setting; the python module reads no settings.

Why there are two routes

Settings

The python engine reads no settings. For the llm engine, both cli.py and ingest_worker.py take every setting from threatlens.conf, with built-in defaults for anything it leaves out: the Ollama URL ([ollama]), the verifier and profiler models ([models]) and the performance tuning ([llm]: batch size, timeouts, context sizes, retries). extractors/calibration.py (threatlens-calibrate) benchmarks the server and writes [ollama], the verifier model and [llm]; the dashboard's Configuration page edits the URL and models and keeps [llm]. (These used to be split with a separate llm_calibration.conf, looked up in the current directory by the CLI but in the repo root by the worker; there is now one file and one lookup.) Before an llm run, both callers print a warning for each setup step not yet done. See CLI for the full table and calibration.

Hand-off

The engine's output is always an IoCExtractionResult. How it leaves the engine depends on the caller.

From the CLI: JSON, on request

IoCExtractionResult ──to_dict()──▶ JSON file (-o)
                                     │  optional, by hand:
                                     ▼
                     python -m threatlens.quality.quality_checker out.json [--compare other.json]
                                     │  load_run() → check_report()
                                     ▼
                              printed quality report (no database)

The JSON contains metadata, verification_model, scope, summary, every candidate in indicators (with its verification), and accepted_by_category. The quality checker reads this format directly. Giving it both engines' JSON for the same report (--compare) is the only place the two verdicts are graded against each other. See CLI.

From the web UI and threatlens-ingest: in memory

IoCExtractionResult
      │  quality_checker.run_from_payload(result)   (calls result.to_dict(); no file)
      ▼
ExtractionRun ──▶ check_report([run], policy from the database's scoring_config)
      │
      ▼
QualityReport ──▶ persist_report() ──▶ sources, raw_documents, indicators,
      │                                 sightings, quality_flags   (commit)
      ▼
rescore_from_db() ──▶ timeliness / accuracy refreshed for stored indicators (commit)

The worker never writes the result to disk. It runs one engine, so check_report gets a single run and every candidate's agreement grade is single_verifier. What travels where:

From the extraction resultEnds up as
metadata.file_sha256The document's identity (raw_documents.sha256); re-ingesting the same file reuses its source
metadata (title, date, URL, …)sources.raw_metadata, sources.url; publish_date drives timeliness
modelsources.verification_model, and sources.extraction_engine (llm / python, widened to llm+python on re-ingest with the other engine)
Each candidate's value, type, corrected type, offsetsAn indicators row and a sightings row, if it survives the quality checks
Each candidate's context excerptsightings.excerpt, one per report. The first report's excerpt is also kept in indicators.context, alongside the structured context the quality checker builds from the report's context entities (see Database & Schema).
Verdict + verdict confidenceTranslated into indicators.confidence (maliciousness) and kept in score_components
scope, summaryNot stored. The quality checker works out the report's audience from the surviving candidates instead, though relevance currently scores 3 for every indicator (see Quality Checker).

See the Quality Checker for what happens to each candidate after hand-off.

Source