External enrichment (threat intel)
FreeDR can look up case indicators (file hashes, public IPs, public domains) with
optional threat-intel providers. Every provider is off by default. FreeDR sends
indicators only, never files. Results are stored as facts on the case. They are
not verdicts: FreeDR does not turn a provider answer into
malicious, suspicious or clean.
Enrichment is not AI. The AI investigator can request it and read its results, but
enrichment works with AI turned off.
Enrichment kinds
| Kind |
Where it runs |
What it does |
file_hash |
Local |
Hashes the implicated file through Artifact acquisition (or uses the SHA-256 already retained on the event). Adds host-prevalence facts: new_to_host, host_first_seen_at, host_paths, host_process_starts. |
file_signature |
Local |
Offline Authenticode check of the exact file version: authenticode_status (trusted_offline, invalid, unsigned, unknown), signer, signer novelty on the host. |
hash_reputation |
External |
Looks up the file's SHA-256. If the event has no retained hash, the file is hashed locally first, then the lookup follows. |
ip_reputation |
External |
Looks up one public IP address from the event. |
domain_reputation |
External |
Looks up one public domain from the event. |
The rest of this page is about the three external kinds.
When enrichment runs
| Trigger |
What happens |
| Automatic |
For every alert-trigger event of an open or in-progress case, and every event attached to such a case, FreeDR requests all five kinds. The evidence decides which kinds have a target. No rule chooses the kinds. An event is tried once per kind and settings revision, so saving provider settings re-runs the external kinds for open cases. Informational and muted alerts never enter cases, so they get no enrichment. |
| Manual |
Case → Investigation → Targeted enrichment form, or POST /api/v1/cases/{case_id}/enrichment. You choose a kind, 1–16 event IDs linked to the case and a purpose. You cannot type an indicator: the target always comes from retained evidence. |
| AI |
The investigator's enrich tool sends the same request as a manual one (requester ai). See AI features. |
Automatic, manual, AI and access-check lookups all use the same daily quota per
provider.
Which indicators are eligible
Network indicators come only from Sysmon events:
| Sysmon event |
ip_reputation uses |
domain_reputation uses |
| Event 3, network connect |
DestinationIp |
DestinationHostname |
| Event 22, DNS query |
the first public address in QueryResults |
QueryName |
- A field that appears more than once in the event gives no target.
- Without Sysmon, or for any other event type, the network kinds end
unknown
with reason source_public_network_pivot_unavailable.
- Hashes come from the event's retained
sha256, or from a local hash of the
file the event names (its own file, a file named by a persisted value, the
acting process's image; see
Alerts & Cases).
Every indicator passes one validating constructor before it can be sent. Anything
that fails is never looked up.
| Class |
Accepted |
Never sent |
| SHA-256 |
64 hex characters, lowercased |
anything else |
| Public IP |
Global IPv4; IPv6 only in 2000::/3 |
IPv4: private (10/8, 172.16/12, 192.168/16), loopback, link-local, 0/8, 100.64/10 (CGNAT), 192.0.0/24, 192.0.2/24, 198.18/15, 198.51.100/24, 203.0.113/24, 192.88.99/24, multicast and reserved (224/3 and up), broadcast. IPv6: loopback, link-local, ULA, IPv4-mapped, 2001::/23, 2001:db8::/32, 2002::/16 (6to4), 3fff::/20 |
| Public domain |
ASCII (punycode), at most 253 characters, at least two labels, letters/digits/hyphens, alphabetic TLD of 2+ letters. Lowercased, trailing dot removed |
IP literals, ports, single labels, and the TLDs local, localhost, localdomain, internal, intranet, private, arpa, lan, home, corp, test, invalid, onion |
| Exact URL |
http/https, ASCII, host passes the domain rules, valid percent-encoding |
URLs with a query (?), fragment (#), user info (@), backslash, IP host, or encoded control characters/spaces |
Internal names under a public TLD
The domain check is syntactic. An internal host name under a public suffix
(for example finance.example.com) is sent, even if it resolved to a
private address. Only the reserved TLDs above are filtered.
Exact URLs
The contract and URLhaus support exact-URL lookups, but in this version no
evidence path produces a URL indicator. In practice no URL is looked up.
One provider per lookup
Each request goes to one provider: the first enabled provider in this
order that supports the indicator. FreeDR never fans out to several providers.
| Kind |
Order |
hash_reputation |
MalwareBazaar → VirusTotal → CIRCL → URLhaus → AlienVault OTX |
ip_reputation |
AbuseIPDB → GreyNoise (IPv4 only) → ThreatFox → AlienVault OTX |
domain_reputation |
URLhaus → ThreatFox → AlienVault OTX |
If no provider in the list is enabled, the request names the first one and ends
disabled (provider_disabled_or_key_missing), with no network traffic.
Providers
All providers share these rules:
- Fixed HTTPS host; HTTPS only; redirects refused; system proxy ignored.
- 10 s connect and total timeout; response body at most 256 KiB.
- Headers:
Accept: application/json, User-Agent: FreeDR/0.1 targeted-enrichment
and, for keyed providers, the key header (marked sensitive). The key is never put
in the URL or body.
- The response must name the indicator that was sent. Otherwise the result is
error / provider_identity_mismatch.
- Only allowlisted fields are kept, as name/value facts: at most 32 facts,
512 characters per value, 16 KiB in total, control characters removed. More
is cut and marked
provider_details_bounded.
- HTTP 429 →
rate_limited; 401/403 → error / provider_access_denied;
3xx → provider_redirect_refused; 5xx → provider_unavailable;
timeout → provider_timeout.
Result statuses
| Status |
Meaning |
hit |
The provider returned a record for the indicator. A hit is not "malicious": an AbuseIPDB record with 0 reports, a VirusTotal file with 0 detections and a GreyNoise benign IP are all hits. Read the facts. |
not_found |
The provider has no record. Not a clean verdict. |
unknown |
No answer (cancelled, deadline, unsupported indicator, no target, query rejected). |
error |
Transport, HTTP, parse or identity failure. See the reason code. |
rate_limited |
Provider 429, or FreeDR's daily quota / spacing (shared_provider_quota). |
disabled |
Provider off or key missing. |
stale |
Shown by the console for a hit, not_found or unknown result past its valid until time. The API keeps the stored status; compare valid_until_us yourself. |
CIRCL hashlookup
|
|
| Key slot |
none |
| Indicators |
SHA-256 |
| Request |
GET https://hashlookup.circl.lu/lookup/sha256/{sha256} |
| Auth header |
none |
| Not found |
HTTP 404 |
| Facts |
known_file_corpus_match, interpretation ("Corpus membership is context, not a benign verdict"), corpus_source, known_file_name, known_file_size, provider_trust_score (hashlookup:trust) |
| Report link |
https://hashlookup.circl.lu/lookup/sha256/{sha256} |
| Limits |
default 100/day, max 10,000/day |
MalwareBazaar (abuse.ch)
|
|
| Key slot |
abuse_ch (shared with ThreatFox and URLhaus) |
| Indicators |
SHA-256 |
| Request |
POST https://mb-api.abuse.ch/api/v1/, form query=get_info&hash={sha256} |
| Auth header |
Auth-Key |
| Not found |
query_status: hash_not_found |
| Facts |
malware_corpus_match, malware_family (signature), file_type, first_seen, last_seen |
| Report link |
https://bazaar.abuse.ch/sample/{sha256}/ |
| Limits |
default 100/day, max 10,000/day |
ThreatFox (abuse.ch)
|
|
| Key slot |
abuse_ch |
| Indicators |
SHA-256, public IP, public domain |
| Request |
POST https://threatfox-api.abuse.ch/api/v1/, JSON. Hash: {"query":"search_hash","hash":…}. IP/domain: {"query":"search_ioc","search_term":…,"exact_match":true} |
| Auth header |
Auth-Key |
| Not found |
query_status: no_result |
| Matching |
Hash: associated IOCs of type domain, ip:port, url, sha256_hash, md5_hash. Domain: IOC of type domain, same name. IP: IOC of type ip:port with the same IP. |
| Facts |
coverage (expired IOCs are omitted by the API), relation, returned_records; for up to 3 matching IOCs ioc_N.indicator, .indicator_type, .threat_type, .malware_family, .provider_confidence, .first_seen, .last_seen; details_bounded if more were returned |
| Report link |
https://threatfox.abuse.ch/ioc/{id}/ (first matching IOC) |
| Limits |
default 100/day, max 10,000/day |
URLhaus (abuse.ch)
|
|
| Key slot |
abuse_ch |
| Indicators |
SHA-256, public domain, exact URL |
| Request |
POST https://urlhaus-api.abuse.ch/v1/payload/ (form sha256_hash), /v1/host/ (form host), /v1/url/ (form url) |
| Auth header |
Auth-Key |
| Not found |
query_status: no_results |
| Facts |
malware_distribution_context, interpretation (malware distribution, not phishing or host guilt), malware_family, file_type, observed_url_count, url_status, threat_type, first_seen, last_seen, date_added |
| Report link |
hash: https://urlhaus.abuse.ch/browse.php?search={sha256}; domain: https://urlhaus.abuse.ch/host/{domain}/; URL: https://urlhaus.abuse.ch/url/{id}/ |
| Limits |
default 100/day, max 10,000/day |
AbuseIPDB
|
|
| Key slot |
abuse_ipdb |
| Indicators |
public IP (IPv4 and IPv6) |
| Request |
GET https://api.abuseipdb.com/api/v2/check?ipAddress={ip}&maxAgeInDays=90 |
| Auth header |
Key |
| Not found |
none: every valid answer is a hit (the record must say isPublic: true) |
| Facts |
abuse_confidence_score (0–100), total_reports, report_window_days (90), interpretation ("zero reports is not clean"), distinct_reporters, last_reported_at, country_code, usage_type |
| Report link |
https://www.abuseipdb.com/check/{ip} |
| Limits |
default 100/day, max 10,000/day. FreeDR also reads X-RateLimit-Remaining/X-RateLimit-Reset: at 0 remaining the provider is blocked until the reset time. |
VirusTotal
|
|
| Key slot |
virus_total |
| Indicators |
SHA-256 only (no upload, no URL/IP/domain) |
| Request |
GET https://www.virustotal.com/api/v3/files/{sha256} |
| Auth header |
x-apikey |
| Not found |
HTTP 404 |
| Facts |
engines_malicious, engines_suspicious, engines_undetected, engines_harmless, interpretation ("zero detections is not a clean verdict"), threat_label, known_file_name, file_type, community_reputation, times_submitted, first_submitted_at, last_analysis_at. No per-engine results. |
| Report link |
https://www.virustotal.com/gui/file/{sha256} |
| Limits |
default 100/day, max 500/day; one request per 15 s (free tier: 4/min) |
AlienVault OTX
|
|
| Key slot |
alien_vault_otx |
| Indicators |
SHA-256, public IP (IPv4 and IPv6), public domain |
| Request |
GET https://otx.alienvault.com/api/v1/indicators/{file,IPv4,IPv6,domain}/{value}/general |
| Auth header |
X-OTX-API-KEY |
| Not found |
HTTP 404, or pulse_count 0 with no OTX validation notes |
| Facts |
pulse_count, interpretation, provider_validation (OTX's own allowlist notes, such as "Whitelisted domain"); for up to 3 pulses pulse_N.name, .adversary, .created, .modified, .tags (5), .malware_families (5); details_bounded |
| Report link |
https://otx.alienvault.com/indicator/{file,ip,domain}/{value} |
| Limits |
default 100/day, max 10,000/day |
|
|
| Key slot |
grey_noise |
| Indicators |
public IPv4 only. An IPv6 address skips GreyNoise for the next enabled IP provider. |
| Request |
GET https://api.greynoise.io/v3/community/{ip} |
| Auth header |
key |
| Not found |
HTTP 404 |
| Facts |
internet_scanner (noise), common_business_service (riot), classification, service_name, last_seen, interpretation ("a benign class does not clear this host's connection") |
| Report link |
https://viz.greynoise.io/ip/{ip} |
| Limits |
default 100/day, max 10,000/day |
Settings
Settings → Enrichment & AI, or GET/POST /api/v1/settings/enrichment.
| Setting |
Default |
Rules |
| Enable this provider |
off |
A keyed provider cannot be enabled without its key: the save fails with credential required. |
| Daily request limit |
100 |
1 to 10,000; VirusTotal 1 to 500 |
| Keys (5 slots) |
none |
abuse_ch, abuse_ipdb, virus_total, alien_vault_otx, grey_noise. 1–4096 printable ASCII characters. Write-only: Keep, Enter or replace, Remove. |
- Keys are encrypted with user-scoped DPAPI under the service account
(LocalSystem) and stored in the control database with the settings. The API
never returns a key, only whether a slot is configured.
- Saves carry the settings revision and a CSRF token. A stale revision returns
409; reload and save again. A new policy takes effect only after the database
commit.
- Keys can also come from the setup configuration file
(Getting started).
Active provider status and access check
The Active provider status list (also in the case panel under Provider status
and shared quotas) comes from GET /api/v1/settings/enrichment/status. Per
provider: enabled, key configured, supported indicators, used_today / daily
limit, Retry after time if blocked, and the last lookup status and reason.
Note
The last status and reason are kept in memory. They are empty after a service
restart until the next lookup.
Check access with a harmless lookup (POST /api/v1/settings/enrichment/{provider}/validate)
queues one lookup of a fixed public value, never case evidence:
| Provider |
Value sent |
| CIRCL, MalwareBazaar, ThreatFox, URLhaus, VirusTotal, AlienVault OTX |
SHA-256 of the empty file, e3b0c442…b855 |
| AbuseIPDB, GreyNoise |
1.1.1.1 |
The check uses the saved settings, counts against the daily quota, and is refused
(403 enrichment_disabled) if the provider is off or has no key. At most 5
checks can be queued. The result appears as the provider's last status: refresh
the status to see it. A not_found result also proves that the key works.
Quotas, spacing and retries
| Topic |
Behaviour |
| Daily quota |
Counted per provider per UTC day in the control database. Each request sent to the provider counts, including retries and access checks. Cached results do not count. |
| Quota exhausted |
The request ends rate_limited (shared_provider_quota). Nothing is queued for the next day. Automatic lookups for the same events are not retried later, unless provider settings are saved again. |
| VirusTotal spacing |
Each reserved VirusTotal request blocks the provider for 15 s. Waiting requests are held in turn, up to 5 minutes after they were created. A request whose turn would come later ends rate_limited. |
Provider 429 / Retry-After |
The provider is blocked until Retry-After (60 s if absent). Requests wait for the block if it ends before their deadline. |
| Retries |
Timeouts, connection errors, interrupted bodies, 5xx and 429 are retried once (2 attempts in total), not before 1 s and not after the deadline. 401/403, other 4xx and parse errors are not retried. |
| Deadline |
60 s after the request is created (VirusTotal spacing can extend it to 5 minutes). After that the request ends unknown (enrichment_deadline). |
| Concurrency |
One coordinator processes requests one at a time. The store also caps running external lookups at 2. One canonical lookup (provider + indicator) runs once; other requests for it wait and share the result. |
| Capacity |
At most 256 requests pending or running in total. Beyond that, new requests are refused (enrichment_busy, HTTP 503; the AI is told to list the check as a gap). |
| Restart |
Running requests return to pending at service start. |
Caching
A result is stored once as an immutable observation and linked to every case
request that asked for the same provider and indicator. A new request reuses a fresh
observation from any case without contacting the provider.
| Result |
Reused for |
hit for a SHA-256 |
24 h |
hit for an IP, domain or URL |
1 h |
not_found, unknown |
15 min |
error, rate_limited, disabled |
never reused |
The provider's Cache-Control: max-age (minus Age) or Expires can shorten
this; no-store / no-cache makes the result non-reusable. A manual request for
the same case, event and kind returns the earlier request while its result is
fresh or it is still running. Old observations stay on the case and are shown as
stale.
Privacy
| What leaves the host |
What never leaves the host |
| One canonical indicator per request: a SHA-256, a public IP or a public domain (exact URL in the contract only) |
Files or file samples (no upload to any provider) |
| The provider key, in its header |
File paths, command lines, user names, host name, case data |
User-Agent: FreeDR/0.1 targeted-enrichment |
Private, reserved and documentation IPs; reserved internal TLDs; URLs with query, fragment or credentials |
- Nothing is sent for a provider you have not enabled. Saving a key alone sends
nothing; enabling and saving does start automatic lookups for open cases.
- Endpoints are fixed in code. No case, indicator or console setting can change
the host, path, proxy or redirect target.
- Provider text is stored as plain display data and rendered as text. Report
links are shown only if they start with
https://.
- Remember that a lookup reveals to the provider that this indicator interests
you. Host name lookups of internal names under a public TLD are sent (see
Which indicators are eligible).
Where results appear
| Place |
What is shown |
| Case → Investigation → Targeted enrichment |
One card per request: provider and status (stale if expired), target, kind, purpose, retrieved and valid-until times, all facts, confidence basis, reason code, Provider reference link, observation ID, evidence ID. Cancel request while pending or running. Request form (kind, linked event IDs, purpose). |
| Case Summary |
Enrichment line counting requests by kind and state, and up to 8 observations with their first 4 facts. |
HTML report (GET …/report.html) |
Enrichment observations section, up to 200 observations. |
| AI |
The case summary's enrichment entry, the case_context section enrichment, and the brief used by Explain this alert. Tool results pass through the AI egress redaction (AI overview). |
API
| Method and path |
Body / query |
Response |
GET /api/v1/cases/{case_id}/enrichment |
limit (1–100, default 50), cursor |
{schema_version, requests[], observations[], next_cursor} |
POST /api/v1/cases/{case_id}/enrichment |
{schema_version:1, csrf_token, request:{schema_version:1, evidence_event_ids[1–16], kind, purpose}} |
202 {schema_version:1, requests[]} |
POST /api/v1/cases/{case_id}/enrichment/{request_id}/cancel |
{schema_version:1, csrf_token} |
{schema_version:1, cancelled: bool} |
GET /api/v1/settings/enrichment/status |
— |
{schema_version:1, providers[]} |
POST /api/v1/settings/enrichment/{provider}/validate |
{schema_version:1, csrf_token} |
202 {schema_version:1, provider} |
GET / POST /api/v1/settings/enrichment |
save: {schema_version, csrf_token, change:{schema_version, expected_revision, providers[8], ai, credentials[]}} |
settings snapshot (no keys) |
- The case must be open or in progress, and every event ID must be a case event
or alert evidence of the case. Extra fields (such as an
indicator) are rejected
with 400.
- A request has
state pending, waiting_local, running, completed or
cancelled, and links to an observation_id when done. An observation has
provider, target, status, facts[], reference_url, reason_code,
retrieved_at_us, valid_until_us, provider_last_seen_us.
- Cancelling marks the request
cancelled and stops its HTTP request if it is
the one in flight. Requests from other cases for the same lookup continue on
their own.
Citing enrichment in a response
A response step can cite an enrichment observation (kind: enrichment,
observation_id). The observation must be linked to the case. Its targets are the
case events that requested it, and the citation is bound to the observation's
content digest.
| Who |
Behaviour |
| AI investigator |
inspect_response accepts an enrichment citation, but any containment step that cites enrichment stays a draft for you. The AI never runs it, even with Let AI contain threats it finds on. |
| You, in the console |
The response panel offers only alert and event citations. |
| You, via the API |
Enrichment citations are accepted and run like other citations. |
Troubleshooting
| Symptom |
Cause |
Fix |
Result disabled, provider_disabled_or_key_missing |
No enabled provider supports the kind |
Enable a provider for that indicator type in Settings → Enrichment & AI and save. Open cases are re-enriched. |
| Save fails: credential required |
Keyed provider enabled without its key |
Enter the key in the same save. |
| Status Key required |
Key slot empty |
Enter the key and save. |
error, provider_access_denied |
Key wrong, revoked or blocked (HTTP 401/403, or abuse.ch no_api_key / invalid_auth_key / user_blacklisted) |
Replace the key, then run the access check. |
rate_limited, shared_provider_quota |
Daily limit used up (UTC day), provider blocked, or VirusTotal spacing queue too long |
Raise the daily limit (within the maximum) or wait for 00:00 UTC; request again manually. |
rate_limited, provider_rate_limited |
Provider returned 429 |
Wait for the Retry after time shown in provider status. |
error, provider_timeout / provider_transport_error |
No route to the provider within 10 s. FreeDR does not use a proxy. |
Allow direct HTTPS egress to the provider host. |
error, provider_unavailable |
Provider 5xx |
Retried once automatically; request again later. |
error, provider_redirect_refused |
Provider (or an intercepting proxy) redirected |
Redirects are never followed; check for TLS interception. |
error, provider_identity_mismatch |
Answer did not name the queried indicator (for ThreatFox: no returned IOC matched) |
Treat as no result. |
unknown, source_public_network_pivot_unavailable |
Not a Sysmon Event 3/22, no Sysmon, private destination, reserved TLD or duplicate field |
Expected for private/internal addresses. Install Sysmon for network lookups. |
unknown, unsupported_indicator |
Provider cannot answer this indicator (for example IPv6 for GreyNoise) |
Enable another provider for that type. |
unknown, enrichment_deadline |
Not done within 60 s (5 min for VirusTotal spacing) |
Request again. |
Request refused, enrichment_busy (503) |
256 requests already pending |
Wait for the queue to drain. |
not_found / hit with no detections |
Normal provider answer |
Neither means benign; judge with case evidence. |
Result shows stale |
Past its valid-until time |
Request again for a fresh lookup. |
| Provider status empty after restart |
Last status is kept in memory only |
Run the access check. |
Provider settings degraded (settings_reset_invalid) |
Stored settings invalid or older version, reset to defaults |
Re-enter providers and keys (Health). |