Skip to content

Resolve threat actor aliases

ZettelForge normalizes entity names before indexing. When you call remember(), every entity extracted from the text passes through AliasResolver first. That means a note mentioning "Fancy Bear" and a note mentioning "APT28" are both indexed under the canonical value apt28 — so queries that use the canonical name find both.

recall_actor() and recall_entity() are direct index lookups. They search for the value you pass in, lowercased. They do not resolve aliases at query time. Use the canonical name in those calls, or use the full recall() method, which runs entity extraction and alias resolution on the query text.

This guide covers how alias resolution works, how to use it directly, and how to extend it with your own mappings.

Prerequisites

  • ZettelForge installed: pip install zettelforge

Steps

1. Understand where alias resolution happens

Resolution happens at store time, inside remember():

text -> entity_extractor -> ["Fancy Bear"] (type: actor)
                         -> resolver.resolve("actor", "fancy bear")
                         -> "apt28"
                         -> indexed as (actor, apt28)

At query time, recall_actor() does a direct index lookup:

from zettelforge.memory_manager import MemoryManager

mm = MemoryManager()

# Direct index lookup — use the canonical name
notes_apt28 = mm.recall_actor("APT28", k=5)

# This searches for "fancy bear" in the index, not "apt28"
# Returns empty unless notes were ingested with the exact alias present
notes_fancy = mm.recall_actor("Fancy Bear", k=5)

print(f"APT28 (canonical):  {len(notes_apt28)} notes")
# notes indexed at store time as "apt28"; querying "fancy bear" finds nothing
print(f"Fancy Bear (alias): {len(notes_fancy)} notes")

To query with natural language (where aliases are resolved before lookup), use recall():

# recall() extracts entities from the query string and resolves aliases
notes = mm.recall("What tools has Fancy Bear used?", k=10)
print(f"Found {len(notes)} notes via full recall")

2. Use AliasResolver directly

AliasResolver is available standalone for debugging or pre-processing entity names before passing them to recall_entity():

from zettelforge.alias_resolver import AliasResolver

resolver = AliasResolver()

# Built-in actor aliases (verified live)
print(resolver.resolve("actor", "Fancy Bear"))   # apt28
print(resolver.resolve("actor", "fancy-bear"))   # apt28
print(resolver.resolve("actor", "FANCY BEAR"))   # apt28
print(resolver.resolve("actor", "Pawn Storm"))   # apt28
print(resolver.resolve("actor", "Cozy Bear"))    # apt29

# Unknown entity — returns input lowercased (passthrough)
print(resolver.resolve("actor", "Volt Typhoon")) # volt typhoon
print(resolver.resolve("actor", "APT28"))        # apt28 (passthrough, already canonical)
print(resolver.resolve("actor", "NewGroup42"))   # newgroup42

To use aliases safely with recall_actor(), resolve first:

canonical = resolver.resolve("actor", "Fancy Bear")  # "apt28"
notes = mm.recall_actor(canonical, k=5)

Built-in aliases are intentionally minimal

ZettelForge ships with six hardcoded entries covering only two actors: "fancy bear", "fancy-bear", "pawn storm", "pawn-storm" (all map to apt28) and "cozy bear", "cozy-bear" (both map to apt29). The tool type has no built-in aliases. All other mappings come from your local JSON file or TypeDB.

Input normalization rules (verified from source and live execution):

  • Lowercased before lookup: "FANCY BEAR" becomes "fancy bear"
  • Hyphens replaced with spaces: "fancy-bear" becomes "fancy bear"
  • Passthrough: if no match found, returns input lowercased

3. Add custom aliases via entity_aliases.json

Create or update ~/.amem/entity_aliases.json to add aliases for any groups or tools your threat intel sources reference by non-canonical names:

import json
from pathlib import Path

alias_file = Path.home() / ".amem" / "entity_aliases.json"
alias_file.parent.mkdir(parents=True, exist_ok=True)

# Load existing aliases if the file exists
aliases = {}
if alias_file.exists():
    aliases = json.loads(alias_file.read_text())

# Add actor aliases
aliases.setdefault("actor", {})
aliases["actor"]["charming kitten"] = "apt35"
aliases["actor"]["phosphorus"] = "apt35"
aliases["actor"]["bronze silhouette"] = "volt typhoon"

# Add tool aliases
aliases.setdefault("tool", {})
aliases["tool"]["sliver c2"] = "sliver"
aliases["tool"]["cs beacon"] = "cobalt strike"

alias_file.write_text(json.dumps(aliases, indent=2))
print(f"Written to {alias_file}")

Verify (live-tested):

resolver = AliasResolver()  # Reloads from file at __init__

print(resolver.resolve("actor", "Charming Kitten"))    # apt35
print(resolver.resolve("actor", "Phosphorus"))          # apt35
print(resolver.resolve("actor", "Bronze Silhouette"))   # volt typhoon
print(resolver.resolve("tool", "CS Beacon"))            # cobalt strike
print(resolver.resolve("actor", "Cozy Bear"))           # apt29 (hardcoded still applies)

Reload after editing

Changes to entity_aliases.json are picked up when a new AliasResolver instance is created. MemoryManager creates its resolver at __init__. To pick up new entries in a long-running process, create a new MemoryManager instance.

JSON file schema:

{
  "actor": {
    "alias phrase": "canonical name"
  },
  "tool": {
    "alias phrase": "canonical name"
  }
}

All keys and values are stored and matched as lowercase. The JSON entries extend the hardcoded dict — they do not replace it. If the same key appears in both, the JSON file wins.

4. TypeDB alias resolution (optional)

AliasResolver queries TypeDB for alias-of relations before consulting the local dict. In the default OSS configuration (SQLite + LanceDB backend), TypeDB is not active and this path is skipped automatically.

To inspect which resolution path is active:

resolver = AliasResolver()

# _try_typedb_resolve returns None if TypeDB is not configured
result = resolver._try_typedb_resolve("actor", "fancy bear")
print(f"TypeDB result: {result}")              # None if TypeDB unavailable

print(f"TypeDB available: {resolver._typedb_available}")  # False after first failure

# Full path (TypeDB first, then local dict)
canonical = resolver.resolve("actor", "fancy bear")
print(f"Final result: {canonical}")            # apt28 (from local dict)

If TypeDB is unreachable, _typedb_available is set to False for the lifetime of the instance and all subsequent lookups go directly to the local dict. No error is raised.

TypeDB-backed alias management with production alias seeding is available in ThreatRecall.ai.

Troubleshooting

recall_actor() returns empty results for a group I know is in my store.

recall_actor() is a direct index lookup. It passes your query value (lowercased) to the SQLite entity index without alias resolution. If you stored notes about APT28 that were ingested when text said "Fancy Bear", those notes are indexed as apt28 (resolved at store time). Query with the canonical name:

# Correct: use canonical name
notes = mm.recall_actor("APT28", k=5)

# Or resolve first, then query
resolver = AliasResolver()
canonical = resolver.resolve("actor", "Fancy Bear")  # "apt28"
notes = mm.recall_actor(canonical, k=5)

# Or use full recall() for natural language queries
notes = mm.recall("What do we know about Fancy Bear?", k=10)

An alias I added to entity_aliases.json is not resolving.

Confirm the alias key is in the correct entity type bucket ("actor", "tool"). Confirm the JSON is valid (python3 -m json.tool ~/.amem/entity_aliases.json). Confirm the AliasResolver instance was constructed after the file was written.

I need aliases that work for both "actor" and "intrusion_set" entity types.

The hardcoded dict contains only the "actor" key. If your store has notes indexed under "intrusion_set" (the STIX-aligned type, used by default for APT/UNC/FIN groups), add the alias under both keys in your JSON file:

{
  "actor": {"fancy bear": "apt28"},
  "intrusion_set": {"fancy bear": "apt28"}
}