Skip to content

Query what tools an APT group uses

Retrieve tool-usage relationships for a threat actor using blended vector + graph retrieval, direct graph traversal, and entity-indexed lookups.

Prerequisites

  • ZettelForge installed and initialized (see Quickstart)
  • CTI data already stored (see Store a threat actor)
  • An LLM provider configured if you use recall() or synthesize() (not required for graph traversal steps 4–5)

Steps

1. Initialize MemoryManager

from zettelforge.memory_manager import MemoryManager

mm = MemoryManager()
notes = mm.recall(
    query="What tools does APT28 use?",
    domain="cti",
    k=10,
)

for note in notes:
    print(f"[{note.metadata.confidence:.2f}] {note.content.raw[:120]}")

recall() classifies the query intent internally. A relational query like "what tools does X use" increases the weight given to graph traversal in the blended retrieval pipeline, surfacing notes connected via USES_TOOL edges in addition to vector-similar content.

Returns: list[MemoryNote]. Requires a configured LLM and embedding model.

3. Synthesize an answer

result = mm.synthesize(
    query="What tools does APT28 use?",
    format="direct_answer",
    k=10,
)

print(result["synthesis"]["answer"])

for source in result.get("sources", []):
    print(f"  source: {source['note_id']} (confidence: {source['confidence']})")

Use format="direct_answer" for a short factual response. Two other formats are available in ThreatRecall.ai (SaaS) but fall back silently to direct_answer in ZettelForge OSS:

Format Available in Output
direct_answer ZettelForge OSS + ThreatRecall.ai Short factual response
synthesized_brief ThreatRecall.ai Paragraph summary with sourcing
relationship_map ThreatRecall.ai Entity relationship summary

synthesize() requires a running LLM provider. If no LLM is configured, use steps 4–5 instead.

4. Traverse the graph directly (no LLM required)

paths = mm.traverse_graph(
    start_type="intrusion_set",
    start_value="apt28",
    max_depth=2,
)

# paths is a list of paths; each path is a list of steps
tools = set()
for path in paths:
    for step in path:
        if step["relationship"] == "USES_TOOL":
            tools.add(step["to_value"])

print(f"Tools used by APT28: {sorted(tools)}")

Each step dict has keys: from_type, from_value, relationship, to_type, to_value.

Use start_type="intrusion_set" for APT-numbered designations (APT28, UNC4899, FIN7). Use start_type="actor" for named actors that do not carry a numeric designation (Lazarus Group, Sandworm, Volt Typhoon). If unsure which type was stored, run both lookups and merge.

traverse_graph() is capped at max_depth=2 in ZettelForge OSS.

5. Get single-hop relationships (no LLM required)

relationships = mm.get_entity_relationships("intrusion_set", "apt28")

tool_rels = [r for r in relationships if r["relationship"] == "USES_TOOL"]
cve_rels  = [r for r in relationships if r["relationship"] == "EXPLOITS_CVE"]

tools = [r["node"]["entity_value"] for r in tool_rels]
cves  = [r["node"]["entity_value"] for r in cve_rels]

print(f"Tools:      {tools}")
print(f"CVEs:       {cves}")

Each item in the returned list has keys: node (with entity_type, entity_value), relationship, edge_properties, note_id.

This is faster than traverse_graph() when you only need direct (depth-1) neighbors.

6. Use entity-specific fast lookups

# All notes mentioning APT28 (searches actor, threat_actor, intrusion_set types)
actor_notes = mm.recall_actor("APT28", k=5)

# All notes mentioning Cobalt Strike
tool_notes = mm.recall_tool("Cobalt Strike", k=5)

# Notes that mention both
actor_ids = {n.id for n in actor_notes}
tool_ids  = {n.id for n in tool_notes}
overlap   = actor_ids & tool_ids

print(f"Notes mentioning both APT28 and Cobalt Strike: {len(overlap)}")

recall_actor() searches actor, threat_actor, and intrusion_set entity types automatically, so it finds APT-numbered designations and named aliases alike.

recall_actor(), recall_tool(), and recall_cve() use the in-memory entity index for O(1) lookup. They bypass vector search entirely and are faster than recall() when you know the specific entity name.

7. Compare tool usage across actors (no LLM required)

actors = [
    ("intrusion_set", "apt28"),
    ("actor",         "lazarus group"),
    ("actor",         "volt typhoon"),
]

for entity_type, actor in actors:
    rels  = mm.get_entity_relationships(entity_type, actor)
    tools = [r["node"]["entity_value"] for r in rels if r["relationship"] == "USES_TOOL"]
    print(f"{actor}: {tools or '(none stored)'}")

Alias resolution

All query methods (recall(), recall_actor(), get_entity_relationships(), traverse_graph()) resolve aliases automatically before lookup. The following aliases are built into ZettelForge OSS:

Alias Resolves to
Fancy Bear, Fancy-Bear, Pawn Storm apt28
Cozy Bear apt29

Additional aliases from your connected MISP or OpenCTI instance are resolved dynamically in ThreatRecall.ai.

When you do not need an LLM

Steps 4, 5, and 7 work with no LLM configured. They read directly from the SQLite knowledge graph built during the remember() / remember_report() ingestion phase and do not call any generative model.

Steps 2 and 3 (recall(), synthesize()) require an embedding model and an LLM provider respectively.