Skip to content
BlackSnufkin edited this page May 3, 2026 · 1 revision

GrumpyCats Library

Python client for the LitterBox HTTP API. Same surface as GrumpyCats CLI but importable β€” useful for batch operations, CI integrations, the LitterBoxMCP server, and anything that needs to drive LitterBox programmatically without shelling out.

from litterbox_client import LitterBoxClient

with LitterBoxClient("http://127.0.0.1:1337") as c:
    info = c.upload_file("malware.exe")
    static = c.analyze_file(info["md5"], "static", wait_for_completion=True)
    risk = c.get_risk_assessment(info["md5"])

Install

The library lives in GrumpyCats/litterbox_client/ inside the repo. There's no separate PyPI package β€” install LitterBox's requirements.txt and add the GrumpyCats/ folder to PYTHONPATH, or just run scripts from GrumpyCats/:

cd GrumpyCats/
python my_script.py

If you're running the CLI or MCP server from this folder, the import already works β€” LitterBoxMCP.py does sys.path.insert(0, Path(__file__).parent).


Mixin structure

LitterBoxClient is a thin composition. Each domain lives in its own mixin under litterbox_client/:

class LitterBoxClient(
    FilesMixin,         # upload_file / upload_driver / delete_file
    AnalysisMixin,      # analyze_file / analyze_holygrail / validate_process
    DoppelgangerMixin,  # run_blender_scan / compare_with_blender / analyze_with_fuzzy
    ResultsMixin,       # get_static_results / get_dynamic_results / get_*
    EdrMixin,           # list_edr_profiles / analyze_edr / get_edr_results
    ReportsMixin,       # get_report / download_report
    SystemMixin,        # get_system_status / get_files_summary / cleanup
    _BaseClient,        # session, _make_request, retry policy
):
    """..."""

Public callers import LitterBoxClient and treat it as one class. The split is purely an internal organization win.


Constructor

LitterBoxClient(
    base_url: str = "http://127.0.0.1:1337",
    timeout: int = 120,
    verify_ssl: bool = True,
    proxy_config: dict | None = None,   # e.g. {"http": "...", "https": "..."}
    logger: logging.Logger | None = None,
)

The client maintains a requests.Session for connection pooling. Use it as a context manager (with LitterBoxClient(...) as c:) or explicitly call c.close() to release the pool.


Files / intake

c.upload_file(path: str, file_name: str | None = None) -> dict
c.upload_and_analyze_file(path, analysis_types=("static", "dynamic"), file_name=None, cmd_args=None) -> dict
c.upload_driver(path: str, file_name: str | None = None) -> dict
c.upload_and_analyze_driver(path, file_name=None, run_holygrail=True) -> dict
c.delete_file(file_hash: str) -> dict

upload_file returns {md5, size, original_name, ...}. Use md5 as the file_hash in subsequent calls.


Analysis

c.analyze_file(target: str | int, analysis_type: str, cmd_args=None, wait_for_completion=True) -> dict
c.analyze_holygrail(file_hash: str, wait_for_completion=True) -> dict
c.validate_process(pid: int) -> dict

target is an MD5 hash for files or an int PID for analysis_type='dynamic'. wait_for_completion=True is synchronous (the request blocks until the server returns final results).


EDR

c.list_edr_profiles() -> dict
c.get_edr_agents_status() -> dict          # cached fleet probe
c.analyze_edr(file_hash, profile, cmd_args=None, xor_key=None) -> dict
c.get_edr_results(file_hash, profile) -> dict
c.get_edr_index(file_hash) -> dict
c.wait_for_edr_completion(file_hash, profile, interval=3.0, timeout=180.0) -> dict
c.fibratus_alerts_since(profile, since_iso, until_iso=None) -> dict

analyze_edr returns the Phase-1 result immediately β€” Phase-2 (alert correlation) runs in a server-side daemon thread. Either poll get_edr_results until the status is no longer polling_alerts, or call wait_for_edr_completion to block.

xor_key (0–255) requests XOR-on-the-wire encoding of the payload. Whiskers reverses the XOR while writing to disk; the cleartext window is bounded by the chunked-write buffer (64 KiB).


Doppelganger

c.run_blender_scan() -> dict
c.compare_with_blender(file_hash) -> dict
c.analyze_with_fuzzy(file_hash, threshold=85) -> dict
c.create_fuzzy_database(folder_path, extensions=None) -> dict

Results / reports

c.get_file_info(file_hash) -> dict
c.get_static_results(file_hash) -> dict
c.get_dynamic_results(target) -> dict
c.get_holygrail_results(file_hash) -> dict
c.get_risk_assessment(target) -> dict
c.get_comprehensive_results(target) -> dict   # parallel fetch of every saved JSON
c.get_report(target) -> str                    # HTML inline
c.download_report(target, output_path=None) -> str   # writes to disk, returns path

get_comprehensive_results fans out to file_info + static + dynamic + holygrail + EDR index in parallel via a thread pool β€” substantially faster than four sequential calls.


System

c.get_system_status(full=False) -> dict
c.get_files_summary() -> dict
c.get_scanners_status() -> dict
c.cleanup(include_uploads=True, include_results=True, include_analysis=True) -> dict

Errors

Two exception classes:

Exception When raised
LitterBoxError Local validation, missing file, unsupported argument
LitterBoxAPIError HTTP error from the server β€” has .status_code and .response_body
from litterbox_client import LitterBoxClient, LitterBoxAPIError

try:
    c.analyze_edr(file_hash, "elastic", wait_for_completion=True)
except LitterBoxAPIError as e:
    if e.status_code == 409:
        print("agent busy β€” try again")
    elif e.status_code == 502:
        print("agent unreachable")
    else:
        raise

Example: batch upload + EDR fan-out

from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

from litterbox_client import LitterBoxClient

samples = list(Path("./payloads").glob("*.exe"))

with LitterBoxClient("http://127.0.0.1:1337") as c:
    profiles = [p["name"] for p in c.list_edr_profiles()["profiles"]]

    # 1) upload everything
    uploaded = [c.upload_file(str(p)) for p in samples]

    # 2) fan out: each sample Γ— each profile
    def dispatch(sample, profile):
        c.analyze_edr(sample["md5"], profile)
        return c.wait_for_edr_completion(sample["md5"], profile, timeout=180)

    with ThreadPoolExecutor(max_workers=8) as pool:
        futures = {
            pool.submit(dispatch, s, p): (s["md5"], p)
            for s in uploaded for p in profiles
        }
        for fut in futures:
            md5, profile = futures[fut]
            result = fut.result()
            print(md5, profile, result["status"], result["summary"]["total_alerts"])

The Whiskers lock (single-occupancy) prevents two profiles from running on the same VM concurrently β€” but different profiles on different VMs run truly in parallel because Phase 2 doesn't hold the lock.


See also

πŸ“Œ LitterBox Β· self-hosted payload analysis sandbox

Release


πŸš€ Getting Started

πŸ“Š Pipelines & Pages

πŸ”¬ Scanners Β· 4 modules

πŸ›°οΈ EDR Integration
πŸ”Œ API & Clients
βš™οΈ Configuration & Dev

Releases Β· CHANGELOG Β· Issues Β· README

Clone this wiki locally