From 9be2fed828b465707988f3f5901b8d410a094c90 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sat, 28 Mar 2026 10:56:38 -0700 Subject: [PATCH 1/2] feat: update cases SDK/CLI to match ext-cases data model changes Align the Python SDK and CLI with the latest ext-cases backend: - Remove `acknowledged` and `escalated` case statuses (now: new, in_progress, resolved, closed) - Simplify status transitions to match new state machine - Replace `assignee` (singular) with `assignees` (array) on update - Remove `escalation_group` and `investigation_id` fields - Standardize evidence fields across entities, telemetry, artifacts: use `note` + `verdict` instead of name/context/first_seen/last_seen (entities), event_summary/relevance (telemetry), description (artifacts) - Artifact add now requires `path` + `source` (artifact_type is optional) - Add `to_stakeholder` and `from_stakeholder` note types - Update all explain texts, docstrings, and tests Co-Authored-By: Claude Opus 4.6 (1M context) --- limacharlie/commands/case_cmd.py | 165 +++++++++++++------------------ limacharlie/sdk/cases.py | 104 ++++++++++++++----- tests/unit/test_cli_case.py | 42 ++++---- tests/unit/test_sdk_cases.py | 44 ++++----- 4 files changed, 190 insertions(+), 165 deletions(-) diff --git a/limacharlie/commands/case_cmd.py b/limacharlie/commands/case_cmd.py index 9e46f911..09e3d9cb 100644 --- a/limacharlie/commands/case_cmd.py +++ b/limacharlie/commands/case_cmd.py @@ -36,7 +36,7 @@ and pagination. Filters (all repeatable/comma-separated): - --status new, acknowledged, in_progress, escalated, resolved, closed + --status new, in_progress, resolved, closed --severity critical, high, medium, low, info --classification pending, true_positive, false_positive --assignee filter by assignee email @@ -57,7 +57,7 @@ Examples: limacharlie case list - limacharlie case list --status new,acknowledged --severity critical,high + limacharlie case list --status new,in_progress --severity critical,high limacharlie case list --assignee alice@example.com --sort severity limacharlie case list --search "mimikatz" --limit 20 limacharlie case list --tag phishing --tag urgent @@ -80,29 +80,25 @@ omitted fields are left untouched. Updatable fields: - --status new, acknowledged, in_progress, escalated, - resolved, closed (state machine enforced) + --status new, in_progress, resolved, closed + (state machine enforced) --severity critical, high, medium, low, info - --assignee email of the assignee + --assignees assignee emails (repeatable for multiple) --classification pending, true_positive, false_positive - --escalation-group arbitrary group name for escalation routing - --investigation-id link to a LimaCharlie investigation --summary investigation findings (max 8192 chars) --conclusion root cause & remediation (max 8192 chars) --tag add/replace tags (repeatable; see case tag subcommand) Status transitions follow a state machine: - new -> acknowledged, in_progress, escalated, closed - acknowledged -> in_progress, escalated, closed - in_progress -> escalated, resolved, closed - escalated -> in_progress, resolved, closed - resolved -> closed, in_progress (reopen) - closed -> (terminal) + new -> in_progress, closed + in_progress -> resolved, closed + resolved -> closed + closed -> in_progress (reopen) Examples: - limacharlie case update --id 42 --status acknowledged + limacharlie case update --id 42 --status in_progress limacharlie case update --id 42 --severity high - limacharlie case update --id 42 --assignee alice@example.com + limacharlie case update --id 42 --assignees alice@example.com limacharlie case update --id 42 --status resolved \\ --classification true_positive \\ --conclusion "Contained via network isolation" @@ -113,11 +109,13 @@ investigation workflows. Note types: - general - General notes (default) - analysis - Threat analysis findings - remediation - Remediation steps taken or recommended - escalation - Escalation context and reasoning - handoff - Shift handoff notes + general - General notes (default) + analysis - Threat analysis findings + remediation - Remediation steps taken or recommended + escalation - Escalation context and reasoning + handoff - Shift handoff notes + to_stakeholder - Notes to stakeholders + from_stakeholder - Notes from stakeholders Provide content via --content, --input-file, or stdin. @@ -143,13 +141,14 @@ _EXPLAIN_MERGE = """\ Merge multiple source cases into a single target case. This -copies all investigation content (entities, telemetry, artifacts, -notes) from the source cases into the target and marks the source -cases with status 'merged' (terminal). +moves detections from the source cases into the target and closes +the source cases (with merged_into_case_id set). This is useful for consolidating duplicate cases created from related detections. +Target case must not be closed. Source cases must not be closed. + Examples: limacharlie case merge --target 10 --sources 11,12,13 """ @@ -172,8 +171,7 @@ Add an entity (IOC) to a case. Duplicate type+value pairs on the same case are rejected (409). -Required: --type and --value. Optional: --name, --verdict, --context, ---first-seen, --last-seen (RFC3339 timestamps). +Required: --type and --value. Optional: --note, --verdict. Entity values are normalized (lowercased) for IP, domain, hash, and email types. @@ -183,17 +181,17 @@ --type ip --value "10.0.0.1" --verdict malicious limacharlie case entity add --case 42 \\ --type hash --value "d41d8cd98f00b204e9800998ecf8427e" \\ - --verdict suspicious --context "Found in startup folder" + --verdict suspicious --note "Found in startup folder" """ _EXPLAIN_ENTITY_UPDATE = """\ Update an existing entity on a case. -Updatable fields: --name, --verdict, --context, --first-seen, --last-seen. +Updatable fields: --note, --verdict. Example: limacharlie case entity update --case 42 --entity-id \\ - --verdict malicious --context "Confirmed C2 server" + --verdict malicious --note "Confirmed C2 server" """ _EXPLAIN_ENTITY_REMOVE = """\ @@ -227,7 +225,7 @@ JSON object via --event. The backend automatically extracts routing.this (atom), routing.sid, and routing.event_type. -Optional: --event-summary, --verdict, --relevance. +Optional: --note, --verdict. Example: limacharlie case telemetry add --case 42 \\ @@ -237,7 +235,7 @@ _EXPLAIN_TELEMETRY_UPDATE = """\ Update a telemetry reference on a case. -Updatable fields: --event-summary, --verdict, --relevance. +Updatable fields: --note, --verdict. Example: limacharlie case telemetry update --case 42 \\ @@ -263,15 +261,15 @@ _EXPLAIN_ARTIFACT_ADD = """\ Add a forensic artifact reference to a case. -The --type field is free-form (e.g., pcap, memory_dump, disk_image, -log_export). Optional: --description, --verdict. +Required: --path and --source. Optional: --type, --note, --verdict. Examples: limacharlie case artifact add --case 42 \\ - --type pcap --description "Network capture during incident" + --path "/captures/incident-01.pcap" --source "sensor-01" \\ + --type pcap --note "Network capture during incident" limacharlie case artifact add --case 42 \\ - --type memory_dump --verdict suspicious \\ - --description "Process memory from PID 1234" + --path "/dumps/pid1234.dmp" --source "edr-collection" \\ + --type memory_dump --verdict suspicious """ _EXPLAIN_ARTIFACT_REMOVE = """\ @@ -513,7 +511,7 @@ def _get_cases(ctx: click.Context) -> Cases: # --------------------------------------------------------------------------- _STATUS_CHOICES = click.Choice( - ["new", "acknowledged", "in_progress", "escalated", "resolved", "closed"], + ["new", "in_progress", "resolved", "closed"], case_sensitive=False, ) _SEVERITY_CHOICES = click.Choice( @@ -529,7 +527,8 @@ def _get_cases(ctx: click.Context) -> Cases: case_sensitive=False, ) _NOTE_TYPE_CHOICES = click.Choice( - ["general", "analysis", "remediation", "escalation", "handoff"], + ["general", "analysis", "remediation", "escalation", "handoff", + "to_stakeholder", "from_stakeholder"], case_sensitive=False, ) _ENTITY_TYPE_CHOICES = click.Choice( @@ -619,7 +618,7 @@ def list_cases(ctx, status, severity, classification, assignee, search, Examples: limacharlie case list - limacharlie case list --status new --status acknowledged + limacharlie case list --status new --status in_progress limacharlie case list --severity critical --severity high limacharlie case list --search "mimikatz" --limit 20 limacharlie case list --tag phishing --tag urgent @@ -783,24 +782,22 @@ def _export_with_data(ctx: click.Context, t: Cases, data: dict[str, Any], @click.option("--id", "case_number", required=True, type=int, help="Case number.") @click.option("--status", default=None, type=_STATUS_CHOICES, help="New status.") @click.option("--severity", default=None, type=_SEVERITY_CHOICES, help="Case severity (critical, high, medium, low, info).") -@click.option("--assignee", default=None, help="Assignee email.") +@click.option("--assignees", multiple=True, help="Assignee email (repeatable for multiple assignees).") @click.option("--classification", default=None, type=_CLASSIFICATION_CHOICES, help="Classification.") -@click.option("--escalation-group", default=None, help="Escalation group name.") -@click.option("--investigation-id", default=None, help="LC investigation ID to link.") @click.option("--summary", default=None, help="Investigation summary (max 8192 chars).") @click.option("--conclusion", default=None, help="Root cause & remediation (max 8192 chars).") @click.option("--tag", multiple=True, help="Set tags (replaces all existing tags; repeat for multiple).") @pass_context -def update(ctx, case_number, status, severity, assignee, classification, - escalation_group, investigation_id, summary, conclusion, tag) -> None: +def update(ctx, case_number, status, severity, assignees, classification, + summary, conclusion, tag) -> None: """Update a case. Only provided fields are changed. Examples: - limacharlie case update --id 42 --status acknowledged + limacharlie case update --id 42 --status in_progress limacharlie case update --id 42 --severity high - limacharlie case update --id 42 --assignee alice@example.com + limacharlie case update --id 42 --assignees alice@example.com limacharlie case update --id 42 --status resolved \\ --classification true_positive limacharlie case update --id 42 --tag phishing --tag urgent @@ -808,15 +805,14 @@ def update(ctx, case_number, status, severity, assignee, classification, fields = { "status": status, "severity": severity, - "assignee": assignee, "classification": classification, - "escalation_group": escalation_group, - "investigation_id": investigation_id, "summary": summary, "conclusion": conclusion, } # Filter out None values fields = {k: v for k, v in fields.items() if v is not None} + if assignees: + fields["assignees"] = list(assignees) if tag: fields["tags"] = list(tag) if not fields: @@ -942,7 +938,7 @@ def _parse_number_list(numbers_str: str | None, input_file: str | None) -> list[ def merge(ctx, target, sources) -> None: """Merge source cases into a target case. - Investigation content is copied; source cases become 'merged'. + Detections are moved to the target; source cases are closed. Example: limacharlie case merge --target 10 --sources 11,12 @@ -987,27 +983,22 @@ def entity_list(ctx, case) -> None: @click.option("--case", required=True, type=int, help="Case number.") @click.option("--type", "entity_type", required=True, type=_ENTITY_TYPE_CHOICES, help="Entity type.") @click.option("--value", "entity_value", required=True, help="Entity value (max 1024 chars).") -@click.option("--name", default=None, help="Display name.") +@click.option("--note", default=None, help="Analyst note (max 2048 chars).") @click.option("--verdict", default=None, type=_VERDICT_CHOICES, help="Verdict assessment.") -@click.option("--context", default=None, help="Context notes (max 4096 chars).") -@click.option("--first-seen", default=None, help="First seen timestamp (RFC3339).") -@click.option("--last-seen", default=None, help="Last seen timestamp (RFC3339).") @pass_context -def entity_add(ctx, case, entity_type, entity_value, name, verdict, - context, first_seen, last_seen) -> None: +def entity_add(ctx, case, entity_type, entity_value, note, verdict) -> None: """Add an entity to a case. Examples: limacharlie case entity add --case 42 \\ --type ip --value "10.0.0.1" --verdict malicious limacharlie case entity add --case 42 \\ - --type hash --value "d41d8..." --context "In startup folder" + --type hash --value "d41d8..." --note "In startup folder" """ t = _get_cases(ctx) data = t.add_entity( case, entity_type, entity_value, - name=name, verdict=verdict, context=context, - first_seen=first_seen, last_seen=last_seen, + note=note, verdict=verdict, ) _output(ctx, data) @@ -1015,30 +1006,21 @@ def entity_add(ctx, case, entity_type, entity_value, name, verdict, @entity_group.command("update") @click.option("--case", required=True, type=int, help="Case number.") @click.option("--entity-id", required=True, help="Entity ID to update.") -@click.option("--name", default=None, help="Display name.") +@click.option("--note", default=None, help="Analyst note (max 2048 chars).") @click.option("--verdict", default=None, type=_VERDICT_CHOICES, help="Verdict assessment.") -@click.option("--context", default=None, help="Context notes (max 4096 chars).") -@click.option("--first-seen", default=None, help="First seen timestamp (RFC3339).") -@click.option("--last-seen", default=None, help="Last seen timestamp (RFC3339).") @pass_context -def entity_update(ctx, case, entity_id, name, verdict, context, - first_seen, last_seen) -> None: +def entity_update(ctx, case, entity_id, note, verdict) -> None: """Update an entity on a case. Example: limacharlie case entity update --case 42 \\ --entity-id --verdict malicious """ - fields = { - "name": name, "verdict": verdict, "context": context, - "first_seen": first_seen, "last_seen": last_seen, - } - fields = {k: v for k, v in fields.items() if v is not None} - if not fields: + if note is None and verdict is None: raise click.UsageError("Provide at least one field to update.") t = _get_cases(ctx) - data = t.update_entity(case, entity_id, **fields) + data = t.update_entity(case, entity_id, note=note, verdict=verdict) _output(ctx, data) @@ -1104,12 +1086,10 @@ def telemetry_list(ctx, case) -> None: @click.option("--case", required=True, type=int, help="Case number.") @click.option("--event", "event_json", required=True, help="Full LC event JSON object.") -@click.option("--event-summary", default=None, help="Human-readable event summary.") +@click.option("--note", default=None, help="Analyst note (max 2048 chars).") @click.option("--verdict", default=None, type=_VERDICT_CHOICES, help="Verdict assessment.") -@click.option("--relevance", default=None, help="Relevance notes (max 1024 chars).") @pass_context -def telemetry_add(ctx, case, event_json, event_summary, verdict, - relevance) -> None: +def telemetry_add(ctx, case, event_json, note, verdict) -> None: """Link a telemetry event to a case. Example: @@ -1126,8 +1106,7 @@ def telemetry_add(ctx, case, event_json, event_summary, verdict, t = _get_cases(ctx) data = t.add_telemetry( case, event, - event_summary=event_summary, - verdict=verdict, relevance=relevance, + note=note, verdict=verdict, ) _output(ctx, data) @@ -1135,29 +1114,21 @@ def telemetry_add(ctx, case, event_json, event_summary, verdict, @telemetry_group.command("update") @click.option("--case", required=True, type=int, help="Case number.") @click.option("--telemetry-id", required=True, help="Telemetry reference ID.") -@click.option("--event-summary", default=None, help="Human-readable event summary.") +@click.option("--note", default=None, help="Analyst note (max 2048 chars).") @click.option("--verdict", default=None, type=_VERDICT_CHOICES, help="Verdict assessment.") -@click.option("--relevance", default=None, help="Relevance notes (max 1024 chars).") @pass_context -def telemetry_update(ctx, case, telemetry_id, event_summary, verdict, - relevance) -> None: +def telemetry_update(ctx, case, telemetry_id, note, verdict) -> None: """Update a telemetry reference on a case. Example: limacharlie case telemetry update --case 42 \\ --telemetry-id --verdict malicious """ - fields = { - "event_summary": event_summary, - "verdict": verdict, - "relevance": relevance, - } - fields = {k: v for k, v in fields.items() if v is not None} - if not fields: + if note is None and verdict is None: raise click.UsageError("Provide at least one field to update.") t = _get_cases(ctx) - data = t.update_telemetry(case, telemetry_id, **fields) + data = t.update_telemetry(case, telemetry_id, note=note, verdict=verdict) _output(ctx, data) @@ -1206,24 +1177,28 @@ def artifact_list(ctx, case) -> None: @artifact_group.command("add") @click.option("--case", required=True, type=int, help="Case number.") -@click.option("--type", "artifact_type", required=True, +@click.option("--path", required=True, help="Artifact path or location.") +@click.option("--source", required=True, help="Artifact source identifier.") +@click.option("--type", "artifact_type", default=None, help="Artifact type (e.g., pcap, memory_dump, disk_image, log_export).") -@click.option("--description", default=None, help="Description (max 2048 chars).") +@click.option("--note", default=None, help="Analyst note (max 2048 chars).") @click.option("--verdict", default=None, type=_VERDICT_CHOICES, help="Verdict assessment.") @pass_context -def artifact_add(ctx, case, artifact_type, description, verdict) -> None: +def artifact_add(ctx, case, path, source, artifact_type, note, verdict) -> None: """Add a forensic artifact reference to a case. Examples: limacharlie case artifact add --case 42 \\ - --type pcap --description "Network capture" + --path "/captures/incident.pcap" --source "sensor-01" \\ + --type pcap --note "Network capture" limacharlie case artifact add --case 42 \\ + --path "/dumps/mem.dmp" --source "edr" \\ --type memory_dump --verdict suspicious """ t = _get_cases(ctx) data = t.add_artifact( - case, artifact_type, - description=description, verdict=verdict, + case, path, source, + artifact_type=artifact_type, note=note, verdict=verdict, ) _output(ctx, data) diff --git a/limacharlie/sdk/cases.py b/limacharlie/sdk/cases.py index 5a65436d..41d07232 100644 --- a/limacharlie/sdk/cases.py +++ b/limacharlie/sdk/cases.py @@ -146,8 +146,8 @@ def get_case(self, case_number: int) -> dict[str, Any]: def update_case(self, case_number: int, **fields: Any) -> dict[str, Any]: """Update a case. - Accepted fields: status, severity, assignee, classification, - escalation_group, investigation_id, summary, conclusion, tags. + Accepted fields: status, severity, assignees, classification, + summary, conclusion, tags. Note: detection-level fields (detection_id, detection_cat, detection_source, detection_priority, sensor_id, hostname) @@ -268,14 +268,28 @@ def add_entity( case_number: int, entity_type: str, entity_value: str, - **fields: Any, + *, + note: str | None = None, + verdict: str | None = None, ) -> dict[str, Any]: - """Add an entity/IOC to a case.""" + """Add an entity/IOC to a case. + + Args: + case_number: Case number. + entity_type: One of ip, domain, hash, url, user, email, + file, process, registry, other. + entity_value: Entity value (max 1024 chars). + note: Analyst note (max 2048 chars). + verdict: Verdict assessment. + """ body: dict[str, Any] = { "entity_type": entity_type, "entity_value": entity_value, } - body.update({k: v for k, v in fields.items() if v is not None}) + if note is not None: + body["note"] = note + if verdict is not None: + body["verdict"] = verdict return self._request( "POST", f"cases/{case_number}/entities", @@ -287,14 +301,28 @@ def update_entity( self, case_number: int, entity_id: str, - **fields: Any, + *, + note: str | None = None, + verdict: str | None = None, ) -> dict[str, Any]: - """Update an entity on a case.""" + """Update an entity on a case. + + Args: + case_number: Case number. + entity_id: Entity ID to update. + note: Analyst note (max 2048 chars). + verdict: Verdict assessment. + """ + body: dict[str, Any] = {} + if note is not None: + body["note"] = note + if verdict is not None: + body["verdict"] = verdict return self._request( "PATCH", f"cases/{case_number}/entities/{entity_id}", query_params={"oid": self.oid}, - body={k: v for k, v in fields.items() if v is not None}, + body=body, ) def remove_entity( @@ -342,9 +370,8 @@ def add_telemetry( case_number: int, event: dict, *, - event_summary: str | None = None, + note: str | None = None, verdict: str | None = None, - relevance: str | None = None, ) -> dict[str, Any]: """Link a telemetry event reference to a case. @@ -353,17 +380,14 @@ def add_telemetry( event: Full LC event dict. The backend extracts routing.this (atom), routing.sid, and routing.event_type automatically. - event_summary: Human-readable event summary. + note: Analyst note (max 2048 chars). verdict: Verdict assessment. - relevance: Relevance notes. """ body: dict[str, Any] = {"event": event} - if event_summary is not None: - body["event_summary"] = event_summary + if note is not None: + body["note"] = note if verdict is not None: body["verdict"] = verdict - if relevance is not None: - body["relevance"] = relevance return self._request( "POST", f"cases/{case_number}/telemetry", @@ -375,14 +399,28 @@ def update_telemetry( self, case_number: int, telemetry_id: str, - **fields: Any, + *, + note: str | None = None, + verdict: str | None = None, ) -> dict[str, Any]: - """Update a telemetry reference on a case.""" + """Update a telemetry reference on a case. + + Args: + case_number: Case number. + telemetry_id: Telemetry reference ID. + note: Analyst note (max 2048 chars). + verdict: Verdict assessment. + """ + body: dict[str, Any] = {} + if note is not None: + body["note"] = note + if verdict is not None: + body["verdict"] = verdict return self._request( "PATCH", f"cases/{case_number}/telemetry/{telemetry_id}", query_params={"oid": self.oid}, - body={k: v for k, v in fields.items() if v is not None}, + body=body, ) def remove_telemetry( @@ -412,12 +450,30 @@ def list_artifacts(self, case_number: int) -> dict[str, Any]: def add_artifact( self, case_number: int, - artifact_type: str, - **fields: Any, + path: str, + source: str, + *, + artifact_type: str | None = None, + note: str | None = None, + verdict: str | None = None, ) -> dict[str, Any]: - """Add a forensic artifact reference to a case.""" - body: dict[str, Any] = {"artifact_type": artifact_type} - body.update({k: v for k, v in fields.items() if v is not None}) + """Add a forensic artifact reference to a case. + + Args: + case_number: Case number. + path: Artifact path or location. + source: Artifact source identifier. + artifact_type: Optional artifact type (e.g., pcap, memory_dump). + note: Analyst note (max 2048 chars). + verdict: Verdict assessment. + """ + body: dict[str, Any] = {"path": path, "source": source} + if artifact_type is not None: + body["artifact_type"] = artifact_type + if note is not None: + body["note"] = note + if verdict is not None: + body["verdict"] = verdict return self._request( "POST", f"cases/{case_number}/artifacts", diff --git a/tests/unit/test_cli_case.py b/tests/unit/test_cli_case.py index 71e2697d..e0057c1d 100644 --- a/tests/unit/test_cli_case.py +++ b/tests/unit/test_cli_case.py @@ -248,13 +248,13 @@ def test_list_multiple_statuses(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "list", "--status", "new", "--status", "acknowledged"], + ["case", "list", "--status", "new", "--status", "in_progress"], mock_t_cls, return_value={"cases": [], "total_counts": {}}, ) assert result.exit_code == 0 call_kwargs = mock_t.list_cases.call_args[1] - assert call_kwargs["status"] == ["new", "acknowledged"] + assert call_kwargs["status"] == ["new", "in_progress"] def test_list_invalid_status_rejected(self): runner = CliRunner() @@ -576,12 +576,12 @@ def test_update_status(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "update", "--id", "42", "--status", "acknowledged"], + ["case", "update", "--id", "42", "--status", "in_progress"], mock_t_cls, return_value={"case": {}}, ) assert result.exit_code == 0 - mock_t.update_case.assert_called_once_with(42, status="acknowledged") + mock_t.update_case.assert_called_once_with(42, status="in_progress") def test_update_multiple_fields(self): p1, p2, p3 = _patch_cases() @@ -590,7 +590,7 @@ def test_update_multiple_fields(self): ["case", "update", "--id", "42", "--status", "resolved", "--classification", "true_positive", - "--assignee", "bob@example.com"], + "--assignees", "bob@example.com"], mock_t_cls, return_value={"case": {}}, ) @@ -599,7 +599,7 @@ def test_update_multiple_fields(self): 42, status="resolved", classification="true_positive", - assignee="bob@example.com", + assignees=["bob@example.com"], ) def test_update_no_fields_error(self): @@ -794,8 +794,7 @@ def test_entity_add(self): assert result.exit_code == 0 mock_t.add_entity.assert_called_once_with( 42, "ip", "10.0.0.1", - name=None, verdict="malicious", context=None, - first_seen=None, last_seen=None, + note=None, verdict="malicious", ) def test_entity_add_invalid_type_rejected(self): @@ -816,7 +815,7 @@ def test_entity_update(self): return_value={}, ) assert result.exit_code == 0 - mock_t.update_entity.assert_called_once_with(42, "eid-1", verdict="benign") + mock_t.update_entity.assert_called_once_with(42, "eid-1", note=None, verdict="benign") def test_entity_update_no_fields_error(self): p1, p2, p3 = _patch_cases() @@ -888,8 +887,7 @@ def test_telemetry_add(self): assert result.exit_code == 0 mock_t.add_telemetry.assert_called_once_with( 42, json.loads(self._SAMPLE_EVENT), - event_summary=None, - verdict="suspicious", relevance=None, + note=None, verdict="suspicious", ) def test_telemetry_add_requires_event(self): @@ -913,17 +911,16 @@ def test_telemetry_add_with_all_optional_fields(self): result, mock_t = _invoke( ["case", "telemetry", "add", "--case", "42", "--event", self._SAMPLE_EVENT, - "--event-summary", "Process spawned", - "--verdict", "malicious", - "--relevance", "Key evidence"], + "--note", "Process spawned, key evidence", + "--verdict", "malicious"], mock_t_cls, return_value={}, ) assert result.exit_code == 0 mock_t.add_telemetry.assert_called_once_with( 42, json.loads(self._SAMPLE_EVENT), - event_summary="Process spawned", - verdict="malicious", relevance="Key evidence", + note="Process spawned, key evidence", + verdict="malicious", ) def test_telemetry_update(self): @@ -936,7 +933,7 @@ def test_telemetry_update(self): return_value={}, ) assert result.exit_code == 0 - mock_t.update_telemetry.assert_called_once_with(42, "tel-1", verdict="malicious") + mock_t.update_telemetry.assert_called_once_with(42, "tel-1", note=None, verdict="malicious") def test_telemetry_update_no_fields_error(self): p1, p2, p3 = _patch_cases() @@ -981,15 +978,16 @@ def test_artifact_add(self): with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( ["case", "artifact", "add", "--case", "42", - "--type", "pcap", "--description", "Network capture", + "--path", "/captures/test.pcap", "--source", "sensor-01", + "--type", "pcap", "--note", "Network capture", "--verdict", "suspicious"], mock_t_cls, return_value={}, ) assert result.exit_code == 0 mock_t.add_artifact.assert_called_once_with( - 42, "pcap", - description="Network capture", verdict="suspicious", + 42, "/captures/test.pcap", "sensor-01", + artifact_type="pcap", note="Network capture", verdict="suspicious", ) def test_artifact_remove(self): @@ -1351,14 +1349,14 @@ def test_update_with_tags_and_status(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "update", "--id", "1", "--status", "acknowledged", + ["case", "update", "--id", "1", "--status", "in_progress", "--tag", "phishing"], mock_t_cls, return_value={"case": {}}, ) assert result.exit_code == 0 mock_t.update_case.assert_called_once_with( - 1, status="acknowledged", tags=["phishing"], + 1, status="in_progress", tags=["phishing"], ) diff --git a/tests/unit/test_sdk_cases.py b/tests/unit/test_sdk_cases.py index ea69cf6a..77638339 100644 --- a/tests/unit/test_sdk_cases.py +++ b/tests/unit/test_sdk_cases.py @@ -175,7 +175,7 @@ def test_minimal(self, cases, mock_org): def test_all_filters(self, cases, mock_org): mock_org.client.request.return_value = {"cases": []} cases.list_cases( - status=["new", "acknowledged"], + status=["new", "in_progress"], severity=["critical"], classification=["pending"], assignee="alice@example.com", @@ -188,7 +188,7 @@ def test_all_filters(self, cases, mock_org): _, kwargs = _extract_call(mock_org) qp = kwargs["query_params"] assert qp["oids"] == "test-oid" - assert qp["status"] == "new,acknowledged" + assert qp["status"] == "new,in_progress" assert qp["severity"] == "critical" assert qp["classification"] == "pending" assert qp["assignee"] == "alice@example.com" @@ -233,17 +233,17 @@ def test_path_and_query(self, cases, mock_org): class TestUpdateCase: def test_patch_with_fields(self, cases, mock_org): mock_org.client.request.return_value = {"case": {}} - cases.update_case(42, status="acknowledged", assignee="bob@example.com") + cases.update_case(42, status="in_progress", assignees=["bob@example.com"]) args, kwargs = _extract_call(mock_org) assert args == ("PATCH", "api/v1/cases/42") assert kwargs["query_params"] == {"oid": "test-oid"} body = json.loads(kwargs["raw_body"]) - assert body == {"status": "acknowledged", "assignee": "bob@example.com"} + assert body == {"status": "in_progress", "assignees": ["bob@example.com"]} assert kwargs["content_type"] == "application/json" def test_none_fields_excluded(self, cases, mock_org): mock_org.client.request.return_value = {"case": {}} - cases.update_case(42, status="resolved", assignee=None, classification=None) + cases.update_case(42, status="resolved", assignees=None, classification=None) body = _extract_body(mock_org) assert body == {"status": "resolved"} @@ -385,27 +385,21 @@ def test_all_optional_fields(self, cases, mock_org): mock_org.client.request.return_value = {} cases.add_entity( 42, "hash", "abc123", - name="Evil hash", + note="Found in startup", verdict="malicious", - context="Found in startup", - first_seen="2026-01-01T00:00:00Z", - last_seen="2026-01-02T00:00:00Z", ) body = _extract_body(mock_org) assert body["entity_type"] == "hash" assert body["entity_value"] == "abc123" - assert body["name"] == "Evil hash" + assert body["note"] == "Found in startup" assert body["verdict"] == "malicious" - assert body["context"] == "Found in startup" - assert body["first_seen"] == "2026-01-01T00:00:00Z" - assert body["last_seen"] == "2026-01-02T00:00:00Z" def test_none_optional_fields_excluded(self, cases, mock_org): mock_org.client.request.return_value = {} - cases.add_entity(42, "domain", "evil.com", verdict=None, context=None) + cases.add_entity(42, "domain", "evil.com", verdict=None, note=None) body = _extract_body(mock_org) assert "verdict" not in body - assert "context" not in body + assert "note" not in body class TestUpdateEntity: @@ -473,15 +467,13 @@ def test_with_optional_fields(self, cases, mock_org): mock_org.client.request.return_value = {} cases.add_telemetry( 42, self._SAMPLE_EVENT, - event_summary="Suspicious process", + note="Suspicious process, related to C2", verdict="suspicious", - relevance="Related to C2", ) body = _extract_body(mock_org) assert body["event"] == self._SAMPLE_EVENT - assert body["event_summary"] == "Suspicious process" + assert body["note"] == "Suspicious process, related to C2" assert body["verdict"] == "suspicious" - assert body["relevance"] == "Related to C2" class TestUpdateTelemetry: @@ -519,20 +511,24 @@ def test_path_and_query(self, cases, mock_org): class TestAddArtifact: def test_required_fields(self, cases, mock_org): mock_org.client.request.return_value = {} - cases.add_artifact(42, "pcap") + cases.add_artifact(42, "/captures/test.pcap", "sensor-01") body = _extract_body(mock_org) - assert body["artifact_type"] == "pcap" + assert body["path"] == "/captures/test.pcap" + assert body["source"] == "sensor-01" def test_with_optional_fields(self, cases, mock_org): mock_org.client.request.return_value = {} cases.add_artifact( - 42, "memory_dump", - description="Process memory", + 42, "/dumps/mem.dmp", "edr-collection", + artifact_type="memory_dump", + note="Process memory", verdict="suspicious", ) body = _extract_body(mock_org) + assert body["path"] == "/dumps/mem.dmp" + assert body["source"] == "edr-collection" assert body["artifact_type"] == "memory_dump" - assert body["description"] == "Process memory" + assert body["note"] == "Process memory" assert body["verdict"] == "suspicious" From 4bf1cef8417ae62f6c5a6f95f4b5f963ac0e7c73 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sat, 28 Mar 2026 11:29:26 -0700 Subject: [PATCH 2/2] fix: send 'sid' query param instead of 'sensor_id' for case list filter The ext-cases backend reads q.Get("sid") not q.Get("sensor_id"). The old param name was silently ignored, making --sid filtering a no-op. Co-Authored-By: Claude Opus 4.6 (1M context) --- limacharlie/sdk/cases.py | 2 +- tests/unit/test_sdk_cases.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/limacharlie/sdk/cases.py b/limacharlie/sdk/cases.py index 41d07232..2cfd7401 100644 --- a/limacharlie/sdk/cases.py +++ b/limacharlie/sdk/cases.py @@ -122,7 +122,7 @@ def list_cases( if search: qp["search"] = search if sensor_id: - qp["sensor_id"] = sensor_id + qp["sid"] = sensor_id if tag: qp["tag"] = ",".join(tag) if sort: diff --git a/tests/unit/test_sdk_cases.py b/tests/unit/test_sdk_cases.py index 77638339..b3571a3b 100644 --- a/tests/unit/test_sdk_cases.py +++ b/tests/unit/test_sdk_cases.py @@ -211,14 +211,14 @@ def test_sensor_id_filter(self, cases, mock_org): cases.list_cases(sensor_id="abc-sensor-123") _, kwargs = _extract_call(mock_org) qp = kwargs["query_params"] - assert qp["sensor_id"] == "abc-sensor-123" + assert qp["sid"] == "abc-sensor-123" def test_sensor_id_none_omitted(self, cases, mock_org): mock_org.client.request.return_value = {"cases": []} cases.list_cases(sensor_id=None) _, kwargs = _extract_call(mock_org) qp = kwargs["query_params"] - assert "sensor_id" not in qp + assert "sid" not in qp class TestGetCase: