rapid7-insightidr 1.14.5: query_log_set can summarize; Static always does

summarize=true returns, over every matching event, each field's most frequent
values with counts, the first and last event time and five samples, instead of
the raw events. On a real run Static got 1000 raw events, could read about
ten, and reported the hosts and accounts of those ten; 300 events now fit in
about 1.5 KB. Static passes summarize always and quotes the returned window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RhL2gy6Sx6Y3FEsyBE3mZ
This commit is contained in:
2026-10-12 07:18:09 +00:00
parent 847f31ce17
commit 889cf660f6
3 changed files with 87 additions and 3 deletions
@@ -137,6 +137,78 @@ def _identify(events, fallback_log_id, detection_name):
return out
# What a summary keeps: the values an analyst reads first, per field.
_TOP_VALUES = 8
_MAX_FIELDS = 30
_SAMPLES = 5
# Rapid7's whole vendor payload, kilobytes per event: it stays out of a summary.
_SKIP_FIELDS = {"source_json", "source_data"}
def _fields(e):
"""One Log Search event as flat fields. `message` is a JSON-encoded string
on parsed sources; nested objects are flattened one level (a.b)."""
msg = e.get("message")
data = None
if isinstance(msg, str):
try:
data = json.loads(msg)
except ValueError:
data = None
elif isinstance(msg, dict):
data = msg
if not isinstance(data, dict):
return {"message": msg} if msg not in (None, "") else {}
flat = {}
for k, v in data.items():
if k in _SKIP_FIELDS:
continue
if isinstance(v, dict):
for k2, v2 in v.items():
if not isinstance(v2, (dict, list)) and v2 not in (None, ""):
flat[k + "." + k2] = v2
elif not isinstance(v, list) and v not in (None, ""):
flat[k] = v
return flat
def _summarize(events, limit):
"""Counts instead of lines: per field, its most frequent values.
An agent asked who a user logged on to does not need a thousand raw lines —
on a real run it got 1000 events, could read about ten, and reported the
accounts and hosts of those ten. Top values per field answer the question
over the whole window and fit in a model's context.
"""
counts = {}
first_ts, last_ts = None, None
for e in events:
ts = e.get("timestamp")
if isinstance(ts, (int, float)):
first_ts = ts if first_ts is None or ts < first_ts else first_ts
last_ts = ts if last_ts is None or ts > last_ts else last_ts
for k, v in _fields(e).items():
counts.setdefault(k, {})
key = str(v)[:200]
counts[k][key] = counts[k].get(key, 0) + 1
# Fields that vary least first: they are the ones that describe the activity
# (result, service, destination) rather than identify a line (timestamps, ids).
ranked = sorted(counts.items(), key=lambda kv: (len(kv[1]), kv[0]))
top = {}
for k, vals in ranked[:_MAX_FIELDS]:
top[k] = {"distinct": len(vals),
"top": [[v, n] for v, n in sorted(vals.items(), key=lambda x: -x[1])[:_TOP_VALUES]]}
return {
"count": len(events),
"truncated": len(events) >= limit,
"first_event": _iso(first_ts) if first_ts is not None else None,
"last_event": _iso(last_ts) if last_ts is not None else None,
"fields": top,
"fields_omitted": max(0, len(counts) - _MAX_FIELDS),
"samples": [_fields(e) for e in events[:_SAMPLES]],
}
def _log_sets():
"""Every log set of the account, as (id, name)."""
out = request("GET", "log_search/management/logsets")
@@ -210,8 +282,14 @@ def run():
first = request("GET", "log_search/query/logsets/" + urllib.parse.quote(log_set_id, safe=""), params=params)
# No fallback log id: a log set spans several logs and every event names its own.
events = _identify(_collect(first, limit), "", inp.get("detection_name"))
window = "%s to %s" % (_iso(start_time), _iso(end_time))
if str(inp.get("summarize") or "").strip().lower() in ("1", "true", "yes"):
out = _summarize(events, limit)
out.update({"from": start_time, "to": end_time, "window": window})
print(json.dumps(out))
return
print(json.dumps({"events": events, "count": len(events), "from": start_time, "to": end_time,
"window": "%s to %s" % (_iso(start_time), _iso(end_time))}))
"window": window}))
try: