Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions limacharlie/commands/case_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,30 @@
to_stakeholder - Notes to stakeholders
from_stakeholder - Notes from stakeholders

Use --is-public to make the note visible to stakeholders (default: private).

Provide content via --content, --input-file, or stdin.

Examples:
limacharlie case add-note --case-number 42 --content "Initial triage complete"
limacharlie case add-note --case-number 42 --type analysis \\
--content "Confirmed C2 beacon to 10.0.0.1"
limacharlie case add-note --case-number 42 --is-public \\
--content "Status update for stakeholders"
echo "Handoff notes" | limacharlie case add-note --case-number 42 --type handoff
"""

_EXPLAIN_UPDATE_NOTE_VISIBILITY = """\
Toggle a note's public/private visibility. Public notes are visible
to stakeholders.

Requires the event ID of the note (from the case event timeline).

Examples:
limacharlie case update-note --case-number 42 --event-id <EID> --is-public
limacharlie case update-note --case-number 42 --event-id <EID> --no-is-public
"""

_EXPLAIN_BULK_UPDATE = """\
Batch update up to 200 cases at once. Provide case numbers as a
comma-separated list or via --input-file (one number per line or JSON
Expand Down Expand Up @@ -371,6 +386,15 @@
limacharlie case assignees
"""

_EXPLAIN_ORGS = """\
List organizations subscribed to ext-cases that the current user
can access. Useful for multi-org users to discover which orgs
have cases enabled.

Example:
limacharlie case orgs
"""

_EXPLAIN_EXPORT = """\
Export a case with all its components in a single JSON object.
Fetches the case record (with event timeline), linked detections,
Expand Down Expand Up @@ -434,6 +458,7 @@
register_explain("case.get", _EXPLAIN_GET)
register_explain("case.update", _EXPLAIN_UPDATE)
register_explain("case.add-note", _EXPLAIN_ADD_NOTE)
register_explain("case.update-note", _EXPLAIN_UPDATE_NOTE_VISIBILITY)
register_explain("case.bulk-update", _EXPLAIN_BULK_UPDATE)
register_explain("case.merge", _EXPLAIN_MERGE)
register_explain("case.entity.list", _EXPLAIN_ENTITY_LIST)
Expand All @@ -456,6 +481,7 @@
register_explain("case.config-get", _EXPLAIN_CONFIG_GET)
register_explain("case.config-set", _EXPLAIN_CONFIG_SET)
register_explain("case.assignees", _EXPLAIN_ASSIGNEES)
register_explain("case.orgs", _EXPLAIN_ORGS)
register_explain("case.export", _EXPLAIN_EXPORT)

_EXPLAIN_TAG_SET = """\
Expand Down Expand Up @@ -712,7 +738,7 @@ def _export_with_data(ctx: click.Context, t: Cases, data: dict[str, Any],
det_dir = os.path.join(output_dir, "detections")
os.makedirs(det_dir, exist_ok=True)
for det in detections:
det_id = det.get("detection_id")
det_id = det.get("detect_id")
if not det_id:
continue
try:
Expand Down Expand Up @@ -834,8 +860,10 @@ def update(ctx, case_number, status, severity, assignees, classification,
@click.option("--input-file", default=None, type=click.Path(exists=True), help="Read note content from file.")
@click.option("--type", "note_type", default=None, type=_NOTE_TYPE_CHOICES,
help="Note type (default: general).")
@click.option("--is-public/--no-is-public", default=None,
help="Make note visible to stakeholders (default: private).")
@pass_context
def add_note(ctx, case_number, content, input_file, note_type) -> None:
def add_note(ctx, case_number, content, input_file, note_type, is_public) -> None:
"""Add a note to a case.

Provide content via --content, --input-file, or stdin.
Expand All @@ -844,6 +872,8 @@ def add_note(ctx, case_number, content, input_file, note_type) -> None:
limacharlie case add-note --case-number 42 --content "Triage complete"
limacharlie case add-note --case-number 42 --type analysis \\
--content "Confirmed C2 beacon"
limacharlie case add-note --case-number 42 --is-public \\
--content "Status update for stakeholders"
echo "notes" | limacharlie case add-note --case-number 42
"""
if content:
Expand All @@ -857,7 +887,29 @@ def add_note(ctx, case_number, content, input_file, note_type) -> None:
raise click.UsageError("Provide content via --content, --input-file, or stdin.")

t = _get_cases(ctx)
data = t.add_note(case_number, text.strip(), note_type=note_type)
data = t.add_note(case_number, text.strip(), note_type=note_type, is_public=is_public)
_output(ctx, data)


# ---------------------------------------------------------------------------
# update-note
# ---------------------------------------------------------------------------

@group.command("update-note")
@click.option("--case-number", "case_number", required=True, type=int, help="Case number.")
@click.option("--event-id", required=True, help="Event ID of the note.")
@click.option("--is-public/--no-is-public", required=True,
help="Set note visibility for stakeholders.")
@pass_context
def update_note(ctx, case_number, event_id, is_public) -> None:
"""Toggle a note's public/private visibility.

Examples:
limacharlie case update-note --case-number 42 --event-id <EID> --is-public
limacharlie case update-note --case-number 42 --event-id <EID> --no-is-public
"""
t = _get_cases(ctx)
data = t.update_note_visibility(case_number, event_id, is_public)
_output(ctx, data)


Expand Down Expand Up @@ -1409,6 +1461,25 @@ def assignees(ctx) -> None:
_output(ctx, data)


# ---------------------------------------------------------------------------
# orgs
# ---------------------------------------------------------------------------

@group.command()
@pass_context
def orgs(ctx) -> None:
"""List organizations subscribed to ext-cases.

Returns OIDs the current user can access that have cases enabled.

Example:
limacharlie case orgs
"""
t = _get_cases(ctx)
data = t.list_orgs()
_output(ctx, data)


# ---------------------------------------------------------------------------
# tag (nested group)
# ---------------------------------------------------------------------------
Expand Down
42 changes: 41 additions & 1 deletion limacharlie/sdk/cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,18 +193,50 @@ def add_note(
case_number: int,
content: str,
note_type: str | None = None,
is_public: bool | None = None,
) -> dict[str, Any]:
"""Add a note to a case."""
"""Add a note to a case.

Args:
case_number: Case number.
content: Note content (max 8192 chars).
note_type: Note category (general, analysis, remediation,
escalation, handoff, to_stakeholder, from_stakeholder).
is_public: Whether the note is visible to stakeholders
(default false).
"""
body: dict[str, Any] = {"content": content}
if note_type:
body["note_type"] = note_type
if is_public is not None:
body["is_public"] = is_public
return self._request(
"POST",
f"cases/{case_number}/notes",
query_params={"oid": self.oid},
body=body,
)

def update_note_visibility(
self,
case_number: int,
event_id: str,
is_public: bool,
) -> dict[str, Any]:
"""Update a note's public visibility.

Args:
case_number: Case number.
event_id: The event ID of the note to update.
is_public: Whether the note is visible to stakeholders.
"""
return self._request(
"PATCH",
f"cases/{case_number}/notes/{event_id}",
query_params={"oid": self.oid},
body={"is_public": is_public},
)

def bulk_update(
self,
case_numbers: list[int],
Expand Down Expand Up @@ -593,3 +625,11 @@ def list_assignees(self) -> dict[str, Any]:
"assignees",
query_params={"oids": self.oid},
)

# ------------------------------------------------------------------
# Orgs
# ------------------------------------------------------------------

def list_orgs(self) -> dict[str, Any]:
"""List organizations subscribed to ext-cases that the caller can access."""
return self._request("GET", "orgs")
Loading