Ingest your first CTI report¶
What you will build: A stored threat intelligence report with extracted CTI entities, queryable SQLite graph relationships, semantic recall, and a direct-answer synthesis call about APT28.
Time estimate: 15 minutes
Prerequisites:
- You have completed Quickstart (Tutorial 01).
- ZettelForge 2.7.0 is installed (
pip install zettelforge). - An LLM provider is configured and running. Steps 3, 4, and 8 require LLM inference for full fact extraction and synthesis. Set
ZETTELFORGE_LLM_PROVIDERandZETTELFORGE_LLM_MODELexplicitly before you run this tutorial. Use an installed Ollama tag such asqwen2.5:3b, uselocalwithQwen/Qwen2.5-3B-Instruct-GGUFand the matching GGUF file, or uselitellmwith the API key required by your hosted model. - ZettelForge uses SQLite by default. No external database required.
Verification state
This tutorial is in progress. On July 28, 2026, a verification run covered the SQLite runtime path, the no-LLM fallback, and the API shapes with a deterministic mock provider. A production LLM provider was not available in this environment, so LLM-generated wording, counts, and relationship richness are examples you verify against your provider before relying on them.
Step 1: Start a Python session¶
Open a terminal and start a Python REPL.
python3
Import ZettelForge and create a MemoryManager instance.
from zettelforge import MemoryManager
mm = MemoryManager()
On the default SQLite backend, the manager initializes with no connection message. The storage backend is SQLiteBackend.
Note
This tutorial works with the default SQLite backend. If you set ZETTELFORGE_BACKEND=typedb, run TypeDB on localhost:1729 before you initialize the manager. Local SQLite graph traversal is capped at depth 2.
Step 2: Create a sample threat report¶
Define a threat intelligence report as a Python string. This report describes APT28 using Cobalt Strike to exploit CVE-2024-1111 against the energy sector.
report = """
THREAT INTELLIGENCE REPORT: APT28 Campaign Targeting Energy Sector
Published: 2026-03-15
TLP:AMBER
Russian state-sponsored threat actor APT28 (also known as Fancy Bear) has been
observed conducting a sustained cyber espionage campaign against energy sector
organizations in Western Europe. The campaign, active since January 2026, leverages
Cobalt Strike beacons delivered through spear-phishing emails containing weaponized
PDF attachments.
Technical Analysis:
The initial access vector exploits CVE-2024-1111, a critical remote code execution
vulnerability in PDF rendering libraries (CVSS 9.8). Upon successful exploitation,
the payload deploys a Cobalt Strike beacon configured to communicate with C2
infrastructure hosted on compromised legitimate websites.
APT28 operators use Cobalt Strike's built-in lateral movement capabilities to
pivot through victim networks, targeting operational technology (OT) network
segments connected to SCADA systems. The threat actor has been observed
exfiltrating engineering schematics and network topology documents from
compromised energy utilities.
Indicators of Compromise:
- C2 Domain: update-service.energy-grid[.]com
- Cobalt Strike Beacon Hash: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
- Exploit Payload Hash: 9f8e7d6c5b4a9f8e7d6c5b4a9f8e7d6c
- Spear-phishing Subject: "Q1 2026 Energy Market Compliance Update"
MITRE ATT&CK Mapping:
- T1566.001 - Spear-phishing Attachment
- T1203 - Exploitation for Client Execution (CVE-2024-1111)
- T1071.001 - Web Protocols (Cobalt Strike C2)
- T1021.002 - SMB/Windows Admin Shares (Lateral Movement)
Recommendations:
Patch CVE-2024-1111 immediately. Block the listed IOCs at network perimeter.
Monitor for anomalous SMB traffic between IT and OT network segments.
"""
Step 3: Ingest the report with remember_report()¶
Feed the report into ZettelForge. The remember_report() method chunks the text, runs two-phase LLM extraction on each chunk, and stores the results with CTI domain metadata.
Note
This step requires a configured LLM provider for the full tutorial output. If the LLM is unavailable, remember_report() stores a degraded fallback fact from the chunk and logs warnings; it does not produce the five extracted facts shown below. Start your provider before continuing.
results = mm.remember_report(
content=report,
source_url="https://intel.example.com/reports/apt28-energy-2026",
published_date="2026-03-15",
domain="cti",
chunk_size=3000
)
The call returns a list of (note, status) tuples. Print the list in the next step to inspect the notes ZettelForge stored.
Tip
The output shows both phases in action. Phase 1 (extraction) pulls candidate facts and scores them by importance (1-10). Phase 2 (update) compares each fact against existing memory and decides ADD, UPDATE, or NOOP.
Step 4: Inspect the extraction results¶
Print the results to see what ZettelForge created.
print(f"Total memory operations: {len(results)}\n")
for note, status in results:
if note:
print(f"[{status.upper()}] {note.id}")
print(f" Content: {note.content.raw[:80]}...")
print(f" Domain: {note.metadata.domain}")
print(f" Tier: {note.metadata.tier}")
print(f" Importance: {note.metadata.importance}")
print(f" Keywords: {note.semantic.keywords}")
print(f" Entities: {note.semantic.entities}")
print()
Representative output shape:
Total memory operations: 5
[ADDED] note_20260728_010541_ad4e
Content: APT28 uses Cobalt Strike beacons delivered through spear-phishing PDF attach...
Domain: cti
Tier: A
Importance: 9
Keywords: ['uses', 'cobalt', 'strike', 'beacons', 'delivered', 'through', 'spear']
Entities: ['apt28', 'cobalt-strike']
[ADDED] note_20260728_010541_25e0
Content: CVE-2024-1111 is a critical remote code execution vulnerability in PDF rendering...
Domain: cti
Tier: A
Importance: 9
Keywords: ['critical', 'remote', 'code', 'execution', 'vulnerability', 'rendering', 'libraries']
Entities: ['cve-2024-1111']
[ADDED] note_20260728_010542_50b4
Content: APT28 is conducting cyber espionage against Western European energy sector...
Domain: cti
Tier: A
Importance: 8
Keywords: ['conducting', 'cyber', 'espionage', 'against', 'western', 'european', 'energy']
Entities: ['apt28']
[ADDED] note_20260728_010542_ef58
Content: APT28 targets operational technology and SCADA systems in energy utilities...
Domain: cti
Tier: A
Importance: 8
Keywords: ['targets', 'operational', 'technology', 'scada', 'systems', 'energy', 'utilities']
Entities: ['apt28']
[ADDED] note_20260728_010542_316d
Content: C2 domain update-service.energy-grid.com is used by APT28 Cobalt Strike...
Domain: cti
Tier: A
Importance: 7
Keywords: ['domain', 'update', 'service', 'energy', 'grid', 'used', 'cobalt']
Entities: ['apt28', 'cobalt-strike', 'update-service.energy-grid.com']
Note
Your note IDs and timestamps differ. A successful LLM pass returns one tuple per extracted fact; the sample fact set above produced five added tuples in the July 28, 2026 verification run. Without a working LLM provider, the same script produced one added fallback note for the whole chunk.
Step 5: Query entity relationships from the knowledge graph¶
Check that ZettelForge built STIX entity relationships during ingestion. Use mm.get_entity_relationships() to query direct outgoing edges from an entity in the SQLite knowledge graph.
Note
APT-numbered groups (APT28, APT29, UNC series, FIN series) are indexed with entity_type="intrusion_set" by ZettelForge's entity extractor. Use "intrusion_set" as the entity type when querying them — "actor" returns a different (smaller) category.
actor_rels = mm.get_entity_relationships("intrusion_set", "apt28")
print("=== APT28 Relationships ===")
for rel in actor_rels:
node = rel["node"]
print(f" APT28 --[{rel['relationship']}]--> {node['entity_type']}:{node['entity_value']}")
print()
tool_rels = mm.get_entity_relationships("tool", "cobalt-strike")
print("=== Cobalt Strike Relationships ===")
for rel in tool_rels:
node = rel["node"]
print(f" Cobalt Strike --[{rel['relationship']}]--> {node['entity_type']}:{node['entity_value']}")
Observed output from the July 28, 2026 SQLite verification run:
=== APT28 Relationships ===
APT28 --[USES_TOOL]--> tool:cobalt-strike
APT28 --[MENTIONED_IN]--> note:note_20260728_010541_ad4e
APT28 --[MENTIONED_IN]--> note:note_20260728_010542_50b4
APT28 --[MENTIONED_IN]--> note:note_20260728_010542_ef58
APT28 --[MENTIONED_IN]--> note:note_20260728_010542_316d
=== Cobalt Strike Relationships ===
Cobalt Strike --[MENTIONED_IN]--> note:note_20260728_010541_ad4e
Cobalt Strike --[MENTIONED_IN]--> note:note_20260728_010542_316d
Note
The SQLite path creates MENTIONED_IN edges and selected heuristic co-occurrence edges such as USES_TOOL. The July 28, 2026 verification run did not create explicit EXPLOITS_CVE or TARGETS_ASSET edges from this sample. Treat richer attack-chain edges as provider- and enrichment-dependent until you verify them in your environment.
Step 6: Use recall() to find the ingested intel¶
Search memory using natural language. The recall() method blends vector similarity search with graph traversal.
results = mm.recall("APT28 cobalt strike energy sector", domain="cti", k=5)
print(f"Found {len(results)} relevant notes:\n")
for note in results:
print(f" [{note.id}] (importance={note.metadata.importance})")
print(f" {note.content.raw[:100]}...")
print()
Observed output from the July 28, 2026 SQLite verification run:
Found 4 relevant notes:
[note_20260728_010542_50b4] (importance=8)
APT28 is conducting cyber espionage against Western European energy sector organizations...
[note_20260728_010542_ef58] (importance=8)
APT28 targets operational technology and SCADA systems in energy utilities...
[note_20260728_010541_25e0] (importance=9)
CVE-2024-1111 is a critical remote code execution vulnerability in PDF rendering...
[note_20260728_010542_316d] (importance=7)
C2 domain update-service.energy-grid.com is used by APT28 Cobalt Strike beacons...
Step 7: Walk the relationship chain with traverse_graph()¶
Traverse the knowledge graph starting from APT28 to discover the full attack chain.
paths = mm.traverse_graph(start_type="intrusion_set", start_value="apt28", max_depth=2)
print(f"Graph traversal found {len(paths)} paths:\n")
for i, path in enumerate(paths):
chain = []
for step in path:
if not chain:
chain.append(f"{step['from_type']}:{step['from_value']}")
chain.append(f"--[{step['relationship']}]--> {step['to_type']}:{step['to_value']}")
print(f" Path {i+1}: {' '.join(chain)}")
Observed output from the July 28, 2026 SQLite verification run:
Graph traversal found 8 paths:
Path 1: intrusion_set:apt28 --[MENTIONED_IN]--> note:note_20260728_010542_ef58
Path 2: intrusion_set:apt28 --[MENTIONED_IN]--> note:note_20260728_010542_50b4
Path 3: intrusion_set:apt28 --[MENTIONED_IN]--> note:note_20260728_010542_316d
Path 4: intrusion_set:apt28 --[MENTIONED_IN]--> note:note_20260728_010542_316d --[SUPERSEDES]--> note:note_20260728_010541_ad4e
Path 5: intrusion_set:apt28 --[USES_TOOL]--> tool:cobalt-strike
Path 6: intrusion_set:apt28 --[USES_TOOL]--> tool:cobalt-strike --[MENTIONED_IN]--> note:note_20260728_010542_316d
Path 7: intrusion_set:apt28 --[USES_TOOL]--> tool:cobalt-strike --[MENTIONED_IN]--> note:note_20260728_010541_ad4e
Path 8: intrusion_set:apt28 --[MENTIONED_IN]--> note:note_20260728_010541_ad4e
The USES_TOOL path shows APT28 connected to Cobalt Strike. This verification run did not produce an explicit path from APT28 to CVE-2024-1111 on the SQLite path.
Step 8: Synthesize a threat brief¶
Use synthesize() to generate a direct-answer brief about APT28 from all ingested memory. The synthesis engine retrieves relevant notes, builds context, and produces a structured answer through the LLM.
Note
This step requires a configured LLM. Without an LLM, synthesis returns a confirmed fallback response: synthesis["answer"] = "No specific answer found...", synthesis["confidence"] = 0.0, synthesis["sources"] = []. Retrieval still runs, but the LLM answer is empty. This tutorial uses direct_answer; re-audit the other synthesis formats before you rely on them in production.
result = mm.synthesize(
query="What do we know about APT28?",
format="direct_answer",
k=10
)
synthesis = result["synthesis"]
meta = result["metadata"]
print("=== APT28 Direct Answer ===\n")
print(synthesis["answer"])
print(f"\nConfidence: {synthesis['confidence']}")
print(f"\nSources used: {meta['sources_count']}")
print(f"Model: {meta['model_used']}")
print(f"Latency: {meta['latency_ms']}ms")
Output shape (requires a working LLM provider):
=== APT28 Direct Answer ===
<LLM-generated answer grounded in the retrieved notes>
Confidence: <float between 0 and 1>
Sources used: <number of retrieved notes>
Model: qwen2.5:3b
Latency: <milliseconds>ms
What you built¶
You ingested a raw threat intelligence report and turned it into structured, queryable knowledge:
- Chunked ingestion --
remember_report()split the report and ran two-phase LLM extraction on each chunk - Fact extraction -- The sample fact set produced five scored facts with a configured provider; the fallback path produced one degraded chunk-level note when the provider was unavailable
- Entity indexing -- The entity index records values such as
intrusion_set:apt28,tool:cobalt-strike,cve:cve-2024-1111, anddomain:update-service.energy-grid.comwhen those entities appear in extracted facts - Relationship query --
get_entity_relationships()retrieves direct KG edges for any entity - Graph traversal --
traverse_graph()walks the available relationship paths from an entity - Semantic recall --
recall()blends vector similarity and graph traversal for ranked retrieval - Synthesis --
synthesize()generates an LLM-backed answer grounded in your stored intelligence
Next steps¶
- How to ingest a news report
- How to store a threat actor
- How to integrate with your LLM agent
- API reference: MemoryManager
LLM quick reference¶
This section is for LLM agents consuming ZettelForge's CTI ingestion pipeline programmatically.
Ingest a report -- mm.remember_report(content, source_url, published_date, domain="cti", chunk_size=3000). Returns List[Tuple[Optional[MemoryNote], str]] where each tuple is (note, status) and status is one of "added", "updated", "corrected", "noop". Chunks on sentence boundaries, runs Phase 1 fact extraction (LLM scores importance 1-10, filters by min_importance=3), then Phase 2 update decisions (ADD/UPDATE/DELETE/NOOP against existing memory). Default max_facts=10 per chunk. Requires LLM for multi-fact extraction. If the provider is unavailable, the current fallback stores one degraded fact from the chunk.
Recall intel -- mm.recall(query, domain="cti", k=10). Returns List[MemoryNote] ranked by blended vector + graph score. Superseded notes are excluded by default. For entity-specific lookup: mm.recall_entity(entity_type, entity_value) where entity_type is one of the indexed entity buckets, including cve, intrusion_set, actor, tool, campaign, attack_pattern, domain, url, md5, sha1, and sha256. Shortcuts: mm.recall_cve("CVE-2024-1111"), mm.recall_actor("apt28"), mm.recall_tool("cobalt-strike").
Query entity relationships -- mm.get_entity_relationships(entity_type, entity_value). Returns List[Dict] where each item has node (dict with entity_type, entity_value), relationship (str), edge_properties (dict), and note_id (str). Writes to the SQLite KG during remember() and remember_report().
Traverse the graph -- mm.traverse_graph(start_type, start_value, max_depth=2). Returns List[List[Dict]] -- a list of paths, each path a list of steps. Each step has from_type, from_value, relationship, to_type, to_value. Depth is capped at 2 in the local SQLite path. Valid start_type values include intrusion_set, actor, tool, cve, campaign, asset, and note. APT-numbered groups (APT28, UNC series) are indexed as intrusion_set, not actor. The July 28, 2026 SQLite tutorial run observed USES_TOOL, MENTIONED_IN, and SUPERSEDES relationships.
Synthesize a brief -- mm.synthesize(query, format="direct_answer", k=10). Returns dict with top-level keys query, format, synthesis, metadata, sources. For "direct_answer": synthesis["answer"] (str), synthesis["confidence"] (float 0-1), synthesis["sources"] (list). ZettelForge exposes additional synthesis formats in source, but this tutorial only verifies direct_answer. Requires LLM for non-fallback output.
MemoryNote fields -- id (str), content.raw (str), semantic.keywords (list), semantic.entities (list), metadata.domain (str), metadata.tier (str, A/B/C), metadata.importance (int 1-10), metadata.confidence (float), links.superseded_by (optional str), links.supersedes (list).
Configuration -- Set ZETTELFORGE_LLM_PROVIDER and ZETTELFORGE_LLM_MODEL explicitly before you run extraction. The July 28, 2026 source checkout has hardcoded Ollama-oriented fallback values in LLMConfig, a config.default.yaml sample with an unresolved Ollama model placeholder, and a root config.yaml that selects litellm with nvidia/meta/llama-3.1-70b-instruct. The verification run did not confirm the sample placeholder as a working upstream model tag. To run fully offline, set ZETTELFORGE_LLM_PROVIDER=local, use Qwen/Qwen2.5-3B-Instruct-GGUF, and set the matching GGUF filename in provider extra options. Default embedding is nomic-embed-text-v1.5-Q via fastembed (in-process). Environment overrides: ZETTELFORGE_LLM_MODEL, ZETTELFORGE_LLM_PROVIDER, ZETTELFORGE_LLM_URL, ZETTELFORGE_LLM_API_KEY, TYPEDB_HOST, TYPEDB_PORT, TYPEDB_DATABASE.