Skip to content

AI Analysis

The AI Runs, One by One

DFIR Companion never makes "one big AI call". The work is split into separate runs, each with its own job, its own cost, and its own trigger. Most of them you never have to think about — they fire on their own. Two of them (2nd opinion, deep pass) you spend money on deliberately, when a case earns it.

Run Trigger Roughly what it costs What you get
Screenshot OCR automatic, on capture free — runs locally searchable screenshot text; redaction before upload
Extraction automatic, after import one call per batch of files/screenshots forensic events (the timeline)
Synthesis automatic + AI Re-synthesize one call findings, MITRE, attacker path, questions, next steps
Second look Run second look button the search is free; one call if you let it re-synthesise raw evidence pulled up; collection leads
Deep reasoning 🧠 checkbox, before a run the same call, plus thinking tokens the same outputs, reasoned harder
Second opinion 2nd opinion button up to three calls a rival model's disagreements, to accept or reject
Deep pass on demand (dashboard / API / CLI) many calls — the expensive one conclusions drawn from every graded event
Missed evidence review Missed evidence button about a penny for a whole case's archive a ranked read of the rows nothing else looks at; you tick what to keep

1. Screenshot OCR

Why we need it. Two reasons, both about text sitting inside pixels. First, a screenshot of a console contains hostnames, usernames and IPs that anonymization cannot tokenize because they are not text — OCR finds them and blacks them out before the image is uploaded to an external vision provider. Second, it makes screenshots searchable.

What it's good for. "I know I saw that hash / that error / that hostname on a screenshot somewhere."

When to use it. Never manually — it runs by itself, locally (Tesseract), after every capture. Nothing leaves the machine and no AI provider is involved. To backfill an older case: npm run ocr-index -- <caseId>.

If OCR cannot read a screenshot. The screenshot is not sent, and the analysis stops with an error. To continue, fix OCR, point vision at a local vision model, or turn anonymisation off for the case.

Settings. DFIR_OCR_SEARCH (full-text search, on by default; set to off to disable), DFIR_OCR_DEBUG / DFIR_OCR_DEBUG_DIR (log each redaction and dump the redacted copies for inspection). The redaction half only runs when an external provider is configured, and follows your anonymization settings (DFIR_ANONYMIZE).

2. Extraction — evidence into events

Why we need it. Screenshots and log files are not a timeline. Extraction is what turns them into dated forensic events: a timestamp, a description, a severity, and structured fields (asset, process, hash, IOC references).

What it's good for. Everything downstream. Nothing else in the tool works without a timeline.

When to use it. Automatic — after each import, and once enough new screenshots have accumulated. Screenshots need a vision model; CSV and log triage are text-only and run on the synthesis model.

How screenshots are batched. Screenshots are analyzed in batches of four, in the order they were taken. A navigation or tab switch sends the current batch early, and a batch that is not full is sent after about five minutes. Batches do not overlap: each screenshot is sent in one batch only. Screenshots marked as duplicates are kept as evidence but are not sent for analysis. So if one page or artifact is captured across several screenshots, it can fall into two batches, and the AI reads each part separately. This is by design. The batch size is fixed; there is no setting for it.

If a batch of screenshots fails. A rate limit (HTTP 429) or a timeout is retried a few times at once. If the batch still fails, the header shows the error and those screenshots are kept as still owed. Later batches are analyzed as normal. The Companion then retries the failed screenshots in the background, up to three times, with a wait between tries. If all three tries fail (for example a bad API key or a removed model), it stops retrying. The screenshots stay owed, and the Diagnostics page still counts the case as a failed analysis. Fix the cause, then turn AI off and on again for the case to retry them. A screenshot whose image file cannot be read (for example, it was deleted from the case folder) is skipped and written to the server log. The other screenshots in its batch are still analyzed, and the skipped one is not retried.

After a restart. Screenshots that were waiting for a full batch when the Companion stopped are not lost. About five minutes after the Companion starts, it analyzes them for every case that has AI on.

Pausing AI during a catch-up. If you turn AI off while it is catching up on screenshots, it stops after the current batch. The rest are analyzed when you turn AI on again.

Settings. DFIR_VISION_PROVIDER / DFIR_VISION_MODEL / DFIR_VISION_KEY / DFIR_VISION_BASE_URL (screenshots), DFIR_AI_SYNTH_* (CSV and log triage), DFIR_AI_TIMEOUT_MS, DFIR_AI_MAX_TOKENS, DFIR_AI_CONTEXT_TOKENS, and DFIR_AI_SYSTEM_PROMPT_FILE / DFIR_AI_CSV_PROMPT_FILE / DFIR_AI_LOG_PROMPT_FILE to override the prompts.

3. Synthesis — the normal run

Why we need it. Extraction produces rows; nobody draws a conclusion from rows. Synthesis is the one call that reads the whole in-scope forensic timeline at once and says what actually happened.

What it's good for. Named findings, MITRE ATT&CK techniques, the attacker path, kill chain coverage, key investigative questions and recommended next steps — i.e. the report.

When to use it. It runs on its own after analysis, and you can force it with AI Re-synthesize whenever you have added evidence, changed the scope window, or marked false positives.

Skip-if-unchanged

Synthesis is skipped automatically if nothing in the timeline changed since last time. Click AI Re-synthesize → Force to override.

If the safety filter stops a run. A model's safety filter can stop a synthesis answer partway. Today only the Claude Code provider reports this stop. Synthesis then asks the same model again, up to DFIR_AI_SYNTH_SAFETY_RETRIES times (default 1). If the model is still stopped, the run moves once to the fallback synthesis model (DFIR_AI_SYNTH_FALLBACK_MODEL) and stays on it for the rest of that run. The Investigation Log line says whether the model passed on a retry or the fallback wrote the run, and how many stops occurred. With no fallback set, you get one clear error that names both settings. The deep pass follows the same rule for each batch it reads (see Deep pass). The second-opinion model (B) and replays retry, but never switch to the fallback. See Settings → AI.

Adversary-emulation context. When a row in the scoped forensic timeline shows a Caldera sandcat agent, synthesis gets one extra block. The block names each host (up to five) and tells the model to weigh this when it judges exercise against real compromise. It is not proof of an exercise: a real attacker can also run Caldera. The block reads the forensic timeline only, never the super-timeline. Synthesis computes it each time it runs, so cases imported before this change get it too.

Leads for the model. Synthesis gets two extra lead blocks. Host activity spikes name the hosts far busier than their peers, or than their own usual rate, with the rows to check. Entry candidates name the programs started just before the first script write on each host, plus console-history downloads marked time unknown. Both read the forensic timeline only.

Initial access. When the timeline shows attacker activity, synthesis is asked to record the earliest observed attacker access as its own finding, even when the origin is unknown. It is titled Initial access only on evidence of entry: an external source, a host with no other activity in the case, or an inbound delivery such as phishing. Otherwise, for example an access from a managed host, the title reads First observed attacker access. The organisation's own VPN address is not external evidence, and a lone alert that other evidence contradicts does not count. Missing source telemetry caps the finding's confidence below 60 and marks the entry as inferred. The finding is left out when no entry is visible or the evidence does not support one, for example when the timeline starts after the entry; the gap is recorded under uncertainties instead. Before it answers the initial access vector as unknown, synthesis reads the parent command line of the earliest confirmed attacker event, and the parent's own process-creation row when it was sent, and says what they show.

Compromised hosts. Before synthesis calls a host not compromised, it checks the events it was sent for that host: account creation, remote sessions, or tooling tied to the attacker. If it finds any, the host is listed as compromised or unresolved. If it finds none, the answer reads "no attacker activity in the events shown", not "not compromised".

Simulation verdict. Sometimes the synthesis concludes that the case is an authorized attack simulation. One finding states that verdict at confidence 80 or more. The attack findings are then capped at Medium and marked "simulated — pending owner confirmation". Persistence on the host keeps its severity. A verdict finding at confidence 25 to 79 also counts, but only when the case summary states the verdict in plain words. A summary that contests the verdict does not count, for example one that says "but", "whether", "unconfirmed" or "real intrusion" in a sentence about the exercise. A sentence about the exercise with a soft word such as "may", "could", "if", "until" or "confirm" blocks the verdict by default. It does not block only when it is a plain follow-up and has no word about the verdict. Plain follow-ups are: reset or reimage the lab hosts, write the report, schedule the next exercise, keep the logs, collect memory, or tune the detection rules. For example, "If the exercise repeats, collect memory as well" does not block the verdict. "Contact the SOC to confirm the exercise" blocks it. These caps show "verdict from summary". To undo the cap, click Treat as real intrusion on the verdict finding.

Echo-only commands. Some findings cite only process rows that run cmd /c echo … and print text, plus file traces of that program. These findings are capped at Medium with the reason "echo only, no effect". A row with %, !, a pipe, a redirect, &, ^, brackets or /k keeps its grade. The rows keep their own severity. For the other finding caps, see Advanced → Finding Severity Caps.

Settings. DFIR_AI_SYNTH_PROVIDER / DFIR_AI_SYNTH_MODEL / DFIR_AI_SYNTH_KEY / DFIR_AI_SYNTH_BASE_URL (a separate, stronger model than the vision one is the recommended setup), DFIR_AI_SYNTH_MAX_EVENTS (how many timeline rows fit in the prompt — default 600), DFIR_SYNTH_INCLUDE_INFO=1 (give Info-severity events prompt space too; off by default), DFIR_SYNTH_GROUP* (collapse repeated detection bursts into one row so more of the case fits), DFIR_AI_SYNTH_PROMPT_FILE.

4. Second look — chasing the questions synthesis just asked

Why we need it. Synthesis only ever sees a sample of the case: a prompt holds a few hundred rows, while the raw super-timeline can hold hundreds of thousands. The evidence that answers synthesis's own open questions is often in the rows it was never shown.

What it's good for. Cases with a large raw super-timeline behind a small forensic timeline — exactly where a quiet recon-or-exfil phase hides.

When to use it. Press Run second look after a synthesis has left you with open questions. It used to run by itself at the end of every synthesis; it no longer does, because it was the only automatic thing that wrote rows into your forensic record, and that is a decision you should make rather than discover.

It takes the case's open hypotheses, unanswered key questions, top connecting IOCs and the model's own "I wasn't shown X" requests, turns them into keyword searches, runs them against the raw record, and pulls up any matching events — tagged so you can see which question pulled each one, and badged ✓ Promoted in the timeline. When a search matches nothing anywhere, that is a collection lead: a gap in what you collected, not a gap in the analysis.

Before you press it, the panel tells you how many searches it would run and roughly how many rows it would write into the case. The search itself costs nothing — it is keyword matching, not a model call. Only the re-synthesis afterwards costs anything, and you can turn that off, though leaving it on is the default so your conclusions never fall behind your record.

What it will not do. It promotes at most a dozen rows per question, and stops after three repeats of the same detection — one noisy rule cannot fill the record. A row it holds back is not deleted: it stays in the raw archive, searchable. Selection by model judgement was tried and rejected; on a real case, ranking by relevance predicted which rows a finding would cite no better than chance.

Settings. None. The caps are fixed and deliberately conservative. It only has something to search if the case has a super-timeline.

4a. Missed evidence review — reading what nothing else reads

Why we need it. The content tagger gets one chance, at import, to lift a row above Info, and it only lifts what its rule set names. Everything else drops out of the forensic timeline and no rule written later can reach back for it. An attack class with no matching rule stays invisible for the life of the case.

What it's good for. Answering "what did the grading miss?" for a whole archive, cheaply, without changing anything.

When to use it. Press Missed evidence when you want a second read of the rows the AI never sees. It grades them with a decision model — a model that returns a grade and a confidence instead of prose — and ranks them. An ordinary press reads up to 2,000 rows; Read every row covers the whole archive and tells you the cost first.

Reading it costs you nothing but the run. Grading changes no case data at all — press it as often as you like. Nothing moves until you tick rows and promote them.

Promoting a row. Tick the rows worth keeping and promote them. Each one is copied into the forensic timeline, which is the record the AI reads, so a promoted row can appear in the next synthesis, in findings and in the report. A row you promote carries:

  • The severity the model gave it. The grade in the table becomes the row's severity. That is the point: an Info row is invisible to synthesis, so a promotion that kept the old grade would change nothing.
  • A note saying a model graded it — the review it came from, the grade, the confidence and the model's name, on the row itself. Six months later nobody has to guess whether an analyst, a tagger rule or a model set that severity.
  • Its old severity, if that was higher. A promotion can only raise a severity, never lower one.

Rows already in the forensic timeline are skipped, not refused — the rest of your selection still goes through, and the reply says what was skipped and why. Sandbox rows are refused: you picked the row, but a model picked the grade, and a model's opinion of sandbox behaviour does not belong in the incident chronology.

Promoting does not re-run the AI. Press AI Re-synthesize when you have finished picking.

Read it with the confidence column. The grade says what; the confidence says whether to trust it. A Medium at 0.9 is worth your time; a Medium at 0.3 is the model admitting it is guessing.

What will fool it. Your own collection kit. On one real archive, 39 of the 45 rows it graded Medium or above were the agent's own binary and service, or detection-pack rule files whose names read like the tools they hunt — …pypykatz_cred_dump_lsass_access.yml is a rule, not Mimikatz. The panel scores each row for that and hides the obvious ones by default.

Settings. DFIR_JEV_ENABLED turns it on (off by default). On the OpenRouter provider a blank DFIR_JEV_KEY reuses the OpenRouter key you already have; the field tells you whether there is one to reuse.

5. Deep reasoning — the 🧠 checkbox

Why we need it. On a multi-hop case, the difference between a shallow finding and a correct one is the model being allowed to think before it writes. Deep reasoning gives the synthesis model an extended thinking budget (Chain-of-Thought).

What it's good for. Complex, multi-host, multi-stage cases; findings that look thin or that miss the link between two hosts.

When to use it. Tick 🧠 in the toolbar before clicking AI Re-synthesize or 2nd opinion — it applies to the next run on this case only, no .env edit and no restart. It is slower and costs extra output tokens, so leave it off for routine re-syntheses. It needs a synthesis (or second-opinion) model on Anthropic, OpenRouter, Ollama or Claude Code; on any other provider the box is greyed out and its tooltip names the provider that does not support it. On Ollama the box sets the model's reasoning effort to high; with the box off, every Ollama call asks for low effort, so a reasoning model such as GLM does not spend its whole output limit thinking.

If a run stops at the output limit. A reasoning model's hidden thinking counts against DFIR_AI_MAX_TOKENS. When a synthesis reply is cut off, the run fails at once with a message that names the limit and, when the provider reports it, how much of it went on thinking. Raise AI max tokens in Settings and restart, or choose a model that reasons less.

Settings. DFIR_AI_SYNTH_THINKING_TOKENS — the global default budget for every synthesis (unset = off). The checkbox is the per-run override; when you tick it without setting the variable, it uses 8000 thinking tokens.

6. Second opinion — a rival model

Why we need it. A model's mistakes are systematic, not random: re-running the same model gives you the same blind spot twice. A different model, ideally from a different vendor, disagrees in useful places.

What it's good for. Quality assurance before you commit to a report, and any finding you would be uncomfortable defending to a client.

When to use it. Click 2nd opinion when the case is essentially done. It runs up to three calls: it first refreshes the primary synthesis if the timeline moved (so you compare two current views, not a stale one against a fresh one), then re-analyzes the case with the second model without writing anything, then diffs the two and hands each disagreement to a referee, which annotates it with a rationale and a recommendation. See Second AI Opinion below for the accept/reject workflow.

When the run finishes. If you are idle, the dashboard jumps to the 2nd opinion panel in Findings and opens it. If you are typing, it shows a toast instead and does not move the page. If the tab is in the background, it jumps when you come back. The panel keeps a Run finished HH:MM — N disagreements note until you dismiss it.

Who is who. Model A is the model that wrote the current findings. The panel names the model that actually answered: if the synthesis model's safety filter stopped it and the fallback model wrote the findings, model A is the fallback. The default referee still runs on the synthesis model and carries its name. Model B is the second-opinion model that re-analyzes the case. The referee judges each disagreement between A and B. It sees the forensic events each disputed finding cites, so it judges from evidence rather than from the two summaries alone. By default the referee is model A. Set it to same-as-b and model B judges its own findings. Set it to a third model for a neutral referee. The panel header names all three.

Settings. DFIR_AI_SECOND_OPINION_MODEL — required; the button stays hidden until it is set. Prefer a model from a different provider. DFIR_AI_RECONCILE_MODEL (with _PROVIDER/_KEY/_BASE_URL) picks the referee — blank = model A, same-as-b = model B, anything else = that model. In the dashboard: Settings → AI → Referee for 2nd-opinion verdicts. DFIR_AI_RECONCILE_PROMPT_FILE overrides the referee prompt.

7. Deep pass — read everything

Why we need it. Prompt size is finite and row count grows with the number of hosts. Measured on real engagements, a 12-host case needs about 5 prompts' worth of rows and a 14-host case about 13 — so on a big case, normal synthesis reads a fraction of the graded evidence and never knows what it missed.

What it's good for. Large, multi-host cases; a case where an entire host or an entire attack phase is suspiciously absent from the findings; the moment before a final report on a big engagement.

When to use it. On demand, when the synthesis coverage line tells you events were left out — and knowingly, because it is the expensive run. Always preview first: the preview makes no AI calls and reports, for your actual case, how many events, rows, batches and tokens each severity floor would cost, so you pick the floor against real numbers rather than guessing.

Batches only ever report observations — what happened, on which host, when, and which event ids it rests on; they are forbidden from issuing verdicts, precisely so thirteen batches cannot invent thirteen conflicting attack stories. Exactly one final synthesis call draws every conclusion. The run is cancellable between batches, and nothing is written until that final call succeeds — an aborted run leaves the case untouched.

Where it is. The Deep pass toolbar button, or the Deep Pass section between Findings and the Forensic Timeline. Opening it measures the case and shows one row per floor — events, prompt rows, batches and estimated input tokens — and you pick the floor there; nothing is spent until you press Run deep pass. While it runs, the current batch is shown next to a Cancel button and the Re-synthesize / 2nd-opinion buttons are locked, because a deep pass ends in a synthesis of its own and starting another would overwrite it. The result names the floor, events, rows, batches and observations — and, if any batches failed, says so in red: that run read less of the case than the numbers suggest. It survives a page reload.

If the safety filter stops a batch. Each batch is read the way synthesis is. The synthesis model is asked again, up to DFIR_AI_SYNTH_SAFETY_RETRIES times. If it is still stopped, that one batch is read on the fallback synthesis model (DFIR_AI_SYNTH_FALLBACK_MODEL), and the next batch starts on the synthesis model again. The result card, the job and the run record say how many batches the fallback model read and name it. With no fallback set, the stopped batch counts as failed and the server log names the setting. A deep pass run on a model you chose for that run never switches to the fallback.

The same thing from the command line, or over HTTP:

# In the companion/ folder:
npm run deep-pass -- <caseId>                   # preview only — no AI calls, no spend
npm run deep-pass -- <caseId> --floor Medium    # run it

There is also an API: GET /cases/:id/deep-pass/preview and POST /cases/:id/deep-pass ({"minSeverity":"Medium"}). A closed or archived case is refused — reopen or restore it first.

Settings. DFIR_DEEP_PASS_MAX_BATCHES (default 30 — a run needing more is refused before spending anything, and the error names a floor that would fit), DFIR_AI_SYNTH_MAX_EVENTS (rows per batch), DFIR_AI_OBSERVE_PROMPT_FILE (the batch prompt). Info-severity events are never included.


AI Providers

DFIR Companion supports multiple AI backends:

Provider Setting
OpenAI DFIR_VISION_PROVIDER=openai
Anthropic (Claude) DFIR_VISION_PROVIDER=openai with DFIR_VISION_BASE_URL=https://api.anthropic.com/v1
OpenRouter DFIR_VISION_PROVIDER=openrouter
Google Gemini DFIR_VISION_PROVIDER=gemini
Ollama (local) DFIR_VISION_PROVIDER=ollama, DFIR_VISION_BASE_URL=http://localhost:11434/v1
LiteLLM (local proxy) DFIR_VISION_PROVIDER=litellm

Configure via the Setup Wizard or in .env. All AI calls are made server-side — API keys never go to the browser. (The screenshot/vision vars were renamed from DFIR_AI_* to DFIR_VISION_*; the legacy DFIR_AI_* names still work as a deprecated fallback.)

Using a local model?

Only screenshot reading needs a multimodal (vision) model — that's DFIR_VISION_MODEL. Everything else (CSV/log import, synthesis, and all other text-only AI features) runs on DFIR_AI_SYNTH_MODEL, so a text-only model is fine there. Use the two-tier setup (DFIR_VISION_MODEL = cheap vision for screenshots, DFIR_AI_SYNTH_MODEL = strong reasoning for everything else) — a weak text model fails log triage silently, returning no events at all rather than wrong ones.


What the AI Sees — Anonymization

By default, the Companion tokenizes identifying information before sending anything to an external AI provider:

Data type Becomes
IPv4 addresses (internal — RFC1918, loopback, link-local, CGNAT) ANON_IP_1, ANON_IP_2, …
IPv4 addresses (public, routable) ANON_EXTIP_1, … — masked on the AI wire; see below for the one place they're kept visible, and the known limitation for what "public" excludes
IPv6 addresses (internal, or public within 2000::/3 / IPv4-mapped) ANON_IP_n / ANON_EXTIP_n — see the IPv6 note below for what's out of scope
Hostnames ANON_HOST_1, … — a host known by its full name (dc01.corp.local) also has its short name (dc01, DC01$) tokenized, and the short name is listed as a separate auto-discovered host. A short name that is a common word (server, www, mail) is left alone. The server name in a share path (\\FS01\C$) is masked as a host too, so it can appear as an auto-discovered host; add it to the suppress list to keep it visible
Usernames (DOMAIN\user, UPNs on an internal domain) ANON_DOMAIN_1\ANON_USER_1, ANON_USER_1@ANON_DOMAIN_2 — ASCII names only, see below
Bare usernames in prose (the account jdoe ran scp) the same ANON_USER_n the qualified form got. A username found in any qualified form (account, UPN, profile path) is replaced wherever it appears as a whole word. Common words used as account names (admin, test, user, guest, system, root, …) are replaced only in their qualified forms
Email addresses ANON_EMAIL_1, … — ASCII local parts only, see below
Domain names ANON_DOMAIN_1, …
User profile paths (C:\Users\<name>, /home/<name>) the username segment becomes ANON_USER_n (the same token as CORP\<name>); the rest of the path is left readable
Credit card numbers ANON_CARD_1, …
Phone numbers ANON_PHONE_1, …
National ID numbers (currently: Israeli Teudat Zehut) ANON_NATID_1, …
PowerShell encoded commands the base64 blob becomes ANON_CMD_1, …; the verb and flag stay visible as tradecraft signal
Windows SIDs tokenized (well-known SIDs like SYSTEM are preserved)

Hashes are deliberately NOT tokenized. They are IOCs — a hash is what makes a finding actionable for the recipient, and it identifies a file, not a victim. The anonymizer has no generic high-entropy rule for exactly this reason: it would clobber them.

This anonymization is applied transparently. The timeline and findings shown to you use the real values (the mapping is maintained per-case).

Known limitation: the account and email detectors are ASCII-only

The DOMAIN\user and email patterns match [A-Za-z0-9._%+-] — ASCII characters only. A name written in any other script is not auto-detected, and this is not only about Hebrew or Cyrillic: any accented Latin name is affected too.

  • mail יוסי@example.co.il → the domain is tokenized, but the local part יוסי is sent as-is.
  • mail josé@example.co.il → same; jose@example.co.il (unaccented) is tokenized in full.
  • logon CORP\יוסי → not detected at all.
  • logon CORP\josé → worse: partially matched. The pattern stops at the unaccented prefix, so this is sent as ANON_DOMAIN_1\ANON_USER_1é — a dangling accented character next to the token. Treat a trailing stray character after an ANON_USER_n token as this bug, not as model output.

Two things do work regardless of script: user profile paths (C:\Users\יוסי\… → C:\Users\ANON_USER_1\…), because that pattern matches the name segment by exclusion rather than by an allow-list; and any value on the case's known-entity or custom-entity lists, which is matched exactly and is script-independent.

So the escape hatches are intact and are the supported route for a non-Latin name: enable Presidio, whose whole purpose is catching names the regex layer misses, and approve the value — or add it directly as a custom entity in the Anonymization panel. Unlike the IP gaps below, nothing here silently records the value as "handled": it is never written to the known list by these detectors, so Presidio keeps flagging it on every call until you resolve it.

Every real, routable IP address is now tokenized before it reaches an external AI provider — public ones included, which the model previously saw in cleartext. A public address becomes ANON_EXTIP_n rather than ANON_IP_n, so the model can still reason about it being external without being shown it, and it is restored to the real value in the answer you read, same as any other token. Two narrow, deliberate exceptions are worth knowing about — see below.

Known limitation: two narrow, deliberate gaps in IP masking

IPv4: every address is classified and either internal (ANON_IP_n) or a genuinely routable public address (ANON_EXTIP_n) — except 0.0.0.0/8, multicast (224.0.0.0/4), reserved (240.0.0.0/4), and the broadcast address 255.255.255.255, which are left visible unchanged. These ranges are structurally never adversary infrastructure (nothing is assigned or routable there), so masking them would only obscure a four-part software version string like 1.0.0.0 that happens to match the same pattern — a real, common false-positive source in forensic text.

IPv6 is narrower: only the globally-routable 2000::/3 range (where adversary infrastructure and documentation examples like 2001:db8::/32 actually live), IPv4-mapped addresses (::ffff:x.x.x.x), and NAT64's well-known prefix 64:ff9b::/96 (judged by the IPv4 it embeds) are treated as maskable public addresses, on top of the usual internal ranges (loopback, unique-local fc00::/7, link-local fe80::/10). An IPv6 literal that is neither internal nor inside 2000::/3 — i.e. unallocated or reserved space — is left completely untouched: not tokenized, not reserved from other detectors, exactly as it appeared in the source text. This is a deliberate trade-off, not a bug: IPv6 addresses are frequently mimicked by ordinary code ([Convert]::FromBase64String(, std::cout, WIN11::admin all look like IPv6 literals to a naive pattern), so masking anything that merely matches the shape would blind every other detector across whatever text it swallowed. In practice this means a routable-but-unallocated or experimental IPv6 address would reach the model unmasked.

The escape hatch for either gap is the same, and it's different from the usual suppression list (which un-masks a value — the opposite of what's needed here): add the specific address as a custom entity — category IP or EXTIP — in the Anonymization panel, which tokenizes it by exact match regardless of what the IP detectors decided.

The redacted export keeps public IPs visible — on purpose

The redacted case package (a ZIP built for sharing outside the tool) does not mask public IPs. A report handed to a client or another team is expected to name adversary infrastructure, so that export path tokenizes everything internal (hosts, users, internal IPs, domains, paths) but leaves public addresses as attacker infrastructure that stays actionable for the recipient.

Screenshot redaction is one-way — a public IP seen only in an image will not become an IOC

Text sent to the AI can always be un-masked, because the anonymizer keeps the real value behind the token. A screenshot is different: OCR finds sensitive text in the image and paints a black box over it before the image is uploaded, and there is no token to restore it from afterward. If a public IP (or any other masked value) appears only in a screenshot and nowhere in text evidence, it will be redacted from the image and will never be extracted as an indicator. Make sure anything IOC-worthy also lands in a log, CSV, or other text import.

National ID numbers: expect the occasional false positive

The Israeli Teudat Zehut check digit rules out roughly 9 in 10 arbitrary nine-digit numbers, but the tenth passes by chance. In a case with no Israeli PII, this will occasionally tokenize an unrelated file offset, sequence number, or ID. Untick the ID numbers category for the case, or add the specific value to the anonymization suppression list, if it becomes a nuisance.

Toggle: Settings → AI → Anonymization, or the per-case AI control panel. For names — the one category no regular expression can catch — see Presidio & PII Masking, an optional add-on layer.


AI Controls (Per Case)

The AI control panel lets you:

  • Enable/disable AI analysis for this case
  • Enable/disable synthesis
  • Enable/disable enrichment
  • Toggle the 🧠 Deep checkbox — enables Chain-of-Thought (extended thinking) for synthesis, giving the model more reasoning budget for complex cases

Second AI Opinion

Click 2nd Opinion in the toolbar (requires DFIR_AI_SECOND_OPINION_MODEL to be configured). A different model (B) re-synthesizes the case independently of the synthesis model (A). The panel header reads A: <model> vs B: <model> · referee: <model>. The dashboard shows where the two models disagree:

  • Added findings (model B found something model A missed)
  • Removed findings (model B did not confirm something model A concluded)
  • Severity differences

A finding that one side already dismissed is not a claim, so it is never listed as an added or removed finding. Without this, every finding you or an accepted decision dismissed on model A (harness noise, for example) would come back as a "removed" row. - MITRE technique additions/removals — a technique counts as mapped by a model when its MITRE table or one of its findings that is not dismissed carries it, so a technique both models tag on a finding is not a disagreement

For a technique disagreement, the referee also reads the findings that carry the technique. A staged tool or script that never ran keeps its technique: the finding text records that it did not run. The referee suggests a removal only when the technique is wrong for the artefact, for example build-time activity.

A referee model reads each disagreement together with the forensic events the disputed finding cites, and writes a referee suggests: accept B or referee suggests: keep A line under it. The referee is model A unless you change it under Settings → AI → Referee for 2nd-opinion verdicts (same-as-b lets model B judge its own findings; any other model ID names a neutral third referee).

The referee reads each cited event with the same tags the main analysis reads: host, process, network connection, the full command line when the description cuts it, build-time and lab-setup markers, and the C2 settings found in script blocks. It judges each disagreement with the same facts both models saw.

If the referee call fails, the panel header says so: ⚠ referee (name) failed: reason. The disagreements are still listed, without referee lines, and follow referee stays hidden. Fix the cause (for example a referee CLI that is not on the server's PATH), then click ↻ re-run referee. Only the referee runs again. Neither synthesis is repeated, and your accept/reject decisions stay. The re-run judges the same comparison the failed attempt was given, not the timeline as it is now.

For each delta you can Accept (adopt the second model's view) or Keep A (keep the original). Accepted deltas survive future re-syntheses.

The referee can decline to make a call when the evidence is ambiguous. Those rows read referee: no call — decide this one. ⚖ follow referee applies only the calls the referee made. It accepts where the referee suggests accept B and rejects where it suggests keep A. Every delta with no call stays pending, and model A's call stays on the case until you decide. After the bulk action, the status bar gives the counts, and the panel lists the deltas that are left: ⚖ N deltas had no referee call — review them, each with its own accept and reject buttons. Click dismiss to hide the list; the per-row line stays. A new run or a switch to another case clears the list. Dismissals marked ⚠ for you are not in this list because they already carry their own warning. When the referee call failed, the list does not appear: the header error explains why there are no calls.

After new evidence, the primary model can make a new call on a finding you already decided. When that call differs from both its first call and the call you accepted, the panel marks the decision reopened (↻) and shows what the primary model now says. Your decision stays applied until you press Keep or Drop. Keep records the new call as the one you overrule. Drop rejects your decision and puts the finding back on the primary model's call at once. A severity you restored over a grading cap still applies after Drop.

Tip

Use a model from a different provider for the second opinion. Same-provider models share training blind spots — cross-provider disagreements are the most informative.


Custom AI Prompts

All AI prompts can be overridden without code changes:

  1. Run npm run prompts:eject -- ./prompts to dump the built-in prompts to files.
  2. Edit the files.
  3. Set DFIR_AI_SYSTEM_PROMPT_FILE=./prompts/system.txt (etc.) in .env.
  4. Changes are picked up on the next AI call — no restart needed.

Available prompts: SYSTEM (extraction), CSV, LOG, SYNTH (synthesis), ASK, EXEC (executive summary), NARRATIVE, HUNTS, PBHUNTS, GAPHYP, MEMNEXT, QUERYXLATE, RECONCILE, REMEDIATION.