Skip to content

[high] fix(chainsaw): index the detections, not just how many there were - #127

Draft
elhoim wants to merge 1 commit into
calebevans:mainfrom
elhoim:fix/chainsaw-detection-indexing
Draft

[high] fix(chainsaw): index the detections, not just how many there were#127
elhoim wants to merge 1 commit into
calebevans:mainfrom
elhoim:fix/chainsaw-detection-indexing

Conversation

@elhoim

@elhoim elhoim commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

BLUF

  • Priority: high.
  • Chainsaw's detections are not searchable in the case. run_chainsaw indexed only a summary — Total findings: 3 and the per-level counts — so a later search() cannot find a Chainsaw detection by rule name, host, event ID or MITRE technique.
  • The records went to tool_response, but with source set that returns a compact preview and drops the rest. So the detections reached neither the case DB nor, in full, the agent.
  • The same defect has a second half: the parsers truncated their record lists (detections[:500], srum_entries[:500], timeline_entries[:1000]) before the caller saw them. On a busy host, records past the cap could not be indexed even in principle.
  • Fix: format one searchable line per record and index all of them; cap only the response, flagged with <key>_truncated. The cap belongs on what is returned, never on what is stored.
  • Scope: src/mulder/server/tools/chainsaw.py only. The formatter is a module-local helper, not a shared abstraction.

The bug

        text_parts = [f"Chainsaw {mode} analysis of {evidence_path}"]
        if mode in ("hunt", "search"):
            text_parts.append(f"Total findings: {result.get('total_findings', 0)}")
            for level, count in result.get("severity_counts", {}).items():
                text_parts.append(f"  {level}: {count}")
        ...
        summary = extract_and_index("\n".join(text_parts), source_name, evidence_path, "chainsaw")
        summary.update(result)

Everything indexed is a count. A hunt that fires Suspicious PowerShell Encoded Command on WORKSTATION-07 stores the sentence "Total findings: 1" and nothing an analyst would actually search for.

And in the parsers, upstream of all of it:

    return {
        "detections": detections[:500],      # cut before the caller can index them
        "total_findings": len(detections),   # ... though the count stays honest
    }

The fix

Records are no longer truncated in the parsers. The caller formats and indexes every one, then caps the response:

        text_parts.extend(_detection_lines(result, mode))

        summary = extract_and_index("\n".join(text_parts), source_name, evidence_path, "chainsaw")
        summary.update(_cap_records(result))
def _cap_records(result: dict[str, Any]) -> dict[str, Any]:
    """Trim the record lists in the *response* only, never before indexing."""
    capped = dict(result)
    for key in ("detections", "srum_entries", "timeline_entries"):
        records = capped.get(key)
        if isinstance(records, list) and len(records) > _RESPONSE_RECORD_CAP:
            capped[key] = records[:_RESPONSE_RECORD_CAP]
            capped[f"{key}_truncated"] = True
    return capped

_detection_lines is deliberately module-local. The fields worth indexing differ per tool — a Chainsaw detection is not shaped like a Zircolite one — so a shared formatter would be a cross-cutting abstraction with no second honest caller. Response size is unchanged at 500 records; the difference is that the other 100 of a 600-detection hunt are now in the case DB instead of discarded.

Deliberately out of scope

  • The tool_response envelope. Making a success response carry more than a preview is a separate concern and is not proposed here — indexing is the right place for the full records, which is exactly what this PR does.
  • The CLI defects. Hunt mode is currently rejected by argument parsing for a missing --mapping, and srum for a rejected --json plus a missing --software. Those are separate PRs against separate subcommands. This PR is about what happens to detections once Chainsaw does produce them.
  • Surfacing failures. Open PR [high] fix(chainsaw): report a failed Chainsaw run instead of a clean scan #95 (fix/chainsaw-exit-code) covers that; independent of this and also branched off main.

Verification

  • uvx pre-commit run --all-files (new test staged first) → ruff, ruff-format, mypy all pass.
  • uv run --locked --extra dev pytest tests/ -q861 passed, nothing deselected, nothing skipped.
  • Discriminating check — with chainsaw.py restored to origin/main and the new tests kept, 4 of 6 fail, all behaviourally (no import errors, no signature changes involved):
FAILED test_the_detection_itself_is_indexed_not_only_its_count - AssertionError: the rule name never reached the case DB; indexed text was:
FAILED test_every_detection_is_indexed_past_the_response_cap - AssertionError: detections past the 500 cap were never indexed
FAILED test_the_response_is_still_capped - KeyError: 'detections_truncated'
FAILED test_timeline_entries_are_indexed - AssertionError: assert 'svchost.exe spawned cmd.exe' in 'Chainsaw timeline ...
4 failed, 2 passed

The two that pass on both trees are the narrowness pins: the existing summary lines are still indexed, and a hunt with zero detections is still a successful, indexed result.

test_the_response_is_still_capped is the one that guards against over-correcting — it asserts the response holds exactly 500 records and total_findings == 600, while detection 599 is present in the indexed text.

Context

Recovered from closed PR #74, which made this change across chainsaw, zircolite, aleapp and ileapp at once behind a shared record-formatting helper. This is one tool, and the helper is module-local — the rejected outcome framework is not reintroduced, and there is no classify_tool_exit or second status taxonomy here, per the review on #32:

focused fixes for specific tools that currently swallow failures or lose partial-result information would be welcome.

Branched fresh from current main (2e5432c); the closed branch was not revised in place, and this uses a new branch name rather than reusing the closed one.

🤖 Generated with Claude Code

run_chainsaw indexed only a summary -- "Total findings: 3" and the
per-level counts. The detections themselves (rule names, computers,
event IDs, MITRE techniques, SRUM and timeline entries) went to
tool_response but never to extract_and_index, and tool_response returns
a compact preview once source is set. A later search() over the case
could not find a single Chainsaw detection by rule name.

The same defect had a second half: the parsers truncated their record
lists -- detections[:500], srum_entries[:500], timeline_entries[:1000] --
before the caller ever saw them, so on a busy host the records past the
cap could not be indexed even in principle.

Format one searchable line per record and index all of them, then cap
only what goes into the response, flagging it with <key>_truncated. The
true total_findings / total_entries count is unchanged and still
reflects everything Chainsaw produced.

The formatter is a module-local helper on purpose: the fields worth
indexing differ per tool, so this is not a shared cross-cutting
abstraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant