Commit Graph

268 Commits

Author SHA1 Message Date
f3nris d3c85cf332 rapid7-insightidr 1.11.1: strip whitespace from comma-separated filter params
"HIGH, CRITICAL" as typed sent " CRITICAL" to the API, which rejects
the whole call — a priority filter written naturally fetched nothing.
Normalize statuses/priorities/sources/tags in list_investigations,
get_investigations_full and search_investigation_alerts before the URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1fWBoEKvAw3k78XDkpRQo
2026-09-11 18:28:15 +00:00
f3nris ab63ead00e docs(manifest): document the per-command category
A command can now declare what it is FOR, and Riposte's agent browses the estate
by it: enrich, hunt, inspect, transform, collect, contain, remediate, identity,
notify, ticket, manage.

It is a second axis to the manifest-level `category`, which says what the
product is. All 101 of HarfangLab's commands are "endpoint"; the analyst looking
for a decoder and the one looking for an isolation are not looking in the same
place, and the product axis cannot separate them.

Optional, and the template says why it is worth writing anyway: a command that
declares nothing is classified by Riposte from its id and description, which is
a guess. A value outside the list is ignored and the command classified instead,
so a typo costs the declaration rather than inventing a twelfth category that
nothing else ever lands in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8cMrB8KPHVpL47rmKynDQ
2026-09-10 14:26:09 +00:00
f3nris a26e71f29e fix(sentinelone): make the process tree draw itself (v1.15.0)
The block reads actor.process.file.name and would have rendered the chain,
but mapperHasProcessChain — the test that places it when a layout is
generated from a mapper — wants actor.process.name alongside the parent's.
This mapper declared only the file name, so the block was never placed and
the chain went unseen on every SentinelOne incident.

process.file.name now feeds both, and certSubject feeds the node's signer
badge (the block reads file.company_name for it, and certSubject is the only
signing identity a unified alert carries).

Verified by running processTree.ts itself over this mapper's output on the
live ClickFix alert: placement true, and a two-node chain explorer.exe ->
powershell.exe carrying the host, the user, both paths and both sets of
hashes when the fetch ran with_details. Without it the chain still draws,
with the parent named but unhashed — which is all the list nodes say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 12:42:28 +00:00
f3nris da2fcbba6c fix(sentinelone): map the unified alert as a real one is shaped (v1.14.0)
Checked against a live STAR alert, three of the details mappings were wrong
about where the data actually sits.

Its alert-level observables list is empty while all twelve observables sit
under the indicators, so the mapper saw none. The two lists are appended now
rather than reading the alert level alone.

The same alert has process.username null while its indicators name the user
(src.process.user), and names its parent process without hashing it anywhere
else. actor.user.name and the parent process path and hashes are read from the
indicator observables, with the alert's own field winning where it is filled
in, so nothing regresses on an alert that does populate it.

attacks and relatedAlerts are guarded on $count rather than $exists: that
alert carries "attacks": null, and null exists in JSONata — the entry would
have written an empty array onto every alert without an ATT&CK mapping.

Verified by running the whole mapper through the scheduler's own jsonata-go
build on that payload, with and without with_details: 95 entries, no empty
arrays, and the plain list fetch still falls back to the fileName/fileHash
observable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 12:38:21 +00:00
f3nris 31e596d770 feat(sentinelone): with_details on the unified alerts fetch (v1.13.0)
The list nodes carry no indicator, no ATT&CK mapping, no observable and no
related alert — everything that says why the console fired, and the analyst
opens the alert in SentinelOne to find it. with_details reads the per-alert
view for every alert the fetch returns and folds it into the alert, so the
incident is created with that context instead of a title and a severity.

Read in aliased batches of ten, so a default-limit fetch costs ten extra
requests rather than a hundred. Merged under the list node — the list is the
authority on the fields both carry, the detail only fills in — so the richer
list asset/process survive the merge and gain process.username and file.size.
Best effort: an alert the console will not detail keeps its list fields and
the shortfall is reported in pagination.details, rather than failing a fetch
whose alerts are already in hand.

Off by default, and verified query-identical to 1.12.0 for a rule that does
not set it.

The mapper gains evidences, finding_info.attacks, finding_info.related_events
and the full observables list. Each is guarded with $exists and wrapped in [ ]
— checked against the jsonata-go build the scheduler uses: the brackets keep a
single indicator from collapsing into a bare object, and the guard leaves the
entry undefined on a plain list fetch so it cannot write an empty array over
the file summary those alerts do carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 12:32:49 +00:00
f3nris fe564cec2c feat(sentinelone): account_ids filter on get_unified_alerts (v1.12.0)
get_threats and get_alerts have taken an accountIds filter since 1.4.0, so a
multi-tenant console could be ingested one account at a time. The unified
alerts could not, which left a tenant-per-rule setup unable to move off the
REST commands.

An alert carries its account twice — the scope it sits in now
(realTime.scope.account.id) and the one it was detected in
(detectionTime.scope.accountId). The first is what an operator means by "this
account", so it is filtered on first, degrading to the second and then to a
filter over the fetched alerts if the console rejects the fieldId, the same
ladder the analytic category uses. pagination.accountFilter reports which of
the three ran. Client-side, an alert matches on either of its two accounts.

The query is unchanged for a rule that does not set the new input, verified
against 1.11.0 on the inputs an existing alert rule sends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 12:03:53 +00:00
f3nris 4cd1cda7cb feat(sentinelone): absorb the SentinelOne SDL package (v1.11.0)
The SDL package covered the same unified (UAM) alerts from a second
integration: one console, two packages, two instances, two tokens. Its
commands now live here, so it can be dropped.

get_unified_alerts gains the SDL filters — classification, attack_surface,
os_type, asset_id, external_id, search_text, unassigned_only,
unmitigated_only and a detectedAt window. status, severity, analyst_verdict
and analytics_category take comma-separated lists (stringEqual on one value,
stringIn on several), and every time input also reads a relative window
('24 hours') on top of ISO8601 and epochs. The query is byte-identical to
1.9.0's for the inputs an existing alert rule sends, apart from enum values
now being upper-cased — a lowercase severity used to filter nothing.

Three capabilities the SDL package had and this one did not:
get_unified_alert_details (indicators, their MITRE mapping, observables,
related alerts), add_uam_alert_note and trigger_uam_alert_action (any console
action by ID). update_uam_alert_assignee completes the status/verdict pair,
on the same alertTriggerActions mutation.

analytics_category filters the fetch to one analytic category, e.g. only the
platform-rule alerts (analyticsCategory=Platform Rule, the console's own
filter). The SDL package showed this endpoint takes dotted fieldIds
(asset.osType, assignee.userId) and spells its sort argument differently, so
both spellings of each are tried before giving up: the category degrades to a
filter over the fetched alerts rather than failing the run, and
pagination.categoryFilter / .sortForm report which shape the console accepted.

update_uam_alert_status and _verdict now report a GraphQL error instead of
printing it as a successful update — a playbook that chained on a refused
update used to carry on as if it had gone through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EU1Rk9N6vNtp3q7YvLpjcc
2026-09-09 11:05:54 +00:00
f3nris 9081bc66fa feat(rapid7-insightidr): evidence rows for the table field (v1.11.0)
The Markdown summary reads as a page. It cannot be sorted or filtered, and an
investigation aggregating several alerts, each with several evidences, is
exactly where those are wanted: "Multiple User Accounts Were Disabled" is a
question about which accounts, and the answer is a column.

So one row per evidence, across every alert, into custom:rapid7_evidence. The
table field reads an array of objects as it stands, so nothing is reshaped on
the mapper side. It sits beside the summary rather than replacing it — one is
read, the other is queried.

Two decisions worth stating.

Each row carries its alert's name first. An investigation aggregates several,
and a row that does not say where it came from cannot be acted on.

Column order is load-bearing, because it is the only ordering signal the field
has: it takes the union of keys in order of first appearance, so whatever the
first row names first is what an analyst sees without scrolling. Known keys
lead — action, result, the source and target pairs, the host, the addresses —
and the rest follow alphabetically.

Width is capped at 30 columns, decided by how many rows carry each key rather
than by which appeared first: an investigation mixing rule types has a sparse
union, and a column present on one row out of eighty would otherwise push out
one present on all of them. Exercised on a 187-column case — the five keys
common to every row all survived, and the count is reported as
evidence_columns_dropped rather than the loss being silent.

Verified against the account-disabled investigation: two evidences become two
rows of twenty columns, `alert` first, the two disabled accounts in
target_account — checked through the table field's own reader, not just the
generator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:57:10 +02:00
f3nris b26257aaeb feat(rapid7-insightidr): render the detection as Markdown, and fix four gaps (v1.10.0)
An "Multiple User Accounts Were Disabled" investigation came back with one
entity — the actor — while the layout stayed nearly empty. Four separate
reasons, and the last one is why the first three keep recurring.

  evidences was an OBJECT, not an array. JSONata returns a bare object when the
  path yields one item, so the key had one shape on an investigation with a
  single alert and another with two. Bracketed to force an array.

  Only the actor was mapped. An ad_admin rule names who acted AND who it was
  done to, and on this alert the disabled accounts are the point of it. Target
  account, target user and both domains now map to the OCSF Target User group,
  the actor to Actor — the distinction that lets the sentence be read in one
  line.

  The domain controller was nowhere. A Windows event puts it in
  source_json.computerName and nowhere else, and source_json was skipped whole
  because the Azure AD one runs to kilobytes. Its top-level SCALARS are now
  read — bounded, and where computerName, osVersion and callerIpAddress live —
  while its nested part stays skipped.

  source_asset was the string "unknown". Mapped as-is it becomes an asset named
  "unknown" in the entity index, shared by every incident that had no asset: a
  pivot that groups things by the absence of a fact. Placeholders are dropped,
  and a key whose value cleans away to nothing is removed rather than kept
  empty, since "" is not nil and would still be written to its OCSF field.

Five entities now, from one: both accounts, both display names, the DC.

And the reason this kept happening. The layout builder's field picker is a
closed <select> over the OCSF catalogue in web/src/lib/ocsf.ts, so a path absent
from it cannot be placed on a layout at all — which is what `detection` and
`evidence` are, and what every unanticipated key will be. Mapping each one to a
real OCSF field is right where a field exists, but rule_keys_of_interest is an
open vocabulary: four spellings turned up in as many days.

So the whole of it is also rendered as a Markdown table into
custom:rapid7_summary — keys of interest first, since the rule itself says
those are what matter, then the evidence fields. Declare a custom field of type
markdown with that name, put it on the layout, and a new rule type is readable
the day it appears instead of after another mapping round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 18:04:14 +02:00
f3nris e19530d3f3 fix(rapid7-insightidr): read the evidence in both of its shapes (v1.9.2)
evidence_fields came back {} on a live run. The evidence was fetched, the run
was clean — one call, 1.3s, no error — and the parser found nothing in it.

`data` does not have one shape. The parser was written against an unparsed
Fortigate source and looked only for {source_data, custom_data}. A PARSED
source has no such wrapper: the event is the object.

  {user, account, source_ip, geoip_country_code, geoip_organization,
   result, service, user_agent, source_json{…}, r7_context{…},
   detection_context{…}}

Every one of those is what the incident was missing, sitting one json.loads
away, and the parser walked past all of it and returned an empty dict. No error
— which is how it survived a run that reported success.

Now both are read as a union rather than one or the other: scalars at the top
level are always taken, so a third shape still yields its plain fields instead
of nothing, and the wrapped keys are overlaid when present. source_json is
skipped — several kilobytes of vendor payload that stay in the raw line —
while r7_context and detection_context are flattened one level, being Rapid7's
own annotations and the same across parsed sources. They carry the account age
in minutes, which is exactly what says whether a sign-in is from a new account.

The Azure AD sign-in now maps to src_endpoint.ip, .isp_org and
.location.country, actor.user.name/.uid/.account.name, http_request.user_agent
and metadata.product.feature.name, and indexes the address and the account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:19:08 +02:00
f3nris 2d6e097742 fix(rapid7-insightidr): explain the process-tree 404 (v1.9.1)
Running the command on an alert reports `{"error": "HTTP 404", "detail": ""}`.
The API returns 404 with an empty body, so the operator is told a number and
nothing else — and cannot tell a normal answer from a fault.

It is usually normal, and the URL is not at fault: it is built exactly as
get_alert_evidence builds its own, which works. Rapid7 uses the same 404 for
three unrelated situations and does not distinguish them:

  - the alert is not an endpoint detection, so no tree exists. This is the
    common case on a feed of ingress_auth, asset_auth or firewall events;

  - the tree exists in principle but was never generated. The endpoint returns
    the CACHED tree unless force_refresh is set, so an ungenerated tree answers
    404 while its data is there. Retrying with force_refresh=true is the first
    thing to try on an endpoint detection;

  - the key lacks the privilege.

All three are now named in the error, and the force_refresh input says what it
is really for rather than describing it only as a staleness control.

The ingestion path already handled this correctly — it treats 404 as "no tree"
and counts only other statuses as errors — so nothing changes there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:56:23 +02:00
f3nris 1888648930 feat(rapid7-insightidr): feed the layout Process Tree block (v1.9.0)
Rapid7 exposes a process tree per alert; the layout renderer has a block that
draws one. They do not speak the same shape.

The block reads ctx.process_tree as N FLAT chains, root first, each
{alert_id, rule, host, user, nodes[]} — it merges shared ancestors itself.
Rapid7 answers with a NESTED tree: one root entity and children[]. So the
command walks each root-to-leaf path into its own chain, maps the node fields
(cmdline -> cmd_line, hashes.md5 -> md5, start_time -> started_at, pid coerced
from the string Rapid7 sends), and rolls every alert's chains up onto the
investigation, since the block reads the key at the top level and no mapper
expression reaches past alerts[0].

Verified through the block's own reader: a tree of explorer -> cmd ->
{bcdedit, vssadmin} flattens to two chains and comes back as one tree with the
ancestors shared and both leaves marked.

That mark is worth stating plainly. The block marks the last node of a chain as
the target, and Rapid7 flags no node as the one the alert fired on — so here it
means "end of this branch" and nothing stronger. Naming a target would assert
something the sensor never said.

Gated, because this is a third call per alert on top of the evidence one:
include_process_tree=auto (the default) asks only for the event types an
endpoint sensor produces a tree for, true asks for every alert, false never.
An authentication or firewall alert never has one, and on a feed where those
dominate the default saves a call per alert. 404 is treated as "no tree", which
is what it means, and only other statuses are recorded as errors. Bounded at 12
chains and 24 levels so an unexpected shape cannot expand without limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:53:22 +02:00
f3nris 995a39ea66 feat(rapid7-insightidr): fetch and parse the evidence behind each alert (v1.8.0)
An alert names what fired. The evidence is what happened, and until now it was
never fetched — which is why an incident could show a rule name and nothing
else. On a Fortigate admin login the alert carried an empty
rule_keys_of_interest; the evidence carried the whole event.

It cannot be reached by mapping alone. The evidence's `data` is a
JSON-ENCODED STRING holding {source_data, custom_data}, and JSONata does not
deserialise, so no mapper expression can ever see inside it. The parsing has to
happen in the command, and that is the reason for this change more than the
extra call is.

Once parsed, two views of the same event:

  custom_data  the vendor's normalised fields, prefixed per source
               (Forti_user, Forti_srcip) — 6 keys on the sample
  source_data  the raw line in key="value" form — 25 keys

Both are merged into evidence_fields with the vendor prefix stripped, so a
mapper names `user` once instead of once per vendor, and source_data is overlaid
last because it carries more and is not affected by the quoting artefact below.

Values are cleaned, and that is not cosmetic: custom_data returns
'10.70.20.66"' with a trailing quote left by the vendor parser. Kept as-is it
yields an address matching no regex — an observable nobody can pivot on, and no
error to say why.

The line itself lands on raw_data and the source's own sentence on message,
both of which the layout builder offers. The Fortigate login now maps to
src_endpoint.ip, dst_endpoint.ip, actor.user.name, device.hostname/name/uid,
activity_name "login" and status_detail "success", and indexes four entities
where it indexed none.

Optional (include_evidence, default true) and capped (max_evidence_per_alert,
default 5), one call per alert under the run's existing time budget. An alert
whose evidence cannot be read keeps an evidence_error and still arrives;
evidence_calls and evidence_errors are reported in the run metadata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:46:17 +02:00
f3nris 45e9cbd269 feat: declare a risk tier on every command in the catalog
467 of the 1592 commands carried no `risk:`. An unset risk is read as
destructive by the agent approval gate, so half the catalog's write side sat
behind the same prompt — and, in HarfangLab's case, 39 pure lookups did too.
A gate that stops everything stops being read as a signal, which is the
argument already made for the toolkit and for sentinelone (df27876).

The catalog is now 1027 read, 261 safe_write, 304 destructive, with nothing
defaulted. The line follows the one sentinelone drew: impact on the estate,
not reversibility — isolate-agent is reversible and is the canonical
destructive example.

  read        only queries; changes nothing outside Riposte
  safe_write  additive or reversible inside the tool's own case/enrichment
              plane: comments, tickets, tags, alert statuses, threat-intel
              records, sandbox submissions, collection jobs, outbound messages
  destructive changes the estate or weakens a control: containment, blocklists
              and allowlists, exclusions and detection rules, every delete,
              identity and credential operations, code on a host, generic
              API escape hatches

Three calls that were not obvious, and are now applied throughout:

- Undoing a containment is tiered like the containment it lifts. Un-isolating
  a host or un-banning an IP mid-incident is its own decision, not a return to
  a neutral state. The catalog contradicted itself here — crowdstrike's
  lift_containment was destructive while harfanglab's deisolate_from_threat
  was safe_write — so deisolate_from_threat and unquarantine_file are
  re-tiered to destructive, the only two existing tiers this pass changes.
- Retrieving a live secret is destructive: cyberark get_password, delinea
  get_secret, vault read_secret, beyondtrust get_credential. It is a GET, but
  the tier describes the stake, not the HTTP verb.
- Sending outward to humans stays safe_write — slack, teams, twilio, telegram,
  mail. Irreversible, but it is the collaboration plane, not the estate.

An object created but not yet referenced by a rule is safe_write; editing one
a rule already reads is destructive. That is why fortigate create_address and
panos create_custom_url_category are safe_write while update_address_group and
edit_custom_url_category are not.

The template and README never documented the field, which is how 467 commands
came to omit it. Both now carry the tier table and these rules, so the next
integration declares a tier instead of inheriting one.

111 packages get a MINOR bump and a changelog entry: an instance compares
versions to offer an update, so an unbumped manifest would leave the tiers
sitting in the catalog and never reaching the estates that need them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 12:55:20 +02:00
f3nris c7569fd4d0 fix(rapid7-insightidr): map both ends of a connection (v1.7.0)
An "RDP connection to DC" investigation arrived with device {}, process {} and
two indexed entities, both of them false — the 32-hex tails of its own alert
RRNs, which the IOC regex cannot tell from an MD5. Its four real values were
sitting in the payload, unmapped:

  source_account             vbalbakakis
  destination_account        vassilisadmin
  source_asset_address       sgazswvdip-01.toto.priv
  destination_asset_address  scazsvpdc01.toto.priv

The mappings assumed an alert has one host and one user, which holds for a
process_start_event and not for an auth rule: a connection names both ends and
neither is "the host". So source and destination now land on src_endpoint and
dst_endpoint, the acting account on actor.user and the account acted upon on
user — the distinction that lets a lateral move be read in one line.

That choice is also what makes the fields placeable. The layout builder's field
picker is a closed <select> over the OCSF catalogue in web/src/lib/ocsf.ts;
a path absent from it cannot be put on a layout at all. All four targets are in
that catalogue. `detection`, the open-vocabulary catch-all, is not and cannot
be — it keeps its value in the Context tab, which renders the whole tree, but a
layout cannot reference it, so anything meant for a layout has to be mapped to a
real OCSF field first.

evidences[] is flattened at the same time. It emitted "device": {}, "process":
{} and "attacks": {"technique": {}} whenever those keys were absent — three
empty objects per alert, filling the context and hiding what was populated.

Verified against the RDP investigation: four entities where there were none,
two hosts and two accounts, and no false hash once the incident type's
extraction policy is set to selected/host,ip,user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 10:21:27 +02:00
f3nris eeebbfed3a fix(rapid7-insightidr): a partial region result is not a quiet period (v1.6.0)
An RRN states its region — rrn:alerts:eu:<org>:alert:… — while the base URL is
built from the instance's configured `region`. Following that thread found a
gap that matters more than the naming.

The Alert Triage API is multi-region behind one gateway, and eleven of its
endpoints can answer HTTP 200 with a region_failures list. The spec is explicit:
the presence of items in that field IS the partial failure. Three of the
commands here call such endpoints and all three read only the payload, so a
region the gateway could not reach produced fewer alerts and nothing else — no
error, no count, no log line. For an ingestion feed that is indistinguishable
from a quiet period, which is the one failure mode this integration has now hit
three separate times.

So region_failures is carried into the run metadata by every command that can
receive it, alongside the regions actually seen in the collected RRNs, the
configured region, and any mismatch between them.

The mismatch report is deliberately a report and not a correction. On a
single-region tenant the two always agree. On a multi-customer key, which spans
organizations, they need not — and rewriting the host per resource would be a
guess about how Rapid7 routes these calls, made without a tenant to verify it
against. Naming the discrepancy is what can be done honestly today; if it ever
fires, the metadata says so instead of the alerts silently going missing.

list_alert_fields already passed region_failures through untouched, since it
returns the API response as it stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 09:07:52 +02:00
f3nris 306450a4b6 fix(rapid7-insightidr): rule_keys_of_interest is an open vocabulary (v1.5.0)
Both mappers read that field through a hardcoded list of keys — hostname,
process.cmd_line, parent_process.*, user — taken from the one alert this work
was built against, a process_start_event detection. Every entry is a JSONata
predicate on an exact key, so a rule emitting anything else matches none of
them.

A real alert from a second tenant showed what that costs. It is a custom
ingress_auth rule, and its keys are:

  account             locuz_hma@totogroup.net
  source_ip           183.82.103.101
  geoip_country_code  IN
  result              SUCCESS

Not one appears in the hardcoded list. The alert was fetched, inlined and
mapped, and produced an incident as empty as the ones this integration set out
to fix — with the account and the source IP sitting in the payload, unread.

Two changes:

  - The whole list is flattened verbatim into `detection`, and rule_matching_keys
    into `detection_match`. Keys nobody anticipated now arrive intact, and they
    reach the AI analysis, which reads the mapped context and never `raw`.

  - account, source_ip, destination_ip, geoip_country_code and result gain
    explicit OCSF entries. The explicit mappings are not redundant with the
    flattening: a value under `detection.` is only ever seen by the IOC regex,
    so an account there would be indexed as an email, or not at all, rather than
    as a user. Typing an entity correctly still requires naming its field.

evidences[] carries the flattened keys too, so the same holds for the second and
third alert of an investigation, not only the first.

Verified against both rule shapes end to end. The process rule yields the two
hosts it did before; the authentication rule now yields the source IP and the
account, where it previously yielded nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 00:35:27 +02:00
f3nris 69e3a3e81c fix(rapid7-insightidr): do not send a bare UUID to /alerts/ops/rrns (v1.4.1)
The run died with HTTP 400 and eight "The provided RRN: [...] is invalid",
every one a plain UUID. Two mistakes compounded.

The alert list under an investigation is not consistent about where the RRN
sits. On the ABA alert this work was built against, `id` held the RRN. On
others it holds a bare UUID. The code read `id` first and `rrn` only as a
fallback, so it picked the UUID whenever both were present.

Worse, the failure was total rather than partial. /alerts/ops/rrns validates the
whole list and rejects the entire request when one entry is malformed, so a
single bad identifier costs every alert in that batch — and with one batch, the
whole run. An investigation feed that stops dead because one alert is shaped
differently is not an acceptable failure mode.

So: read `rrn` first, and drop anything that is not RRN-shaped before the batch
is sent, counting it as alert_ids_without_rrn in the run metadata. The
investigation still arrives, its retrievable alerts still arrive, and what could
not be fetched is a number an operator can see rather than an absence nobody
would notice.

Both commands that walk this list are fixed; the selector is shared and was
checked against the two real shapes plus the empty and null cases.

This turns a total failure into a partial one. It does NOT establish that those
eight alerts are reachable at all: if they carry no RRN anywhere, they are not
in the Alert Triage system and no field_ids or batch call will produce them.
That needs the raw shape of one, which the next run of list_investigation_alerts
on the failing investigation will show.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:52:51 +02:00
f3nris 841ae1199f feat(rapid7-insightidr): the investigation, with its alerts inlined (v1.4.0)
The previous command returned the alerts as its result rows, which quietly
changed the unit: an investigation holding three alerts would have become three
incidents. The investigation is what an analyst opens and what the SOC tracks —
it stays the incident, and the alerts are its content.

get_investigations_full walks the same three hops and inverts the shape:
investigations under `data`, each carrying its alerts under `alerts` plus an
alert_count. results_path=data and dedup_key=rrn, so it is a drop-in replacement
for list_investigations in an existing ingestion rule — only the command name
and the mapper change.

A few first-alert values are also lifted to the top level, and that is not
cosmetic. MapFields resolves dotted paths only and cannot index an array, so
`alerts[0].ai_suggested_disposition` in a field_mappings entry reads as nothing
— silently, leaving the custom field empty with no error anywhere. Three custom
fields were empty for exactly that reason until an end-to-end run showed it. The
ocsf section has no such limit: it is evaluated by full JSONata and indexes
freely, which is why the host and process mappings worked while the custom
fields did not.

The mapper maps the investigation first and the first alert after, so the alert
wins wherever the container says nothing. It also lands every alert — not just
alerts[0] — as OCSF-shaped objects under `evidences`, because the extractor
walks arrays and classifies on the path suffix: an investigation aggregating
two alerts on two machines now indexes both hosts, where reading alerts[0]
alone would have indexed one and silently dropped the other.

Verified end to end against the investigation that started this, with a second
alert added on another host: field mapping, OCSF mapping and entity extraction.
Both hosts are indexed. With the incident type's extraction policy set to
selected/host,ip,user, the entity set is exactly those two — the alert RRN's
32-hex suffix, which the IOC regex cannot tell from an MD5, is filtered out
there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:39:45 +02:00
f3nris 220fa75b99 feat(rapid7-insightidr): ingest only the alerts that belong to an investigation (v1.3.0)
Ingesting from search_alerts brings back every alert the tenant produces,
including the operational noise — two of the first three sampled were an agent
reporting it lacked Full Disk Access. What is worth an incident is what Rapid7
or the analyst folded into an investigation.

Filtering search_alerts on investigation_rrn does not work. The term is accepted
— no validation error, HTTP 200 — and matches nothing: zero alerts over fifteen
days, on a tenant where an alert carrying an investigation_rrn provably exists
in that window. The field is not searchable, and the API says so by returning an
empty page rather than by refusing the term. That failure mode is the reason not
to build an ingestion feed on it: a filter that silently stops matching drains
the feed to zero with no error anywhere, and nothing would ever say why.

search_investigation_alerts is guaranteed by construction instead:

  1. the investigations in the window (incremental on their created_time)
  2. their alert RRNs, one call per investigation — there is no bulk form
  3. the alerts themselves via /alerts/ops/rrns, batched 100 at a time

So 1+N+1 calls, and an alert with no investigation is never even named. Its
failure modes are loud: an unreadable investigation is skipped and counted, and
HTTP errors surface as themselves.

Step 2 is the one that grows with the window, so it runs under a time budget
(60s, against the server's 90s ceiling) and reports truncated=true rather than
being killed with the whole run's alerts lost. A partial fetch that says it is
partial beats a silent zero — the same reasoning that rules out the filter.

The output shape matches search_alerts exactly, so the mapper committed in 1.2.0
serves both. It gains five entries for the parent investigation, which resolve
to nothing on the search_alerts feed and are skipped there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:34:57 +02:00
f3nris 7e231c7c78 fix(rapid7-insightidr): name the input when terms_json will not parse (v1.2.1)
json.loads answers a bad value with "Expecting value: line 1 column 1 (char 0)",
which names neither the input at fault nor what it actually received. The
command has three string inputs; nothing in that message says which one broke,
so the operator is left guessing.

The mistake it hides is the obvious one. Documentation shows the input name and
the value together, so `terms_json: [{...}]` gets pasted whole into the value
field, and the parser then chokes on the field name it was handed. Echoing the
first 80 characters back makes that visible immediately.

Also carry a worked example in the manifest — the term that keeps only alerts
attached to an investigation, which is the filter this input mainly exists for —
and say explicitly to paste the array alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:31:27 +02:00
f3nris b6903d4590 feat(rapid7-insightidr): map alerts to OCSF, with real observables (v1.2.0)
An incident built from a Rapid7 investigation indexed zero entities. Nothing to
pivot on, nothing to correlate, and an AI analysis with nothing to read. The
reason turned out not to be the mapper but the feed: an investigation is an
envelope, and neither it nor its /alerts list carries a host, an account, a
process or a MITRE code.

The Alert Triage API does, in rule_keys_of_interest. On the alert behind the
investigation that started this, that field holds:

  hostname                            NBSGIT01-26
  process.name                        bcdedit.exe
  process.cmd_line                    bcdedit  /set {default} recoveryenabled No
  process.exe_path                    C:\WINDOWS\System32\bcdedit.exe
  parent_process.name                 cmd.exe
  parent_process.cmd_line             "C:\WINDOWS\system32\cmd.exe"
  parent_process.exe_file.hashes.md5  e463e36104d22a2479046a7d4f620816

with rule.mitre_tcodes ["TA0040", "T1490"] — Impact / Inhibit System Recovery,
which is what disabling Windows recovery actually is. None of it was reachable
before.

It is a list of {key, values} pairs rather than an object, so every entry reads
through a JSONata predicate — rule_keys_of_interest[key='hostname'].values[0].
A key a rule does not emit resolves to nothing and is skipped, so one file
covers every detection rule.

Two details worth keeping:

  - mitre_tcodes mixes tactics and techniques in one array ("TA0040" beside
    "T1490"). They are split on the "TA" prefix so a tactic never lands in the
    technique field, where it would name a technique that does not exist.

  - rule.name is documented but absent from live ABA alerts, so the analytic
    name falls back to `type`, which repeats the rule's display name.

Rapid7's own status, disposition and AI-suggested disposition ride as custom
fields. They cannot reach the incident's status and verdict columns — MapFields
accepts only title, severity, source, description and custom:* — but an analyst
can at least see that the source already closed the alert and what it decided.
The AI suggestion is exposed as a fact, never as a verdict.

Validated end to end against a live alert: field mapping, OCSF mapping and
entity extraction, which now yields the host and the parent process hash where
it previously yielded nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:07:53 +02:00
f3nris 157af87c17 fix(rapid7-insightidr): make list_alert_fields callable with no arguments (v1.1.1)
The bare call answered HTTP 400:

  root path with path depth greater than 1 is not supported for V2
  without a search filter

Sending neither path nor path_depth asks the V2 fields endpoint to walk the
whole hierarchy from the root, which it refuses. That is exactly the call an
operator makes first — open the command, run it, see what the tenant exposes —
so the one entry point into this API failed on its most obvious use.

Default path_depth to 1 when neither path nor search is given: the top-level
nodes come back, and `path` drills down from there. Also expose the `search`
query parameter, which was missing from the command entirely and which lifts
the depth restriction — it is the way to look through the whole tree at once
for a field whose branch you do not know yet (search=hostname).

Found by running the command against a live tenant. The 400 also settles the
open question from v1.1.0: the API answered a validation error rather than
403, so the Alert Triage API is reachable with this key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 22:37:48 +02:00
f3nris da44d41555 feat(rapid7-insightidr): add the Alert Triage API commands (v1.1.0)
An ingested investigation carried almost nothing: title, priority, status,
assignee and timestamps. Not a host, not an account, not a command line — so
no entity was ever indexed, and neither the pivot graph nor the AI analysis
had anything to read. Fetching the investigation's alerts did not help: that
endpoint answers with the rule name (already the investigation's title), the
alert source and an alert RRN, and nothing else.

The detection content lives in a different API. The Alert Triage API is served
under /idr/at — its own base, next to /idr/v1 and /idr/v2 — and it is where
the evidence, the actors, the MITRE T-codes and the process trees actually
are. It was missing from this package entirely.

Six read commands:

  search_alerts            POST /alerts/ops/search
  get_alert                GET  /alerts/{rrn}
  get_alert_evidence       GET  /alerts/{rrn}/evidences
  get_alert_actors         GET  /alerts/{rrn}/actors
  get_alert_process_trees  POST /alerts/{rrn}/process_trees/latest
  list_alert_fields        GET  /alerts/2/fields

search_alerts is the one that changes the shape of the feed. It returns full
alert objects — rule with its mitre_tcodes, rule_keys_of_interest, the
AI-suggested disposition, the parent investigation RRN, the status transition
timings and the log entries behind the alert — and its start_time is required
by the API, which makes it a natural incremental watermark. So the alerts can
be read directly, paginated, with no per-investigation fan-out.

Two things worth knowing before using them:

  - field_ids is not optional in practice. The API returns no additional
    fields when it is empty, and those additional fields are where the
    hostnames, accounts and command lines are. Their ids are per-tenant dotted
    paths (process.cmd_line, …), which is what list_alert_fields is for.
    /alerts/fields is deprecated, hence /alerts/2/fields.

  - the whole API is gated on Managed Detection and Response with access to
    the Alerts experience. A key that works on every investigation endpoint
    can still be refused here, so 401 and 403 are reported with that named
    explanation rather than as a bare status code — a licence boundary should
    not read as a bug.

Written against the published OpenAPI spec (SIEM Alerts API 39.10.0); not yet
exercised against a live tenant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 22:31:12 +02:00
f3nris df27876a8f feat(sentinelone): declare a risk tier on every command (v1.9.0)
An unset risk is treated as destructive by the agent approval gate. Only
test_connection carried one, so the other 83 commands — get-threats,
get-alerts, count-agents, every lookup in the package — sat behind the same
gate as isolate-agent and run-remote-script. A gate that stops everything
stops being read as a signal.

The package was the marketplace's worst on this: 2 of 86 commands annotated,
where all 180 packages declare some and 57 declare them throughout. The next
weakest is panos at 2 of 10.

39 read, 22 safe_write, 24 destructive. The line drawn for destructive is
impact on the estate, not reversibility — isolate-agent is reversible and is
the canonical destructive example:

- what removes detection or protection: creating an exclusion, disabling or
  editing a STAR rule (an edit can silently narrow a rule to match nothing)
- what adds blocking: the hash blocklist, a firewall rule
- what changes containment in either direction: isolate and reconnect both,
  since undoing containment mid-incident is its own decision
- what reboots or shuts a host down: shutdown, uninstall, enable-agent (it
  takes an optional reboot), move-agent (it changes the applied policy)
- every delete, including removals from the blocklist and the exclusions

Authoring detection content in a customer console is a change to that
console, so the whole STAR rule lifecycle is gated: create as well as update,
disable and delete. Draft status is one click from live and does not earn a
lower tier.

Additive threat intel stays safe_write: creating an IOC or enabling an
existing STAR rule widens what the console sees, which is not the direction
that needs a gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 16:26:05 +02:00
f3nris fd4b1b18f7 feat(sentinelone): fetch the unified (UAM) alerts (v1.8.0)
The package could already write to a unified alert — update its status and
its analyst verdict — but never read one. The only fetch was get_alerts,
which reads /cloud-detection/alerts: a different object, covering only the
detections the STAR and custom rules raise. Everything SentinelOne surfaces
through the console-wide alert model — cloud, identity, third-party — had
no way into an incident.

get_unified_alerts reads the unifiedalerts GraphQL endpoint, alongside the
two mutations already pointed at it. Notes on the shape it returns:

- edges/node is flattened to a plain data[]. The envelope carries no id of
  its own, so a rule dedup'ing on a top-level id would key every alert on
  nothing; flattened, the ingest block matches the REST commands in this
  package (results_path: data, dedup_key: id).
- Sorted ascending, so a run truncated by the limit leaves the watermark on
  the oldest unseen alert instead of skipping everything behind the newest.
- created_after is normalised from ISO8601, epoch seconds or epoch
  milliseconds: the scheduler injects the watermark raw, in whichever of the
  three formats the rule was configured with. The script builds the GraphQL
  filter itself, so the incremental input stays a plain timestamp.
- A GraphQL error arrives as HTTP 200 with an errors array. Left unread, an
  unusable query would look like an endless run of empty, successful fetches.

The mapper covers 74 fields. Its severity branch ends on the raw value
rather than a number, so an alert with no severity — or a level SentinelOne
adds later — falls through to the rule's default instead of being asserted
Informational on data that never said so. A UAM alert also carries fields
with no honest OCSF home (assignee, ticketId, the kubernetes controllers);
they are fetched and left in the raw payload rather than forced into an
unrelated field.

The package default ingestion still points at get_threats: the new command
is opt-in from the Collect editor, so no existing instance changes what it
collects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 16:16:46 +02:00
f3nris 857d2b8d57 feat(cortex-xdr): map the blocked process and its chain (v1.5.0)
action_process_image_* — the process an alert is about, the child a
prevention module blocked — was mapped nowhere in get_incidents_full, so
its command line existed only inside raw. It fills process.*, the group
the SentinelOne and Carbon Black mappers reserve for the process acted
upon. Its ancestry lands in process.lineage as OCSF Process objects,
root-first; a generation XDR leaves null drops out instead of showing as
an empty level.

Every alert's chain also travels under a process_tree context key, not an
OCSF field: process.lineage is the ancestry of ONE process, and an XDR
incident groups N alerts each with its own chain. Nodes carry their role
because the chain is positional — action_process_instance_id is null and
the *_causality_id fields name the causality group, not the node.

Also mapped: actor pid and MD5, the causality actor's command line,
endpoint FQDN/domain/MAC/OS build, protection module, agent version,
alert id, the incident id as the shared correlation key, activity kind,
and the registry/DNS/user-agent fields other alert kinds carry.
action_country is dropped when it is the literal UNKNOWN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 00:13:50 +02:00
f3nris ad1fa1559b feat(jira): list the saved filters, so one can be referenced by id
A saved Jira filter could always be ingested: "filter = 12345" is ordinary JQL,
and the fetch's own created >= watermark clause sits on top of it happily. What
was missing was the id. Nothing in the product named the filters an instance
holds, so the only way to reach one was to open Jira, find the filter, and read
the number out of the browser's address bar — which is enough friction that the
workflow may as well not exist, and the alternative is retyping a JQL query the
SOC already maintains in Jira.

list_filters returns each filter's id, name, JQL and owner, plus jql_reference:
the clause to paste, already written. Cloud reads filter/search, which pages
and can match a name server-side; Data Center reads filter/favourite, present
across versions where filter/search is not, and pages and matches locally. Both
response shapes — a paginated envelope, a bare array — were checked against
what the API actually returns.

get_incidents and query_issues now say a filter can go in their query box, and
which command hands over the id.
2026-09-02 17:32:35 +02:00
f3nris b9822f310d docs(jira): say what to type in each command field
Every input carried a description, and most of them restated the field name:
"Comment ID" for comment_id, "Board ID" for board_id, "JQL query string" for
query. That is a label, not help. An operator filling get_incidents for the
first time has no way to learn from it that the fetch appends its own
created >= watermark clause and an ORDER BY, that writing either breaks it,
that the watermark box is filled by the alert rule rather than by hand, or that
a run makes a single call and silently drops whatever exceeds max.

103 input fields rewritten to answer two questions instead: what goes in this
box, and where do I get it. Formats are shown by example rather than named —
2026-09-30 rather than "yyyy-mm-dd", soc,phishing rather than "comma-separated"
— and every field holding an id now names the command that returns it:
get_id_by_attribute for an assignee, board_list for a board, get_comments for
a comment, get_issue for an attachment, get_link_types for a link type and for
which issue belongs on which side of it.

Fields that replace rather than append say so, since labels and components
quietly drop whatever is left out.
2026-09-02 13:56:28 +02:00
f3nris 2354ab0a00 fix(jira): name the credential that failed instead of returning a bare 401
A personal access token silently outranked Basic credentials. Anything left in
pat — an email pasted into the wrong field, a token from a decommissioned Data
Center — became the Authorization header, and Atlassian answered "Client must
be authenticated to access this resource." That sentence names no field, so the
search starts at the URL, the API version, the token itself: everywhere except
the one field that caused it.

Basic authentication now wins whenever username and api_key are both set, and
pat serves the rest, so a token left behind cannot outrank credentials someone
has just filled in. A lone username, or a lone api_key, is not a pair and does
not claim the request. A 401 or 403 says which method was used and whether the
other was configured and ignored.

Three configurations that cannot work are refused with the reason rather than
sent to Jira:

  - no usable credential, where an empty username/api_key pair was being
    base64-encoded into a valid-looking header;
  - the Cloud gateway URL with an empty cloud_id, which addresses no site, so
    every path under it is a dead link and Jira replies with an HTML 404 that
    explains nothing;
  - a cloud_id set against any other host, where it is not an address at all,
    only a path segment that does not exist.

Credentials are stripped before use: one pasted with a trailing newline
corrupts the header and reads back as a wrong password.

Error bodies are condensed instead of dumped. Jira's 404 page is four hundred
lines of AUI markup, stylesheet links and inline scripts around one sentence,
and it was going into the test modal whole; what surfaces now is the <title>
and the readable text, capped. A 2xx that is not JSON — a login page, an SSO
redirect, a proxy standing in for the API — reports what answered and the URL
the redirects landed on, in place of a JSON parser offset.

Config descriptions now say which fields pair with which deployment, and which
credential is used when both are present.

Applied to all 27 scripts; the seven shared helper blocks stay identical across
them.
2026-09-02 12:49:26 +02:00
f3nris 643290b733 feat(crowdstrike): fetch incrementally via a created_from watermark (v1.3.0)
search_detections had one input for everything, `filter`, so an alert rule
configured for incremental fetch had nowhere to put its watermark but
there — where it REPLACED the FQL with a bare timestamp instead of
narrowing it. The API rejects that outright (HTTP 400, "invalid query
filter in request"), and even had it not, the product/type scoping would
have been silently discarded with it. The only working configuration left
was a full fetch on every run.

created_from is that missing input, following the shape get_alerts already
uses on sentinelone: a timestamp of its own, declared as the command's
incremental_field so the alert-rule editor offers it. The script folds it
into the FQL as created_timestamp, which is where this API expresses a time
bound. filter now carries the scoping only — the two no longer compete.

Surrounding quotes on the value are stripped: FQL wants them, and an
operator filling the first-fetch date by hand may reasonably include them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 22:47:46 +02:00
f3nris 21b957be72 fix(crowdstrike): map severity and the MTTD anchor in search_detections (v1.2.4)
Two silent defects in the search_detections OCSF mapper.

Severity was a four-branch ternary that resolved to nothing, so every
ingested alert kept the alert rule's default severity instead of Falcon's
— a Low arrived as Medium and a Critical arrived as Medium too. It is now
a plain path to severity_name, whose words the ingestion engine reads
natively. (The ternary form is also repaired upstream, but a plain path is
simpler to read and to correct.)

No entry fed the OCSF `time` field, which is the only field the engine
reads to stamp detected_at. Incidents were anchored at ingestion instead
of at detection, so MTTD measured the collection delay as zero — over a
day on a slow poll. finding_info.created_time carries the same instant but
is read by nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 22:06:45 +02:00
f3nris e8b2166d30 feat(cortex-xdr): query the data lake in XQL (v1.4.0)
Four commands reach the XQL engine: cortex-xdr-xql-query runs a query and
waits for its rows, cortex-xdr-xql-start-query and
cortex-xdr-xql-get-query-results split that same work in two when the query
outlives a command, and cortex-xdr-xql-get-quota says what the day's hunting
has already cost — an XQL query is billed by the data it scans.

Three things the API makes easy to get wrong are handled here.

Past 1000 rows it stops answering with results and hands over a stream id
instead: gzipped JSON lines, unpacked into rows rather than dropped on the
playbook as an archive it cannot read.

A query carrying no limit stage is served the tenant's own default, so one is
appended (100 rows, configurable) unless the query already has its own. The
reference client does the same on a looser substring test, which also fires on
a field whose name merely contains "limit".

Waiting is a budget, not a promise: an integration command is killed at 60
seconds, so a query still running when the budget expires comes back PENDING
with its execution_id instead of failing, and the results command collects the
rows afterwards. Re-running the query would be charged against the quota a
second time — which is why the pending answer names the command to call rather
than inviting a retry.

The relative window is parsed here (24 hours, 7 days, 3 months) since the
scripts have no dateparser; an unreadable one is refused before the query is
started, rather than silently widening to the tenant default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:15:44 +02:00
f3nris 9f9ee5344d fix(cortex-xdr): put the actor where the other EDR mappers put it
HarfangLab and SentinelOne both send the process that acted to actor.* —
process.username and sourceProcessInfo.user to actor.user.name, the process
itself to actor.process.* — and keep user.name for the person the event
happened to: HarfangLab's top_impacted_users, SentinelOne's login account.

Cortex XDR filled neither slot correctly. XDR labels the acting process
actor_process_* and its account actor_effective_username; the mappers sent
the process to process.* and the account to user.name, which the OCSF
catalogue calls Target User. A rule written on actor.user.name or
actor.process.name matched HarfangLab and SentinelOne and skipped Cortex,
and a rule reading user.name as the victim read the attacker.

process.* is not free-for-all either: in the SentinelOne (targetProcessInfo)
and Carbon Black (childproc_*) mappers it holds the process acted upon, so
Cortex's actor was sitting in the target's seat.

get_incidents_full had the collision in the open: the incident's impacted
users[] and the first alert's user_name both wrote user.name, and last
non-empty wins, so the actor overwrote the impacted user on every incident
carrying both.

Tenants using the shipped templates pick this up on the next sync; a forked
mapper, or a column or rule pointed at the old Cortex paths, needs repointing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:03:06 +02:00
f3nris bf273b959a feat(cortex-xdr): watermark incidents on modification_time, not creation
An XDR incident is not finished when it is created. Alerts keep joining it, an
analyst changes its status, its severity is raised. A creation_time watermark
fetches it once, the watermark moves past it, and nothing that happens
afterwards ever reaches Riposte — which is precisely the content the full fetch
exists to bring in.

modified_after filters and sorts on modification_time instead, so an incident
comes back on every change and dedup on incident_id turns the second visit into
an enrichment of the incident already there. It is now what the ingest hint
prefills; created_after stays for a one-shot backfill.

Worth knowing about that enrichment: it merges context and can fill a detection
anchor that was missing, but it does not restate the incident's severity or
status. An incident XDR later raises to critical stays at the severity it was
ingested with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:50:24 +02:00
f3nris ba68d19e51 refactor(cortex-xdr): name the full fetch get-incidents-full (v1.3.1)
"fetch" said nothing next to a list of commands that all start with "get". The
command sits beside cortex-xdr-get-incidents in the picker, and the only thing
an operator needs to read there is which of the two carries everything — so the
name says it: get-incidents-full.

The id moves with it (fetch_incidents -> get_incidents_full), since the script
and the bundled mapper are bound to a command by filename. Anyone who created a
rule against the old id in the few minutes 1.3.0 was up has to point it at the
new command; the changelog says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:47:17 +02:00
f3nris a6e1245c79 feat(cortex-xdr): fetch incidents with their alerts, not a 21-field summary
incidents/get_incidents/ answers with a summary and nothing else: 21 fields,
no hosts, no users, no MITRE, no tags, and not one of the alerts the incident
aggregates. Ingesting through it leaves an incident whose raw payload says
almost nothing about what happened — and the shipped mapper had been written
for a richer shape than the endpoint ever returns, mapping hosts[0], users[0]
and mitre_* that simply are not in that response.

incidents/get_multiple_incidents_extra_data/ returns the same incidents with
39 fields, every alert in full — 156 fields each — and the file and network
artifacts. It is what the reference client fetches through (demisto/content,
CortexXDRIR.get_multiple_incidents_extra_data), and full_alert_fields must be
set or the nested alerts come back trimmed to a handful of fields.

Records arrive as {incident, alerts, network_artifacts, file_artifacts} with
each nested block wrapped as {total_count, data}. The script flattens them, so
every expression written against get_incidents keeps working — the summary's
21 fields are a subset of these 39 — while the alerts and artifacts land beside
them as plain lists, and their total_count says when a list is a sample rather
than the whole set. incident_sources is lifted into a scalar for the same
reason severity was on the alerts side: the incident-field mapper reads dotted
paths and cannot index a list.

get_incidents stays, for cheap polling, and now says in its description what it
does and does not carry.

The mapper maps the aggregate first and the first alert last, so the alert
fills in whatever the aggregate leaves silent — including the detection anchor,
since an XDR incident's detection_time is usually null while its alerts carry
theirs. Verified against the vendor's recorded response
(test_data/get_multiple_incidents_extra_data.json): 33 of 52 entries resolve,
severity critical lands on 5, source reads "XDR Agent", and the anchor falls
through to the alert's detection timestamp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:41:27 +02:00
f3nris d06f8ea413 fix(cortex-xdr): the alerts endpoint never answered — wrong request dialect
get_alerts posted the incidents body, {filters, search_from, search_to, sort},
to alerts/get_alerts_by_filter_data/. That endpoint serves the alerts GRID and
speaks another dialect entirely, so every call — before this branch as much as
after it — came back a bare HTTP 500 with no hint as to why.

Shape taken from the reference client (demisto/content, Packs/ApiModules/
Scripts/CoreIRApiModule, get_alerts_by_filter_command):

  request_data.filter_data = {
    sort:   [{FIELD, ORDER}],          # a list, uppercase keys
    paging: {from, to},                # not search_from/search_to
    filter: {AND: [{SEARCH_FIELD, SEARCH_TYPE, SEARCH_VALUE}]},
  }

Severity is an enum there (SEV_040_HIGH), and several severities are OR'd, not
passed as a list. The watermark is a RANGE, since the grid has no gte operator;
its upper bound carries five minutes of slack, because our clock and the
tenant's are not the same clock. A filterless query is bounded to the last
thirty days rather than sent empty — the reference client refuses one outright,
and the grid is not meant to be asked for a whole retention.

The response needed as much work as the request. Rows arrive wrapped as
{alert_fields, incident_fields}, and mapping through that wrapper would put an
alert_fields. prefix on every expression an operator writes, so each row is
unwrapped. Two of its fields cannot be mapped as they stand: severity is the
enum code, and status.progress carries a dot INSIDE the key, which no mapping
path can express. Both are derived into severity_name and status_progress.

The mapper follows the grid's own vocabulary — internal_id, alert_name,
agent_hostname, agent_ip_addresses — and dedup moves to internal_id, since
alert_id belongs to the other API. case_id is kept as the correlation UID: it
is the join back to the incident feed.

Verified end to end against the vendor's own recorded response
(test_data/get_alerts_by_filter_results.json): 33 of 54 OCSF entries resolve on
it, severity lands on 3, the detection anchor is set, and the paging walks
0-100, 100-200, 200-250 with the truncation flag raised only when the ceiling,
not the window, ended the fetch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:34:26 +02:00
f3nris 17318d4225 feat(cortex-xdr): ingest alerts, not only incidents (v1.3.0)
An XDR incident is an aggregate; the SOC works the detections under it. The
alerts endpoint was already exposed as a read command, but nothing could feed
an alert rule with it — no results path, no dedup key, no watermark, no mapper,
no incident type. All five are here now, so an alert rule can be pointed at
reply.alerts the same way it is pointed at reply.incidents.

get_alerts pages past the API's 100-results-per-call ceiling: an alert feed
carries far more than a hundred detections between two polls, and whatever a
single page leaves behind is never fetched again, because the next run's
watermark has already moved past it. On an incremental fetch it also sorts
oldest first, so a window larger than the limit drops its most recent alerts —
the only ones the next poll can still see — and says so via `truncated`.

Two fixes to the incident side while in the same files:

- The severity expression compared strings, which the mapping engine cannot do
  (it reads numeric comparisons only). Every test read as false, so every
  ingested incident silently took the alert rule's default severity. The bare
  field works: Riposte maps critical/high/medium/low onto 1-5 itself.
- The incident mapper carried no `time`, so the detection anchor was missing
  and MTTD stayed empty for the whole feed. creation_time fills it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:26:31 +02:00
f3nris c22982c8af chore: retire the Riposte Sextant integration
It pushed agent readings over HTTP into a sibling product. That product was
absorbed into the SOAR in 0.112.0, and the readings now go through the built-in
SOC Reporting integration, which writes into the same database the server
already owns — no base URL, no ingestion token, no second deployment to keep
reachable. This integration is the seam the absorption spent five phases
removing.

Nothing here is worth keeping behind: the counters, the rule that an omitted
counter stays unknown rather than zero, and the client-listing command all
exist on the other side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:02:43 +02:00
f3nris 1d475a6cf5 fix(sentinelone): count-agents can be asked about the whole fleet again
Declared as a boolean, isActive rendered as a checkbox in the toolbox,
and a checkbox has no empty state: the count was forced to one half of
the fleet or the other, with no way to ask for both. As text, an empty
field means the filter is not sent.

true/yes/1 and false/no/0 are read in any case, a real JSON boolean from
a playbook still works, and anything else is refused rather than folded
into false — that would answer a different question than the one asked.
2026-08-14 23:48:40 +02:00
f3nris 93309ac74d feat(sentinelone): count-agents can be scoped to an account
A multi-tenant console answered one number for the whole estate.
accountIds narrows the count to the accounts asked for, the way
get_threats and get_alerts already do at fetch time.
2026-08-14 23:40:13 +02:00
f3nris a5095d3f0f feat(sentinelone): count-agents can tell the active fleet from the dormant one
isActive splits a count that until now lumped both together. It is sent
only when the caller sets it: an unset boolean must not reach the console
as isActive=false and quietly count the dormant agents instead of all of
them.
2026-08-14 23:30:47 +02:00
f3nris cebb3af796 feat(sentinelone): count-agents can ask about versions, state and hardware
The count only knew where an agent was, not what it was running or what
it runs on. Four filters carry the console's own vocabulary through:
agentVersion__gt to count the fleet still below a target build,
networkStatuses to keep only the endpoints in a given connection state,
operationalStatesNin to leave states out of the tally, and machineTypes
to count servers apart from laptops.

The list filters are split and re-joined so a hand-typed
"connected, disconnected" does not reach the API with the space inside
the value.
2026-08-14 22:39:29 +02:00
f3nris ba6bcd19f4 fix(cortex-xdr): say what went wrong when the API answers something other than JSON (v1.2.1)
A tenant URL that is not the API host answers 200 with the console's HTML
shell, and the client turned that into "Expecting value: line 1 column 1
(char 0)" — a JSON parse error that names neither the URL nor the response.

The shared client now reports the status, content type and first bytes of any
non-JSON reply, and points at the API host the operator was meant to copy.
Around it, the URL is reduced to scheme + host so a pasted /public_api/v1 or
console path cannot double the API root, a missing key or key ID is refused
before the request, and 401/403 recalls the three usual causes (auth mode,
revoked key, clock skew).

Nonce and timestamp now ride along in standard mode too, matching the
reference client, and test_connection probes get_incidents — the read that
ingestion actually depends on — instead of the distribution versions.
2026-08-13 14:35:54 +02:00
f3nris aabe1a6837 feat(riposte-sextant): list the clients, and the identifier to deposit under (v1.1.0)
The refusal you get on the first run is "no Sextant client is paired with this
SOAR client", and it names the identifier it did not recognise — but nothing
told you which ones it would have recognised. This does.

Clients with no pairing appear with an empty identifier, and are surfaced again
under `unpaired`: they are the whole reason somebody runs this command twice,
and spotting them in a list is exactly what nobody does.

Verified against a running Sextant: the paired client comes back with its
identifier, and the route refuses a caller with no ingestion token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:04:30 +02:00
f3nris 50358ef8a1 feat(riposte-sextant): push agent readings into the SOC steering file
Sextant counts nothing itself: it receives. Until now the opposite was true —
it reached into this SOAR to run a command, which meant SOAR had to host a
whitelist table, a screen to fill it, a dedicated permission and a catalogue
route. Configuration belonging to another product, living here.

As an integration, none of that is needed: an operator creates an instance with
a URL and a token like for any other tool, and a scheduled playbook counts on
the vendor console, maps, then calls push_agent_stats.

The mapping lives in that playbook, which is where it belongs — next to the
connector that produced the numbers, and per client rather than per product.

WHAT THE SCRIPT REFUSES TO DO, and why it matters more than what it does: a
counter left empty is OMITTED, never sent as zero. "We did not measure how many
agents are in error" and "no agent is in error" are opposite pieces of news, and
the second reassures wrongly — the whole steering file exists not to say it. A
counter that is present but not a number is refused instead, naming the field:
that is a broken mapping in the calling playbook, and dropping it silently would
look exactly like "not measured".

Verified against a running Sextant rather than assumed: the deposit files under
the paired client with the empty counter absent, a broken mapping is refused by
name, and an unpaired client comes back saying which identifier was not
recognised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 08:46:10 +02:00
f3nris 82f22fdbb3 feat(harfanglab): split image_name into the process path and the threat name (v1.4.1)
image_name was left unmapped because HarfangLab prefixes it with the
matched threat name — "[PUADlManager:Win32/OfferCore] C:\Windows\
explorer.exe" — and the clean path was available from the event data.
That reasoning only held for event-log alerts. On the yara, binary and
ioc subtypes there is neither a `process` object nor an `eventlog`
block, so those alerts reached the incident with no process at all.

Both halves are now pulled out, each guarded on the bracket actually
being there: $substringBefore and $substringAfter return the whole
string when the pattern is absent, so without the guard an unprefixed
path would land verbatim in malware.name.

The three entries lead the process block so the more specific sources
below — the matched process object, then the event log's own fields —
still overwrite them when present. malware.name consequently surfaces on
every subtype instead of only where Defender is the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:15:44 +02:00
f3nris f7c5e99cc5 feat(harfanglab): map the fields the alert and threat payloads actually carry (v1.4.0)
Replayed a real get_alerts and a real get_threats response through the
bundled mappers to see what came out.

get_alerts: a sigma alert matching a Windows event log carries the whole
record under `eventlog`, and none of it was mapped. On a Defender 1116
detection that left the malware, the file, the acting user and the
detecting product invisible — the incident indexed a hostname and
nothing else. The event data uses the provider's own field names, spaces
included, so the new entries quote them with JSONata backticks, and
Defender's "file:_" path prefix is stripped. Also added the agent IP
(placed before details_connection.SourceIp so a network alert's own
source still wins), the DNS domain, device type, agent version and
groups, the tenant, the alert subtype and threat type, the string
severity and the event time.

get_threats: `level` had no entry at all, so a threat's severity never
reached the OCSF document even though field_mappings derived the
incident's 1-5 from it. Added it, the event time, and the top agent's
reachability — whether the endpoint is online decides what response is
possible, and it was excluded without a reason.

Both trailers now record why each remaining field is left out, and the
event log's own SYSTEM writer and FQDN spelling are called out: indexing
them would create a junk user entity and a second spelling of one host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:30:01 +02:00
f3nris 6343a0c952 feat(ipinfo): support the Lite API for free/Lite plan tokens (v1.1.0)
The integration only exposed the Core API (/lookup/*), which returns 403
for accounts on the free/Lite plan — including the instance Test button.

Add the Lite endpoints: lite_lookup_ip (GET /lite/{ip}), lite_lookup_me
(GET /lite/me) and lite_batch (POST /batch/lite, code-first because the
endpoint takes a raw JSON array; chunked at the documented 1000-IP cap).

Point test_connection at /lite/me: every plan can reach it, so the token
check now succeeds on Lite-only accounts while still validating Core ones.
Core commands keep their paths and state the plan requirement up front.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:01:43 +02:00