Repository navigation
Conversation
An automated sweep across N hosts produces the same observation N times. Nothing deduplicates findings today, so the examiner reads and decides on every near-identical copy individually, and the IOC cascade fires per finding. Add `vhir review --findings --cluster`, which reduces each finding's text to a template by masking the spans that legitimately vary between hosts (addresses, hashes, GUIDs, user names, timestamps, host labels) and groups findings sharing a template. A second pass merges templates whose token overlap reaches 0.85, which catches the wording drift a generative model produces writing the same finding N times. On a synthetic 36-finding sweep — one persistence mechanism found on 30 hosts, one credential-access finding written three slightly different ways, three genuinely distinct findings — this collapses to 5 review units, and the three distinct findings stay distinct. Deterministic, with no model and no network. The same case clusters identically on every machine, which is the property a forensic tool needs and which a probabilistic grouping cannot offer. Exact-template grouping is linear; the merge pass compares distinct templates only and is skipped above 400 of them, so the quadratic term stays bounded. Presentation only. Every finding keeps its own id, its own content_hash and its own row in approvals.jsonl. Nothing here reads or writes status. Collapsing findings into a single custody record would destroy the per-host provenance this tool exists to preserve, so cluster-level approval is deliberately not included. Nothing is persisted: compute_content_hash() covers every field outside an explicit exclusion set, so writing a cluster id into a finding would invalidate its integrity check. Clusters are recomputed on read. Adds tests/test_clustering.py (13 tests). 441 passing on Python 3.10 and 3.12. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 17, 2026
CVE-2024-1234 normalised to "<HOST>-<N>": the host-label rule consumed "CVE-2024" and the bare-integer rule consumed the rest. Two findings that differed only in which CVE was exploited produced identical templates and merged into one cluster. T1003.001 and T1003.002 collapsed the same way via the sub-technique digits. Which CVE was exploited, and which sub-technique was observed, are precisely the distinctions an examiner must not lose to a display grouping. Fold these identifiers to \w-only tokens before the masking rules run, so neither the host-label nor the integer rule can reach into them. The PR body already named host-label masking as the most likely source of a wrong merge. This is that failure, found and fixed. Adds 4 regression tests: different CVEs and different sub-techniques stay separate, the same CVE across hosts still merges. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 17, 2026
Open
Contributor
|
Thanks. We're deferring new review-queue features to the next minor release. One note for when we pick it up: the |
elhoim
marked this pull request as ready for review
October 9, 2026 20:03
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
BLUF
vhir review --findings --clustergroups near-duplicate findings into one review unit. On a synthetic 36-finding sweep this is 36 items → 5 clusters; the genuinely distinct findings stay distinct.content_hashand its own row inapprovals.jsonl. Nothing here reads or writesstatus. Cluster-level approval is deliberately not included; that is a custody decision for you, not for me.--clusterwithout--findingsdoes nothing.Priority: MEDIUM
The problem
A triage sweep stages findings per host. Thirty hosts with the same scheduled-task persistence produce thirty findings that differ only in hostname, user, PID and timestamp. Today
vhir review --findingslists all thirty; the interactive approval queue walks all thirty.The approach
Each finding's text is reduced to a template by masking the spans that legitimately vary between hosts:
<IP><SHA256>/<SHA1>/<MD5><GUID><TS>/<DATE>/<TIME>\Users\<name>\,/home/<name>/<USER>WIN-4K2J1,SRV-DC01)<HOST><N>Findings sharing a template are the same observation. A second pass merges templates whose token overlap (Jaccard) reaches 0.85, which catches the wording drift a generative model produces when writing the same finding thirty times —
"lsass.exe accessed by rundll32.exe"vs"lsass.exe was accessed by rundll32.exe".Measured
Synthetic case: one persistence mechanism on 30 hosts, one credential-access finding written three slightly different ways, three genuinely distinct findings.
The three distinct findings did not merge, which is the result that matters — a clustering that over-merges is worse than none.
Why deterministic and not a model
This started as a proposal to ask a model "do these two findings describe the same activity?". Doing it in code turns out to be better here, not merely cheaper:
Complexity
Exact-template grouping is linear. The merge pass compares distinct templates, not findings — on the demo above, 5 comparisons rather than 36², because the 30 duplicates collapse before the pass runs. Above
MAX_TEMPLATES_FOR_MERGE = 400distinct templates the pass is skipped entirely and only exact matches group, so the quadratic term is bounded. (The original proposal flagged pairwise blow-up as this feature's failure mode; this is the answer to it.)Nothing is persisted
compute_content_hash()hashes every field outside an explicit exclusion set, so writing a cluster id into a finding would change its hash and breakvhir review --verify. Clusters are recomputed on read every time. That is cheap here precisely because the computation is deterministic.Testing
tests/test_clustering.py, 17 tests: masking of each varying span, hashes not nibbled by the integer rule, identical activity across hosts collapsing to one cluster, distinct activity staying separate, wording drift merging, dissimilar text not merging, every finding appearing exactly once, findings left unmutated, deterministic ordering, empty input, and — added after a self-review caught it — that two findings differing only in their CVE or MITRE sub-technique do not merge.pytest tests/— 445 passed on Python 3.10 and 3.12 (428 pre-existing, unchanged; 17 new)ruff check ./ruff format --check .— cleanDeliberately not in this PR
vhir approveis the custody command.CVE-2024-1234normalised to<HOST>-<N>, merging findings about different CVEs. Fixed in a follow-up commit on this branch, with regression tests.)WIN-4K2J1matches, a host namedfileserverdoes not. This is the most likely source of a wrong merge, which is exactly why clustering only groups and never decides.CI status
The checks will show
action_required: GitHub gates workflow runs on PRs from first-time fork contributors until a maintainer approves them. Reproduced locally with the same steps asci.yml:pytest tests/— Python 3.10pytest tests/— Python 3.12ruff check .ruff format --check .Branched from
main@954a623.🤖 Generated with Claude Code