Ledgerhound is a crypto-scam campaign intelligence pipeline. It ingests public scam and phishing reports, extracts and validates indicators of compromise (cryptocurrency addresses, domains, URLs, Telegram handles), enriches them, clusters related indicators into campaigns based on shared infrastructure and fund flows, and produces STIX 2.1 bundles and written threat intelligence reports for analysts and downstream tooling.
This document describes the product as a whole and is written for consumers of its output — analysts, other intel teams, and engineers integrating Ledgerhound's data — not just for contributors to this repo.
Status: All six milestones are implemented — collection, extraction, enrichment, clustering, assessment, export (STIX 2.1 / MISP / IOC feed / Markdown+PDF reports), and a browser dashboard over the same database. See Roadmap for what's deliberately out of scope.
At full build-out, Ledgerhound is designed to answer three questions an analyst or automated defense typically has about a crypto scam report:
- Is this indicator part of a known campaign? — Given an address, domain, or handle, which cluster of related infrastructure does it belong to, and what else is in that cluster?
- What does this campaign's infrastructure look like? — Shared receiving wallets, cash-out paths, TLS certificates, DNS registrar/nameserver pairs, and phishing page templates, expressed as a graph of typed relationships between indicators.
- What should a defender or investigator do with this? — A written assessment per campaign summarizing scope, confidence, and recommended actions, a machine-readable STIX 2.1 bundle, and a MISP feed/IOC feed, all for ingestion into existing threat-intel tooling.
Today, Ledgerhound answers all three: what validated indicators have been reported, by whom, how often; what does public data say about them right now (on-chain balance and counterparties, downstream fund flow, DNS/WHOIS/certificate/hosting data, and known-bad labels); which indicators are actually part of the same campaign, evidence-backed by shared wallets, cash-out paths, TLS certificates, DNS infrastructure, or phishing-page templates; a structured written assessment of that campaign — key judgements, targeting, MITRE ATT&CK tradecraft, financial analysis, and outlook — drafted by an LLM from that evidence but never published without a human's explicit sign-off; and, once approved, a publishable set of intelligence products — a STIX 2.1 bundle, a MISP feed, a static IOC feed, and a Markdown/PDF report with its methodology and limitations spelled out, not just its conclusions.
Ledgerhound collects from public, community- or vendor-maintained scam and
phishing feeds. Each source is registered in config/sources.yaml with an
Admiralty/NATO reliability grade
(A–F) reflecting how much independent corroboration and vetting the source
applies before publishing an entry — this grade is a property of the feed
as a collection process, not of any individual indicator's accuracy, and
is intended to travel downstream into confidence scoring at the assessment
stage.
Sources configured today:
| Source | Type | What it publishes | Reliability |
|---|---|---|---|
| ScamSniffer | Phishing feed | Community-reported malicious Web3 domains and wallet addresses, JSON blocklists | B — usually reliable; crowdsourced, no per-entry vetting disclosed |
| URLhaus (abuse.ch) | Phishing feed | Recently-reported malicious/malware-distribution URLs, CSV | B — usually reliable; automated + community reporter submissions, abuse.ch applies some validation |
Collection is pull-based and scheduled per source via cadence_minutes.
Every fetch is stored verbatim as a raw_report before any parsing
happens, so extraction logic can be revised and re-run retroactively
without re-fetching, and so the pipeline has an audit trail back to the
literal upstream payload for every indicator it has ever produced.
Adding a source means implementing ledgerhound.collect.base.Collector
(see ledgerhound/collect/scamsniffer.py for a minimal example) and adding
an entry to config/sources.yaml. No other code changes are required —
the CLI and dedupe/storage layer are source-agnostic.
Indicator extraction (ledgerhound/extract/) deliberately does not trust
regex shape-matching alone: plenty of random base58/hex strings match an
address's shape without being a valid address. Every candidate is
cryptographically or structurally validated before being stored:
- Bitcoin — legacy (
1.../3...) addresses are validated against their base58check checksum; bech32 (bc1...) addresses are validated against the BIP-173 checksum, including rejecting addresses that mix upper/lowercase within a single string (a BIP-173 requirement, and a common corruption pattern in copy-pasted reports). - Ethereum —
0x+ 40 hex chars. Mixed-case addresses are validated against their EIP-55 checksum and rejected on mismatch; all-lowercase or all-uppercase addresses have no checksum to validate and are canonicalized to EIP-55 mixed-case form for storage, so the same address never ends up split across multiple casing variants in the database. - TRON — base58check with the TRON version byte (
0x41),T-prefixed. - Solana — base58, decoded and checked for a 32-byte public key.
- Domains/URLs — parsed with
urllib.parse, not string splitting; tracking query parameters (utm_*,gclid,fbclid, etc.) are stripped during canonicalization so the same phishing link with different campaign-tracking tags collapses to one indicator; bare IP literals are detected and routed to a separate rejected-IPs bucket rather than being stored as domains; already-defanged input (hxxp,[.],(dot),[at]) is refanged before matching, so indicators pasted out of a report or forum post are still recognized. - Telegram handles —
@name, 5–32 chars, with email local-parts (user@example.com) masked out first so they aren't misread as handles.
A URL's host is stored both as the full URL indicator and, separately, as a domain indicator — infrastructure-level clustering (shared registrar/NS, shared certificate) operates on domains, and a scam rarely reuses the exact same URL path across its full domain footprint.
Extraction is idempotent: indicators are upserted by (type, value), and
first_seen/last_seen only ever widen to cover the full range of
reports an indicator has appeared in, regardless of what order reports are
processed in. Re-running extraction over already-processed reports creates
no duplicate rows.
Every chain address and domain indicator gets enriched from public,
keyless sources — no API key is required to run the full pipeline,
anywhere. Enrichment is async (ledgerhound/enrich/) since it's
dominated by waiting on many independent network calls, not compute.
Multi-source with automatic fallback. Every kind of data has at least
two independent providers in a priority-ordered chain. The registry
(enrich/registry.py) tracks each provider's health (consecutive
failures, a backoff cooldown) and skips an unhealthy provider
automatically, retrying it after the cooldown elapses. Every enrichment
row records which provider actually served it — provenance that carries
through into any report built on this data. lh providers shows current
health; lh enrich --provider NAME forces a single source for testing.
| Data | Primary | Fallback | Notes |
|---|---|---|---|
| BTC address | mempool.space | blockstream.info | Both are Esplora-API deployments — genuinely interchangeable. |
| ETH address | Blockscout | public JSON-RPC | RPC has no "history for an address" method, so it only ever returns balance + nonce (partial=True). Blockscout leads so the fallback isn't permanently dead code — see the docstring in enrich/chain/eth.py for why this inverts the milestone brief's literal "RPC primary" ordering. |
| TRON address | TronGrid | Tronscan | Both parse native TRX transfers only — TRC20/TRC10 token transfers move no native value and aren't valued in this milestone. |
| SOL address | Solana RPC (api.mainnet-beta.solana.com) |
a second public Solana RPC | Solana's own RPC exposes real address history (getSignaturesForAddress), unlike Ethereum, so both sides of this pair are equally complete. Counterparty attribution from a transaction's balance deltas is a heuristic for anything beyond a simple two-party transfer (always partial=True). |
| Domain DNS | a full recursive resolver (dnspython) | Cloudflare DNS-over-HTTPS | DoH is a real fallback, not a token one: it survives networks that block raw port-53 DNS. |
| Domain registration | RDAP via IANA's bootstrap registry | RDAP via rdap.org, then real port-43 WHOIS | Three-tier chain, all writing the same DomainRegistrationProfile shape. RDAP is primary — plain HTTPS/JSON, ICANN-mandated for gTLDs — but deployment isn't universal (.sk, .ch, and others have no RDAP server in IANA's bootstrap registry at all; .cz has an entry but an unreliable server), so classic WHOIS is kept as a real fallback, not dropped: per-TLD server resolved via IANA's own WHOIS referral (never hardcoded), a per-TLD parser (.sk, verified against the live registry) with a generic best-effort fallback parser for everything else (partial=True when fields can't be extracted). An RDAP 404 or a WHOIS "not found" response is a valid registered=False result, not an error. |
| Domain certificates | crt.sh | (none) | The one facet without a fallback pair: crt.sh is the only free, comprehensive CT-log aggregator; a real second source means paying for one or implementing the raw CT protocol. Domain enrichment as a whole still has redundancy across its four independent facets. |
| Domain HTTP fingerprint | direct request to the domain | (none — there's only one "what does this site serve") | A connection failure, TLS error, or non-2xx status is a valid result (a dead/parked domain is itself intelligence), never an error. |
| Sanctioned addresses | OFAC SDN list | — | Refreshed on a TTL, cached locally (.cache/ofac_sdn.xml by default) so a run doesn't re-pull a ~30MB file per indicator. |
GDPR/privacy redaction is stored, never hidden. domain_profiles.registrant_redacted
is set whenever the registrant entity is missing entirely, a top-level RDAP
remark declares redaction, the newer RFC 9537 structured redacted
extension is present, or a registrant field holds a known placeholder
("REDACTED FOR PRIVACY" and similar). Redaction status is itself a weak
signal worth keeping — a domain publishing full registrant data is at
least a little more unusual than one that doesn't. Which of the three
registration providers actually served a domain is recorded on
domain_profiles.registration_provider, same as the serving provider is
recorded for every other kind of enrichment.
Fund-flow tracing (lh trace ADDRESS) follows an address's outbound
value downstream, breadth-first, up to --depth hops (default 2, hard
ceiling 5) and --breadth counterparties per hop (default 25, highest-
value edges first). Expansion stops at a node before it's ever queried
further if that node is a known exchange deposit address, mixer, bridge,
or OFAC-sanctioned address — labeled and left as a terminal node. Depth 2
is usually enough precisely because of these stop conditions: a scam
wallet typically reaches a recognizable sink within one or two hops.
Cycles (an address already seen in this trace) are recorded as edges but
never re-expanded. Every cap that was actually hit — a breadth
truncation, the depth ceiling, a provider that couldn't be reached — sets
summary.truncated (and prints a warning in --format table), because a
trace that silently implies more coverage than it has is worse than one
that admits its limits.
Known-address labels (enrich/labels/) — mixer, bridge, and exchange-
deposit address sets, each carrying its source and retrieval date; an
unsourced label doesn't get used. labels/known.py's bundled dataset
ships Tornado Cash's mixer pool contracts populated (38 addresses,
sourced directly from the U.S. Treasury OFAC's original 2022 designation
press release — a citable primary source, kept as a mixer label
independent of the live OFAC feed since that designation's legal status
has since been contested and litigated) and bridges and exchange
deposit addresses shipped empty by design: no free, verifiable bulk
dataset exists for either category at the time this was built (exchange
deposit addresses in particular come from proprietary chain-analytics
clustering, not a public feed) — see enrich/labels/data/known_addresses.yaml
for the exact reasoning and the schema to populate them yourself.
Values are exact, never float. Every monetary field — balances,
counterparty totals, trace edge values — is a Python Decimal in the
chain's smallest native unit (satoshi/wei/sun/lamport), backed by a
dialect-portable PreciseNumeric column type that stores the exact
decimal string on SQLite (plain NUMERIC silently degrades to a lossy
float there) and native arbitrary-precision NUMERIC on Postgres.
lh cluster run (ledgerhound/cluster/) links indicators into an entity
graph via five independent, evidence-backed relations, then groups
connected components above a confidence threshold into campaigns.
Every edge carries the evidence for why it exists — an unexplainable
cluster is not intelligence, so nothing in edges.evidence is ever
discarded once persisted. The graph itself is built and traversed with
networkx; nothing here reimplements connected-components.
| Relation | Weight | Evidence recorded |
|---|---|---|
shares_receiving_wallet |
1.0 | The shared wallet address, plus the raw-report ids behind each side's observation. |
shares_cashout_path |
0.8 | The shared terminal address, its label type (exchange/mixer/bridge/sanctioned), and each side's hop distance to it. |
shares_cert |
0.6 | A cert identifier (issuer + serial — crt.sh doesn't expose a real SHA-256 fingerprint, so this is the closest stable substitute), the overlapping SANs, issuer, and validity window. |
shares_template |
0.5 | Which hash(es) matched (page body, favicon, or both) and both sides' fetch timestamps. |
shares_registrar_and_ns |
0.2 | The shared registrar and nameserver set, plus both domains' creation dates. |
Weights and every threshold below live in config/clustering.yaml, not
hardcoded, so they can be retuned without a code change.
Rules that keep clusters honest, not just connected:
- A registrar/nameserver match alone can never create a campaign.
Two unrelated domains parked at the same budget registrar with default
nameservers is weak corroboration, not a link —
shares_registrar_and_nsonly ever adds weight to a component another relation has already connected. A component whose only relation type is this one always scores0.0(cluster/scoring.py), no matter how many domains share it. - Known exchange/mixer addresses never act as linking nodes. An
address labeled
exchangeormixer(enrich/labels/) is excluded fromshares_receiving_walletentirely — two scams both paying the same Tornado Cash pool isn't evidence they're related.shares_cashout_pathis the one exception: it's allowed to reference a labeled terminal, but only as evidence of where each side's funds converge, never as a node in the graph itself. - Wildcard/shared hosting certs are discounted, not excluded. A cert
covering more than
cert.wildcard_san_thresholdSANs (default 20) is almost always shared hosting infrastructure rather than scam-specific provisioning, so it contributes at a flatcert.wildcard_weight(default 0.1) instead of the normal 0.6. - Cert and template matches require temporal proximity. Two domains
can only be linked by
shares_certorshares_templateif they were both observed live (perenriched_at) withincert.liveness_window_days/template.liveness_window_days(default 90 days each) of each other — a certificate or template reused two years apart is a coincidence of infrastructure reuse, not necessarily the same operator still active.
Scoring: a connected component's score is the sum of its edge
weights divided by its node count, plus a campaign.diversity_bonus
(default 0.2) if the component is corroborated by more than one relation
type. A component becomes a campaign if its score is at or above
campaign.score_threshold (default 0.5) — inclusive, deliberately: a
single shares_receiving_wallet edge between exactly two nodes (the
single strongest, most common real-world link) scores exactly 1.0/2 = 0.5, and a strict > would exclude the most basic campaign case a
threshold like this exists to catch. Campaigns additionally require
campaign.min_size (default 2) nodes.
Stable identity across runs. Campaigns are not recomputed from
scratch and re-numbered every run — each new run's connected components
are matched against existing campaigns by mutual best Jaccard
similarity over member sets (cluster/campaigns.py), at or above
campaign.merge_split_jaccard_threshold (default 0.5, also inclusive for
the same boundary reason: two equal-sized campaigns fully merging land
exactly at 0.5 Jaccard against each original). A component matched by
more than one existing campaign is a merge (the highest-Jaccard/
lowest-code campaign survives and absorbs the rest); an existing campaign
matched by more than one new component is a split (the
highest-Jaccard component continues the original id; the others become
new campaigns). Every merge or split is recorded as a row in
campaign_events with the exact before/after member-id sets and the
related campaign code(s) — a full audit trail of how a campaign's
membership evolved, not just its current state. New campaigns are named
sequentially, LH-YYYY-NNN (year of first observation); an
analyst-set display_name is never overwritten by a later run.
Status is derived, not stored as a manual flag: a campaign is
active if any member indicator's last_seen falls within
campaign.active_window_days (default 30) of the run, else dormant.
Determinism. Every candidate list, component list, and member-id
comparison is sorted by string form before use — same database state
always produces the same edges, the same components, and the same
campaign codes on every run, with no dependency on dict/set iteration
order or wall-clock timing. lh cluster run --dry-run runs the full
pipeline and reports what it would do without committing anything.
Trace persistence (a milestone-2 gap closed here). lh trace wrote
its results to stdout only through milestone 2, despite the traces/
trace_edges schema already existing — shares_cashout_path depends on
trace data being queryable from the database, so persistence needed to
exist first. lh trace now also writes trace_nodes (new table: every
node visited, its hop distance, and whether/why it was a terminal) and
persists automatically whenever the traced root is itself a tracked
Indicator; if it isn't, results still print but a warning notes they
weren't saved. shares_cashout_path always uses only the most recent
trace per root indicator, so re-running lh trace with different
depth/breadth doesn't leave stale terminals in the graph.
Inspect the graph directly:
lh cluster run # detect all 5 relations, persist edges + campaigns
lh cluster run --relation shares_cert --dry-run
lh cluster campaigns --status active --min-size 3
lh cluster show LH-2026-001
lh cluster explain 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 # which campaign, and why
lh cluster graph LH-2026-001 --format dot | dot -Tpng -o campaign.pnglh assess draft CAMPAIGN_ID (ledgerhound/assess/) has an LLM draft a
structured analytic assessment of a campaign — key judgements, targeting
profile, MITRE ATT&CK tradecraft, financial analysis, an overall
assessment, an attribution judgement, an outlook, and explicit gaps. The
LLM drafts; the analyst decides. Nothing it produces reaches a report
without an explicit lh assess approve, and every hard constraint below
is enforced in code — not just requested in the prompt — so a draft that
violates one is rejected before it's ever stored, not flagged for review
after the fact.
The evidence pack is the model's entire world. assess/evidence.py
assembles everything Ledgerhound knows about a campaign's members —
indicator identities, every edge and its evidence, chain-address and
domain projections, certs, labels, and the reliability grade of every
contributing source — into one EvidencePack. The model sees only
that JSON: no raw provider payloads, no network access of its own, and
no visibility into any other campaign. Every item in the pack carries a
stable ref string (e.g. member:<indicator-id>, derived from the
underlying row's own identity, never random), and the pack serializes to
a deterministic sha256 (EvidencePack.evidence_hash) — the same
database state always produces the same hash, which is how staleness
detection and the re-draft guard below both work without ever persisting
the pack itself.
The model call is forced tool-use, not free text, and the backend is
pluggable. drafter.py never talks to a specific vendor's SDK — it
calls whatever AssessmentLLM it's given (assess/llm.py), a small
structural interface with one method, .draft(...). The one
implementation shipped, OpenAICompatibleLLM, speaks the OpenAI
/chat/completions + tool-calling wire format over plain httpx, which
is what OpenAI itself, Azure OpenAI, Ollama, LM Studio, Groq, Together,
OpenRouter, and vLLM all implement — pointing LLM_BASE_URL/LLM_MODEL
at a different one of those switches backends with no code change.
LLM_API_KEY is required only for lh assess draft; every other
command works with no key set. Anything that isn't OpenAI-compatible at
all just needs a small class of its own with the same .draft(...)
signature. Either way, the call forces a single submit_assessment tool
call whose input schema is generated directly from the same pydantic
model (AssessmentDraft) validation runs against — so a structurally
invalid response is already unusual, not the common case malformed-JSON
parsing would otherwise produce.
Hard constraints enforced in code:
- Every
supporting_evidence_refsentry must resolve to a real evidence-pack item. Checked againstEvidencePack.all_refs(); an invented or stale ref fails the whole draft. - Every
technique_idmust be a real, current MITRE ATT&CK Enterprise technique.assess/attack.pyfetches MITRE's public STIX bundle once (revoked and deprecated techniques excluded), caches a distilled{id: name}map locally (ATTACK_STIX_CACHE_PATH, no TTL — ATT&CK ships a handful of releases a year, so a months-old cache is the intended steady state), and records which ATT&CK version a draft'sttpswere checked against. attribution.claimmust be null unless at least onekey_judgementhas confidence"high". Unattributed is the normal, expected outcome — the check exists so a campaign is never attributed just to look complete.- Confidence is exactly
low/moderate/high. The schema types every confidence field as that enum, so a value like"likely"fails pydantic validation outright; words like that are fine in prose, never in a confidence field. financial.total_observed_inflowmust match the evidence pack's own computed total exactly. The model copies a number Ledgerhound already computed (summing the chain-address projections'total_receivedacross the campaign's members); it never does independent arithmetic.
Versioning, not overwriting. Every draft — from the LLM or from lh assess edit (opens the current draft's JSON in $EDITOR; on save, the
edited JSON is re-validated against every constraint above, exactly like
a fresh draft, and stored as a new version with edited_by_human=true)
— is a new, immutable row in assessments. Nothing is ever overwritten,
so lh assess diff CAMPAIGN_ID N M and the full review history always
have something to show. lh assess draft refuses to re-draft when the
evidence hash hasn't changed since the last version (pass --force to
override) — there's nothing new for the model to say otherwise.
Review is a small state machine, per version:
draft ----+--> approved
|
in_review --+--> rejected
approve is legal from draft/in_review; reject is legal from
draft/in_review/approved (an analyst revoking an approval after
finding a problem); neither is legal from rejected — fixing a rejected
version means editing or re-drafting to produce a new one, and approving
an already-approved version is also illegal. Exactly one approved
version can exist per campaign at a time, enforced twice: a partial
unique index in the database (the ultimate guarantee) and an
application-level check in assess/review.py that raises a clear error
instead of an opaque constraint violation — lh assess approve --force
explicitly supersedes an existing approval (marking it rejected with a
note; never silently discarded).
Stale, not silently wrong. Whenever a fresh evidence pack is built
(drafting or editing), the campaign's currently-approved version (if
any) has its stale flag refreshed: true whenever the campaign's
evidence has changed since that version's evidence_hash was computed.
The export layer (below) refuses to publish a stale approved assessment
until it's re-reviewed — re-checking freshness itself at export time
rather than trusting whether anyone remembered to re-run lh assess draft after the evidence changed.
lh assess draft LH-2026-001 # build the pack, call the model, validate, store v1
lh assess show LH-2026-001 # render the latest version, evidence refs expanded inline
lh assess edit LH-2026-001 --version 1 # edit in $EDITOR; re-validated, stored as a new version
lh assess approve LH-2026-001 --notes "Reviewed against source reports; approved."
lh assess reject LH-2026-001 --notes "Attribution too speculative."
lh assess diff LH-2026-001 1 2Only a campaign with an approved, non-stale assessment can be
exported or published — ledgerhound/export/gate.py's
get_publishable_assessment is the one gate every export/report/publish
path shares, and it re-derives staleness from a freshly built evidence
pack rather than trusting a possibly-stale stale flag. Nothing in
ledgerhound/export/ calls the LLM or makes a network request; the one
piece of milestone 4 machinery it reuses is assess/attack.py's
local-cache-only load_cached_attack_dataset, deliberately never its
network-fetching sibling — if the ATT&CK cache is missing, export fails
with a clear message instead of silently fetching it.
STIX 2.1 (lh export stix, via the stix2 library). Per campaign: an
identity (Ledgerhound, with the approving analyst in
contact_information), a campaign object (name/description from the
approved summary, first/last seen), an indicator per member, an
attack-pattern per TTP, indicates/uses relationships, and a
report wrapping the assessment (report_types: ["campaign"],
published, and confidence on STIX's 0–100 scale — a documented
Ledgerhound mapping of low/moderate/high onto representative points
25/50/75, not a literal OASIS table, since the spec's own confidence
scale is non-normative). STIX has no native cryptocurrency-address or
messaging-handle observable type, so chain addresses and Telegram
handles use a documented custom x-crypto-address/x-telegram-handle
type referenced by name inside the indicator's pattern (legal per STIX
2.1's x--prefixed custom-type rule); domains and URLs use their real
native SCOs. TLP marking is applied per the real, OASIS-published TLP
2.0 marking-definition objects (oasis-open/cti-stix-common-objects) —
vendored verbatim, since the installed stix2 library predates TLP 2.0
and only ships the older TLP 1.0 constants. ATT&CK ids are never
invented: every attack-pattern uses MITRE's own STIX id from the
cached dataset (assess/attack.py's stix_ids map, captured from the
same STIX bundle lh assess draft already downloads); a technique
missing from that cache fails the export rather than fabricating an id.
Every object's id is a UUIDv5 of stable content (campaign code,
indicator type+value, technique id, ...) and every timestamp is pinned
to the assessment's approval time — the same approved assessment
always produces byte-identical bundle output, verified directly
against stix2-validator in tests (which flags the deliberately-non-v4
ids as a "SHOULD" warning, never an error).
MISP feed (lh export misp, all publishable campaigns): the static
feed format (manifest.json + one JSON file per event + hashes.csv)
any MISP instance can add as a feed URL — not the live MISP REST API,
which Ledgerhound never talks to. Attribute types are MISP's real,
current ones (checked against MISP/PyMISP's describeTypes.json
during development): btc is native; Ethereum/TRON/Solana addresses and
Telegram handles have no native MISP cryptocurrency type (only btc and
xmr exist as of writing), so they use MISP's generic text type with
a comment rather than an invented eth/tron/sol type a real MISP
instance would silently reject. Tags are likewise real, verified
taxonomy values: tlp:<color>,
estimative-language:confidence-in-analytic-judgment="low|moderate|high"
(confirmed this predicate's values are exactly Ledgerhound's vocabulary,
not assumed), and misp-galaxy:mitre-attack-pattern="<name> - <TID>".
Static IOC feed (lh export feed, all publishable campaigns): a
GitHub-Pages-ready directory — per-campaign JSON, combined
iocs.json/iocs.csv, a feed.json index, and an index.html listing
with status and last-seen per campaign. Defanged by default, straight
from each indicator's own defanged_value column (the same one
extraction computed in milestone 1); --refanged swaps in the raw
value instead — a no-op for chain addresses/handles, which have
nothing to defang either way.
Reports (lh report render, Markdown then optionally PDF via
weasyprint, imported lazily so a Markdown-only render never needs it
importable): twelve fixed sections — Header, Key Judgements, Summary,
Targeting, Tradecraft, Infrastructure, Financial Analysis, Assessment,
Outlook, Indicators of Compromise, Sources & Reliability, and
Methodology & Limitations, which is never optional and is entirely
auto-generated from the evidence pack (which relations linked the
campaign, trace depth used, which providers served the data, and every
partial or truncated result — a truncated trace shows up explicitly in
both Financial Analysis and here, never silently dropped). The campaign
graph is rendered via the system dot binary (Graphviz) when available;
if it isn't, the report embeds the DOT source as text instead of failing
outright. reports/<CODE>/ holds the Markdown source committed
alongside the PDF.
Publishing (lh publish CAMPAIGN_ID or --all) runs all of the
above for one campaign — or every publishable campaign — and records a
publications row with every artifact's path and sha256 hash (STIX
bundle, MISP event, feed entry, Markdown, PDF). The MISP feed and IOC
feed are collection-level artifacts (one manifest describing every
event/campaign in the directory), so every publish regenerates them
across every currently-publishable campaign, not just the one(s) being
published, so the manifest never goes stale relative to what's on disk.
lh export stix LH-2026-001 --out bundle.json
lh export misp # all publishable campaigns
lh export feed --refanged # raw values instead of defanged
lh report render LH-2026-001 --format both
lh publish LH-2026-001
lh publish --alllh serve runs a browser UI (FastAPI backend in ledgerhound/api/,
React/TypeScript frontend in web/) over the same database every other
command uses — it presents what the CLI already computes and never
duplicates it: every write endpoint imports and calls the exact
assess//cluster/ function the corresponding CLI command does
(assess.drafter.draft_assessment, assess.review.edit_assessment /
approve_assessment / reject_assessment), so a validation rule can
never drift between lh assess edit and the dashboard's edit form —
they're the same code. Three views: a campaign dashboard (browse
campaigns, explore the entity graph, read published reports), an
assessment review tool that replaces the $EDITOR-based lh assess edit workflow with a proper form, and a Settings page for the
config in config_store.py below.
Config store (ledgerhound/config_store.py): sources, providers,
and clustering — each mirroring one of config/*.yaml — plus
runtime (the assessment-drafting LLM's key/base URL/model, the default
TLP marking, and enrichment concurrency/TTL) are editable live from
either lh config show/set/reset or the dashboard's Settings page, both
calling the exact same store — a change from one is visible to the other
immediately, no restart. A domain's row in the config_overrides table
takes precedence over its file/env default the moment it's written;
lh config reset DOMAIN (or the Settings page's "Reset to default"
button) deletes the row, reverting to that default. DATABASE_URL and
logging stay .env-only — both are needed before this table can be
reached — and so do filesystem paths and HTTP retry tuning, which are
lower-value things to expose in a settings page than actual behavior
knobs.
No authentication. This runs locally against your own database, the same trust boundary the CLI already has — a public deployment would need auth added in front of it before exposing it to anyone else.
Backend (ledgerhound/api/): read-only except draft edits,
approve/reject, and campaign display-name renames. GET /api/campaigns/{code}/graph and the evidence panel both reuse
assess.evidence.build_evidence_pack — the exact same evidence pack
lh assess/lh report render already build — rather than a second
graph-assembly path; GET .../explain/{indicator_id} reuses a new
cluster.graph.explain_indicator helper factored out of (and now shared
with) lh cluster explain. ledgerhound/stats.py was split out the
same way so lh stats and GET /api/stats can't disagree either.
Frontend (web/, Vite + React + TypeScript strict): TanStack Query
for all data fetching, no ad hoc useEffect fetches. Request/response
types are generated from the API's own OpenAPI schema
(npm run generate:types, via openapi-typescript) into
web/src/api/schema.ts, which is committed — a fresh npm install && npm run build never needs the Python backend importable, only when
types are deliberately regenerated after an API change. The entity graph
(components/EntityGraph.tsx) uses react-force-graph-2d, force-
directed, nodes colored by indicator type and edges by relation type
with thickness by weight; this is a separate, interactive client-side
view from the static Graphviz-rendered graph embedded in a milestone 5
report — the two intentionally don't share a rendering path. The
assessment review form (components/DraftEditForm.tsx) constrains
confidence to a <select> over the backend's exact enum, picks evidence
refs from checkboxes built from the real evidence pack rather than free
text, and checks ATT&CK technique ids against GET /api/attack/techniques (the cached dataset, client-side, before submit)
— all of which the server re-validates on save regardless, through the
same assess/drafter.py rules the CLI uses. Dark-mode-first styling
(web/src/styles/theme.css) throughout, including the graph background,
so nothing mixes a light dashboard shell with a dark graph view.
Dev vs. prod are the same one command, lh serve, in two shapes:
# Dev: two processes, hot-reloading frontend
lh serve --reload # FastAPI on :8000
cd web && npm run dev # Vite on :5173, proxies /api/* to :8000
# Prod: one process
cd web && npm run build # writes web/dist/
lh serve # serves the API and web/dist/ together on :8000web/vite.config.ts's dev-server proxy and ledgerhound/api/main.py's
static mount (only activated when web/dist/ actually exists) were both
verified end-to-end during development, not just read — lh serve was
started and curled directly, and separately alongside a real vite
dev-server process, to confirm both shapes actually work before calling
this done.
- Coverage is only as good as the two configured public feeds. Neither source vets submissions before publication to the degree a paid threat-intel provider would; false positives in upstream feeds propagate into Ledgerhound's indicator store as-is. Milestone 1 does no independent verification of whether a reported address/domain is actually malicious — it stores what was reported.
- First-run backfills over a feed's full history are slow. ScamSniffer
publishes its entire historical blocklist on every fetch (there's no
upstream delta/pagination), so a first
lh collect --allinserts on the order of several hundred thousand raw reports; the firstlh extractover that backlog is I/O-bound on a per-record upsert and can take tens of minutes against a cold database. Steady-state incremental runs (lh extract --since <last-run>) process only the new delta and are fast. This is a target for optimization (batched upserts) in a later milestone, not a milestone 1 deliverable. - Domain canonicalization normalizes punycode to Unicode. This is a deliberate, one-directional choice (never the reverse) so a domain can't be split across two indicator rows depending on which form a report used — but it means Unicode homograph domains are stored in their decoded form, which downstream tooling that expects ASCII/punycode must account for.
- Extraction has no per-language or per-encoding awareness beyond UTF-8 text matching. Reports containing indicators as images, PDFs, or in languages/scripts that interact unusually with the regexes above are not specifically handled.
- Exchange deposit and bridge address labels ship empty. No free,
verifiable bulk dataset exists for either category (real ones come from
proprietary chain-analytics clustering or paid data) — see "Enrichment
methodology" above. Fund-flow tracing will not recognize a hop into an
exchange or bridge unless you populate
enrich/labels/data/known_addresses.yamlyourself; it will still stop at OFAC-sanctioned addresses and the (populated) mixer set. - SOL and TRON enrichment is coverage-limited by design, not by bug.
Solana counterparty attribution is a best-effort heuristic on balance
deltas for anything beyond a simple transfer; TRON enrichment values
native TRX transfers only, not TRC20/TRC10 token movements. Both are
always marked
partial=Truefor exactly this reason. - Provider health is process-local, not persisted.
lh providersrun on its own always shows every provider healthy — the cooldown/failure tracking inenrich/registry.pylives only as long as the Python process running the command, by design (there's no requirement, or clear value, in persisting it across separate CLI invocations). - Fund-flow tracing queries live providers, not the enrichment
cache.
lh tracealways reflects current on-chain state rather thanaddress_profiles/address_counterparties, and does not benefit from (or contribute to) the enrichment TTL/cachinglh enrichmaintains — it does now persist its results (traces/trace_edges/trace_nodes, as of milestone 3) soshares_cashout_pathhas something to query, but everylh tracerun is still a fresh set of live provider calls, not a cache lookup. - RDAP/WHOIS lookups target the exact indicator value, not the
registrable domain. A
DOMAINindicator that's actually a subdomain (e.g.mail.example.comrather thanexample.com) will typically get a 404 from RDAP, since registries answer for registered domains, not arbitrary subdomains — observed live during development. Because a 404 is (correctly, for a real "not registered" case) treated as a validregistered=Falseresult rather than a failure, a subdomain indicator will be recorded as unregistered even though its parent domain is registered — the chain never falls through to WHOIS for this case, since RDAP didn't fail, it just answered a question that wasn't quite the right one. DNS, certs, and HTTP fingerprint for the same indicator are unaffected either way. Registering the base domain instead (or extracting it separately from a subdomain indicator) isn't done in this milestone. shares_cert's "fingerprint" is issuer+serial, not a real certificate hash. crt.sh's certificate-transparency data doesn't expose a SHA-256 fingerprint directly;(issuer, serial_number)is a practically-unique substitute for grouping purposes but is not cryptographically equivalent to hashing the certificate itself.- Clustering only ever links indicators already enriched. A relation
can't detect a connection through data that was never fetched — an
address never run through
lh enrich, or a domain with no cert/DNS data yet, simply produces no candidate edges for that facet.lh cluster runreflects the current state of the enrichment tables, not a live re-check of the underlying infrastructure. - No cross-type corroboration within a single relation. Each relation links same-kind evidence (wallet-to-wallet, domain-to-domain); a scam that reuses a wallet on one report and a domain certificate on another is only linked into one campaign if some other observed indicator bridges the two components — clustering never itself infers a wallet-to-domain edge directly.
- Cash-out and wallet relations are still capped by unpopulated bridge
and exchange-deposit labels (see "Enrichment methodology" above), so
shares_cashout_pathcan currently only terminate at a populated mixer or OFAC-sanctioned address untilenrich/labels/data/known_addresses.yamlis filled in. - An assessment reasons only from what's already in Ledgerhound. The
evidence pack reflects the campaign's current enrichment/clustering
state at draft time; it never re-checks live provider data and knows
nothing about a member indicator beyond what
lh enrich/lh trace/lh cluster runhave already recorded. A campaign with sparse enrichment produces a sparse, gap-heavy assessment — which is the intended behavior (seegapsin the schema), not a failure. - A draft's
evidence_hashisn't independently re-derivable once the underlying database rows change or are deleted. The hash proves a specific evidence state produced the draft, and staleness detection compares against it correctly, but Ledgerhound doesn't persist the full pack — reconstructing exactly what the model saw for a very old draft after significant data drift isn't supported. - No enforced reviewer identity.
--reviewerdefaults to the local OS username (getpass.getuser()) with no authentication behind it;reviewed_byis an audit-trail field, not an access-control boundary. lh assess editshells out to$EDITORsynchronously. There's no non-interactive way to submit an edited draft (e.g. from a script or a web UI) yet — that would mean accepting the edited JSON as a file path or stdin instead, which isn't built.- The MITRE ATT&CK cache has no automatic refresh policy. Unlike
OFAC's 24-hour TTL,
attack_stix_cache_pathis refreshed only when missing or when a caller explicitly requests it — a new ATT&CK release adding techniques won't be picked up until the cache file is deleted (orforce_refresh=Trueis passed) andlh assess draftruns again. Export inherits this: it fails outright (never invents an id) if a TTP's technique has dropped out of the cached dataset since drafting. - The STIX confidence mapping is Ledgerhound's own reasonable choice,
not a literal external standard. STIX 2.1's confidence scale is
explicitly non-normative
(no single official low/moderate/high → 0–100 table exists); the
25/50/75 mapping here is documented as ours, not OASIS's, in
export/stix.py. - STIX/MISP object ids are UUIDv5, not UUIDv4. Deliberate, for
determinism (the same approved assessment must always produce the
same bundle), but
stix2-validatorand some other STIX tooling treat a non-v4 id as a "SHOULD"-level warning, not an error — expect to see that warning from any validator, not just Ledgerhound's own tests. - Ethereum/TRON/Solana addresses and Telegram handles have no
first-class STIX or MISP type. STIX gets a documented custom
x-crypto-address/x-telegram-handlepattern type; MISP gets the generictextattribute type with a comment.btc/domain/urlare the only indicator kinds with a real native type in either format as of writing. - The MISP feed's
hashes.csvand hosting-friendly static IOC feed are Ledgerhound's own implementation of documented, publicly-known formats, not validated against a real running MISP instance (no MISP server is part of this stack) — tests validate internal consistency (manifest ↔ event files ↔ hashes) and real MISP type/tag vocabulary, not an actual MISP import. - The campaign graph embedded in a report is only a rendered PNG when
Graphviz's
dotbinary is installed on the machine runninglh report render. Otherwise the report falls back to embedding the DOT source as text — correct and complete, but not a picture — rather than failing the whole render. - Report PDF rendering depends on
weasyprintand its system Pango/Cairo libraries being installed and importable.lh report render --format mdnever needs them;--format pdf/bothraises a clear error (rather than crashing) if they're unavailable, and no alternate PDF engine is implemented. Publicationrows are an append-only audit log, not a lock. Nothing stops republishing the same campaign repeatedly (each creates a new row); there's no "latest publication" concept beyond querying for the most recent row bypublished_at.- No authentication on the dashboard. It's built to run locally
against your own database — the same trust boundary the CLI has, not
a stronger one. Exposing
lh servebeyond localhost needs auth added in front of it first; nothing here does that. - The reviewer identity on a dashboard approve/reject is a free-text
field the caller supplies (or the placeholder
"dashboard"), not an authenticated user. Same limitation as the CLI's--reviewer(getpass.getuser(), no verification) —reviewed_byremains an audit-trail field, not an access-control boundary, in either interface. - The report/STIX/MISP/feed viewers are read-only surfaces over
whatever's already on disk. The dashboard never triggers
lh export .../lh report renderitself — a campaign only shows up in the Report Viewer or Feed Browser after those commands (orlh publish) have actually run. There's no "export from the browser" button. - The interactive entity graph has no built-in size limit or
virtualization.
react-force-graph-2d's force simulation is rendered client-side for however many members/edges a campaign has; a very large campaign's graph hasn't been performance-tuned beyond what the library does by default. - Frontend testing stops at component tests (Vitest +
@testing-library/react, mockingreact-force-graph-2dsince jsdom can't meaningfully run its canvas/d3-force simulation). No end-to-end browser tests (e.g. Playwright) exist — calling that out explicitly rather than silently shipping without them; adding real E2E coverage of the full click-through-the-UI flows is a reasonable next step.
Every planned milestone (collection, extraction, enrichment, clustering, assessment, export, dashboard) is implemented. Reasonable further work not in scope for any milestone here: authentication for a non-local deployment of the dashboard; end-to-end browser tests (Playwright); direct push integration with a running MISP instance (rather than the static feed format); multi-analyst review workflows beyond the current single-approver model.
sources.yaml ──► Collector.run() ──► raw_reports (JSONB, deduped by
(source_id, content_hash))
│
▼
extract.pipeline.run_extraction()
│
▼
indicators ◄──► indicator_observations
(validated, canonicalized, │
deduped by (type, value)) (one row per
indicator × report)
│
▼
enrich.scheduler.run_enrichment()
(never-enriched indicators prioritized,
then stale refreshes; per-provider
token-bucket rate limited)
│
┌────────────────────────┴────────────────────────┐
▼ ▼
enrich.registry.ProviderRegistry enrichment (raw, JSONB,
(fallback chains, per-provider health) provider-tagged, verbatim)
│
┌─────────────┴─────────────┐
▼ ▼
chain/{btc,eth,tron,sol}.py infra/{dns,whois,certs,http}.py
│ │
▼ ▼
address_profiles ◄──► address_counterparties domain_profiles ◄──► domain_certs
│
▼
address_labels (OFAC + known-address hits)
chain.trace.Tracer ──► traces ◄──► trace_edges ◄──► trace_nodes
(via `lh trace`, live (hop, terminal reason,
against providers) labels per visited node)
│
▼
cluster/relations/*.Relation.detect()
(wallet, cashout, cert, registrar_ns, template —
reads the projection tables above)
│
▼
edges (synced per relation run)
│
▼
cluster.graph.build_graph() (networkx)
│
▼
cluster.scoring + cluster.campaigns
(connected components ──► score ──► stable-identity
match against existing campaigns via Jaccard)
│
▼
campaigns ◄──► campaign_members campaign_events
(LH-YYYY-NNN, active/dormant) (merge/split audit trail)
│
▼
assess.evidence.build_evidence_pack()
(members, edges, projections, labels, source
grades — the model's entire world, hashed)
│
▼
assess.drafter.draft_assessment()
(pluggable-backend tool-use call, forced schema,
hard constraints validated in code, not just prompted)
│
▼
assessments
(versioned, draft/in_review/approved/rejected,
edit/approve/reject via `lh assess`, never overwritten)
│
▼
export.gate.get_publishable_assessment()
(approved AND non-stale, or refuse — the one
rule every export/report/publish path shares)
┌─────────────┬───────────────┬───────────────┬─────────────┐
▼ ▼ ▼ ▼ ▼
export.stix export.misp export.feed export.report (lh publish
(STIX 2.1 bundle) (MISP feed: (static IOC (Markdown, then orchestrates
manifest + site: JSON/ optional PDF all of these
events + CSV/HTML) via weasyprint) for one or
hashes.csv) every campaign)
└─────────────┴───────────────┴───────────────┴─────────────┘
│
▼
publications
(append-only: which artifacts, their sha256 hashes,
and the approved assessment version published)
Nineteen tables, all with a UUID primary key and timezone-aware UTC
created_at/updated_at:
| Table | Purpose |
|---|---|
sources |
Configured feeds (mirrors config/sources.yaml). |
raw_reports |
Deduplicated raw payloads as fetched, JSONB, keyed by (source_id, content_hash). |
indicators |
Canonicalized IOCs, keyed by (type, value), with first_seen/last_seen. |
indicator_observations |
Join table: which raw report an indicator was seen in, and when. |
enrichment |
Raw provider payloads per indicator, JSONB, provider-tagged — the audit trail behind every projection table below. |
address_profiles |
Normalized chain-address snapshot: balance, tx count, first/last on-chain activity, decimals, serving provider. One row per chain indicator, replaced on re-enrichment. |
address_counterparties |
Aggregated counterparty relationships for a tracked address (direction, tx count, total value). |
address_labels |
Exchange/mixer/bridge/sanctioned/scam labels on a tracked address, with source and confidence. |
domain_profiles |
Normalized domain snapshot merging DNS, registration (RDAP or WHOIS), and HTTP-fingerprint facets — including registration/update/expiry dates, status codes, nameservers, redaction status, and which provider served the registration data. |
domain_certs |
Certificate-transparency-observed SANs for a tracked domain. |
traces |
One lh trace run: root address, depth, whether any cap truncated it, summary JSONB. |
trace_edges |
Edges discovered during a trace run (src, dst, hop, value, tx count). |
trace_nodes |
Every node visited during a trace run: hop distance, whether it was a terminal, why, and its labels at trace time. |
edges |
Relationships between indicators inferred by cluster/relations/, one row per (src, dst, relation) with its weight and evidence JSONB — full-synced (added/updated/deleted) on every lh cluster run. |
campaigns |
A connected component that cleared the scoring threshold: code (LH-YYYY-NNN), optional analyst display_name, status (active/dormant), score, and which relation types corroborate it. |
campaign_members |
Join table: which indicators belong to which campaign. |
campaign_events |
Audit trail of stable-identity changes across runs: a merge or split, the related campaign code(s), and the exact before/after member-id sets. |
assessments |
One versioned LLM-drafted (or human-edited) campaign assessment: evidence hash, prompt/model used, the draft itself (JSONB), review status, and reviewer/notes. A partial unique index enforces at most one approved row per campaign. |
publications |
One lh publish run: which campaign, which approved assessment version, TLP marking, and a JSONB map of every artifact produced (STIX bundle, MISP event, feed entry, Markdown, PDF) to its path and sha256 hash. Append-only. |
Requires Python 3.12, (for real deployments) Postgres 16, and — only for the dashboard — Node.js 18+.
# Install
pip install -e ".[dev]"
# Local Postgres via Docker Compose
cp .env.example .env
docker compose up -d db
# Schema
lh db upgrade
# Collect from all enabled sources, then extract indicators
lh collect --all
lh extract
# Enrich chain addresses and domains from public sources
lh enrich
# Link enriched indicators into campaigns
lh cluster run
# Draft, review, and approve an assessment (needs LLM_API_KEY)
lh assess draft LH-2026-001
lh assess approve LH-2026-001 --notes "Reviewed against source reports; approved."
# Publish everything for that campaign
lh publish LH-2026-001
# See what you've got
lh stats
lh cluster campaigns
# Or browse it all in the dashboard (build once, then one process)
cd web && npm install && npm run build && cd ..
lh serveRun a single source: lh collect --source scamsniffer.
Run extraction incrementally: lh extract --since 2026-09-01T00:00:00Z.
Enrichment:
lh enrich --type btc_address --limit 50 # just BTC addresses, capped
lh enrich --force # re-enrich even if not stale
lh enrich --provider mempool_space # force one provider, skip its fallback
lh providers # provider health/facets table
lh trace 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --depth 2 --format table
lh trace <address> --format dot | dot -Tpng -o trace.png # or --format jsonConfig (see "Config store" under Dashboard — the dashboard's Settings page edits this exact same store):
lh config show # every domain's effective value + override status
lh config show runtime
lh config set runtime tlp_default TLP:AMBER
lh config set runtime enrich_concurrency 10
lh config set clustering --file clustering-override.json # sources/providers/clustering
lh config reset runtime # revert to its file/env defaultClustering:
lh cluster run # detect all 5 relations, persist edges + campaigns
lh cluster run --relation shares_cert --relation shares_template
lh cluster run --dry-run # report what would change, persist nothing
lh cluster campaigns --status active --min-size 3
lh cluster show LH-2026-001
lh cluster explain <indicator value> # which campaign it's in, and the evidence path
lh cluster graph LH-2026-001 --format dot # or --format jsonNo API key is required for any of the above — everything routes through keyless public providers by design.
Assessment (the one command that needs a key — set LLM_API_KEY in
.env first; LLM_BASE_URL/LLM_MODEL default to OpenAI, and can point
at any OpenAI-compatible provider or self-hosted server instead):
lh assess draft LH-2026-001
lh assess show LH-2026-001
lh assess edit LH-2026-001 --version 1
lh assess approve LH-2026-001 --notes "Reviewed against source reports; approved."
lh assess reject LH-2026-001 --notes "Attribution too speculative."
lh assess diff LH-2026-001 1 2Export & reports (only a campaign with an approved, non-stale assessment is publishable — no API key or network access needed for any of this):
lh export stix LH-2026-001 --out bundle.json
lh export misp # MISP feed, all publishable campaigns
lh export feed --refanged # static IOC site, raw (non-defanged) values
lh report render LH-2026-001 --format both # Markdown + PDF
lh publish LH-2026-001 # all of the above for one campaign + a publications row
lh publish --all # every publishable campaignDashboard:
# Dev (two processes, hot-reloading frontend)
lh serve --reload
cd web && npm run dev
# Prod (one process)
cd web && npm run build && cd ..
lh serve
# Regenerate web/src/api/schema.ts after changing an API pydantic schema
cd web && npm run generate:typespytestThe test suite runs entirely against an in-memory SQLite database (no
Docker/Postgres required) via dialect-portable column types
(ledgerhound/db/types.py, including a PreciseNumeric type that keeps
Decimal values exact on SQLite too); it never touches the network —
collector and enrichment-provider tests replay canned responses via
httpx.MockTransport (async, via pytest-asyncio) built from fixtures in
tests/fixtures/ and schema-accurate literals verified against the real
APIs during development. The rate limiter is tested against a fake clock,
never a real sleep. Assessment tests mock AssessmentLLM the same
way — a fake .draft(...) returning a canned dict — so the suite never
spends a real API call; OpenAICompatibleLLM itself is tested against
httpx.MockTransport, checking the request it builds and both response
shapes real backends return, and assess/attack.py is tested the same
way via httpx.MockTransport, against a small fixture STIX bundle
rather than MITRE's real multi-megabyte one. Export tests
validate real STIX bundles against stix2-validator using a vendored
copy of the actual oasis-open/cti-stix2-json-schemas schemas
(tests/fixtures/stix2_schemas/) — conftest.py self-heals a confirmed
packaging defect in the stix2-validator PyPI wheel (it ships without
its own bundled schemas) by copying that vendored copy into the
installed package once per environment, so validation works out of the
box after a plain pip install -e ".[dev]" with no manual setup. API
tests (tests/test_api_*.py) exercise the FastAPI app directly via
TestClient with get_session overridden to the same in-memory SQLite
session a test seeds — the same db_session fixture every other test
uses, with StaticPool/check_same_thread=False added so it survives
TestClient running handlers in a worker thread. Production deployments
always run on Postgres 16, per docker-compose.yml.
Frontend tests (component-level only — see "Known limitations" for why there's no browser/E2E suite):
cd web
npm run typecheck # tsc --noEmit, strict mode
npm run test # vitest
npm run build # full production build, catches anything typecheck alone wouldn'tmypy --strict ledgerhound
ruff check ledgerhound tests