Commit Graph

336 Commits

Author SHA1 Message Date
f3nris 18ae4a24ff fix(sentinelone): map the unified alert as the console describes it (1.17.0)
Corrected and completed against a real Identity alert.

Wrong data, not just missing:

- device.type read asset.type, which is UNKNOWN on every alert whose
  surface is not the endpoint. A domain controller the same alert called
  a Server was ingested as an unknown device. asset.category is read
  first now, with a numeric device.type_id beside it.
- device.ip took the IPv6 address ahead of the IPv4, so every Windows
  host carried a link-local fe80:: address nothing can pivot on, and
  which the indicator extractor mined as an IOC all the same.
- The flat file summary went straight into observables, so an alert with
  no file — identity, cloud — carried one observable whose name and value
  were both the literal string N/A. It now fills file.name and the
  file.hashes entry its digest length names, and only when the process
  block did not already describe the file.
- finding_info.types is a list, as OCSF and the layout generator expect.
- src_endpoint and dst_endpoint are oriented by the detection rather than
  by habit. SentinelOne fills detectionTime.attacker only when a party
  distinct from the asset is involved, so its presence decides which way
  the pair points: attacker as source and asset as destination when it is
  filled, asset as source and no destination when it is not. Mapping the
  attacker to dst_endpoint unconditionally announced a domain controller
  as the source of the enumeration it had in fact suffered. device.* is
  the invariant across both branches, and the asset's full OS version
  moved onto it so the flip costs nothing.
- The base event had no clock of its own. time, start_time and end_time
  come from detectedAt, firstSeenAt and lastSeenAt, so a timeline no
  longer falls back to the moment Riposte polled.

47 entries added, 97 to 144, covering every one of the 120 fields the
query fetches. 23 sit under unmapped.* — the Azure image, instance size
and resource tags, the console-side IP, the Kubernetes cluster,
namespace, node and controller, the agent's connectivity, pending reboot
and policy, the ticket and whether a note exists — surfaced for the page
instead of being left in the raw payload.

Checked by running the platform's own jsonata-go mapping engine over the
real Identity alert, over a STAR alert fetched with_details, over an
alert naming only the attacker's address, and over a bare alert: no
expression errors on any of them, and the process chain, evidences,
attacks and observables come out unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 08:28:02 +00:00
f3nris a6a52f7356 feat(crowdstrike): count devices by filter without listing them (1.4.0)
The device query already answers with meta.pagination.total — the number
of devices matching the filter, independent of the page asked for — so
device_count asks for a one-ID page and reads it, where search-device
resolves every matching device in full through
/devices/entities/devices/v2 to reach the same figure, and only filters
on four properties.

Filters cover sixteen device FQL properties (hostname, device id, local
and external IP, platform, OS version, role, containment status, sensor
version, AD domain/OU/site, serial, tags, host groups, RFM), add
first_seen/last_seen bounds, and keep a raw FQL escape hatch.

Two details the reference client leaves to chance are handled here.
Several values on one property become an OR group, parenthesised so it
binds before the '+' that ANDs it to the other clauses. And values are
quoted with the single quote escaped, so a hostname carrying an
apostrophe cannot end the literal early and change what is counted.

devices/v1 stops reporting a total on an estate it cannot page to the
end of, so a missing total falls back to the scroll query, which has no
such ceiling.

The estate `total` comes back next to `count` so the figure reads as a
proportion, and a filter too broad to mean anything shows as matching
everything rather than passing for an answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 12:41:57 +00:00
f3nris beefe047a7 feat(cortex-xdr): count endpoints by filter without listing them (1.7.0)
The endpoints grid already answers with reply.total_count — the number
of endpoints matching the filters, independent of the page asked for —
so endpoint_count asks for a one-record page and reads it, where
get-endpoints has to walk the estate 100 records at a time to arrive at
the same figure, and only filters on four fields.

Filters cover the twelve list fields the API accepts (hostname, alias,
endpoint id, internal and public IP, group, platform, distribution,
status, isolation, scan status, username), add first_seen/last_seen
bounds taking ISO8601 or epoch ms, and keep a raw JSON `filters` escape
hatch for anything else. Filters are ANDed; the API has no OR.

The estate `total` comes back next to `count` so the figure reads as a
proportion, and a filter too broad to mean anything shows as matching
everything rather than passing for an answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 12:37:34 +00:00
f3nris 505c518bf8 feat(harfanglab): count agents by filter without listing them (1.6.0)
The agent collection is DRF-paginated, so `count` already carries the
filtered total. endpoint_count asks for limit=1 and reads it, instead of
pulling up to 10000 agent records back through the playbook the way
endpoint_search does — and endpoint_search only filters on hostname.

Filters cover the documented agent query parameters (hostname, network,
os, version, status, isolation_state, tenant), add lastseen/firstseen
bounds over the DRF range lookups, and keep a raw `filters` escape hatch
for anything else the instance accepts.

The API silently drops a query parameter it does not recognise as a
filter, which turns a typo into a plausible-looking fleet-wide total, so
the fleet `total` is returned next to `count` to make that case visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 12:29:53 +00:00
f3nris ed56523b07 fix(ovhcloud): treat the billing-account list as advice, never as a verdict (1.2.6)
The listing now answers 200 with an empty array rather than erroring, and the
probe failed on "not one of the accounts these credentials can see" — the same
mistake as 1.2.4, in its other shape: a diagnostic step deciding the outcome of
the test it exists to explain.

OVH's IAM filters what you may SEE rather than refusing you, so an empty list is
how it says no — and a policy can grant a resource without granting the route that
enumerates it, which makes "not in the list" weak evidence either way. Only the
account-scoped routes decide now.

When nothing is reachable anywhere, the message names the IAM policy as the cause
and says what to grant, because an empty array is the least self-explanatory
refusal this API produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 19:29:09 +00:00
f3nris 42032e576e fix(ovhcloud): use the API endpoint as configured, for both schemes (1.2.5)
1.2.0 rewrote /1.0 to /v1 whenever a service account was in use, reasoning from a
documentation example that showed OAuth2 against a /v1 path. It was wrong, and it
was the cause of the 404s and the 500 that followed: OVH's own API console issues
Bearer requests against /1.0, while /v1 — which exists, and answers 401
unauthenticated like any live route — answers 500 with an empty body for a token
on the telephony routes. A failure with no message at all, produced by a helpful
rewrite nobody asked for.

The endpoint is now whatever was typed, for both authentication schemes. The
config field says where to copy it from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 19:26:02 +00:00
f3nris 35f287837b fix(ovhcloud): stop a diagnostic step from failing the test (1.2.4)
GET /telephony — the list of billing accounts — answers 500 with an empty body on
at least one service account. Nothing in this integration needs that list: it was
added as a diagnostic, and it promptly failed the very test it exists to explain,
hiding whatever the account-scoped routes had to say behind its own failure. It is
now recorded and stepped over.

When neither product can be listed either, the message names what each code means
rather than repeating the status: 403 is an IAM policy missing the telephony
actions, 404 is a billing account that does not exist under these credentials, and
500 on every telephony route means this service account cannot reach the telephony
API at all — for which the way round is an application key from
api.ovh.com/createToken, which this integration already accepts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 19:25:00 +00:00
f3nris 3ca2d6ded2 fix(ovhcloud): make the test say where it stopped (1.2.3)
This endpoint answers a bare 404 — "This service does not exist" — for a wrong
billing account, a wrong service name, a queue filed under the other product, and
a number nobody has configured yet. Four different jobs, one message, and nothing
on the Riposte screen could tell them apart. Two rounds went to guessing which one
it was.

The probe now walks there one step at a time and reports what it saw at each: the
billing accounts these credentials can reach, the services each product holds, the
queues in the chosen one. A failure names the step and lists the values that do
exist, which is what somebody can act on.

It also answers its own VERSION. When a test fails identically twice, the first
question is whether the new code is the code that ran, and nothing on the screen
could answer it.

And a failed diagnosis now exits non-zero with an "error" key. Returning ok:false
on exit 0 would have been worse than the 404 it replaces: the screen would call
the instance healthy while naming the step where it is not.

Verified end to end through the real entry point for six shapes — success under
either product, unreachable account, wrong account, number that is not a queue,
service with no queue, and a listing the IAM policy refuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 19:21:54 +00:00
f3nris d682d66f04 fix(ovhcloud): find the call queue under either product (1.2.2)
A queue reached 404 "This service does not exist" on an account where the number
was plainly configured as one. The number was right: the PRODUCT was wrong. OVH
files a call queue under easyHunting or under ovhPabx depending on the offer the
number carries, the hunting routes underneath are identical either way, and the
control panel says "File d'appels" for both — so there is nothing an operator can
look at to tell which one they have.

It is now discovered once per run rather than assumed, and a service found under
neither product says which names DO exist under each. That turns the least
helpful error this API produces — one that reads as a wrong number — into a list
somebody can copy the right value out of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 19:15:57 +00:00
f3nris aacfa135aa fix(ovhcloud): test what the instance is for, not the account (1.2.1)
An instance created for on-call routing has no business reading the account, and a
service account narrowed to /telephony — which is exactly how it should be
narrowed — answers 403 "User not granted for this request" on
account:apiovh:me/get. The credentials were perfect and the Test button said they
were not, which is the worst thing a test can do: it sends somebody off to
regenerate a secret that was never wrong.

With telephony coordinates configured the probe now reads the service's call
queues instead. That is worth more than /me ever was: one call proves the
credentials, the IAM policy, the billing account, the service AND the queue. With
no coordinates it still reads the account, which is the right probe for an
instance used to look at servers.

A service found holding no queue is reported as a SUCCESS with a sentence saying
so. It is not a credential failure, and confusing the two is the difference
between "go and create a queue" and an evening spent on authentication.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 18:52:04 +00:00
f3nris 6e03b17afb feat(ovhcloud): accept a service account (OAuth2) as well as application keys (1.2.0)
The control panel does not offer application keys on every account — some can only
create a SERVICE ACCOUNT, which authenticates with OAuth2 client credentials. An
instance built that way could not talk to the API at all, which is a hard stop for
anybody whose account is in that shape.

Both schemes are now supported, and which one is used is decided by WHAT WAS
FILLED IN rather than by a mode selector: a client_id and a client_secret mean the
service account, anything else falls back to the signed scheme. One less field to
get wrong, and every existing instance keeps working untouched.

The API base is corrected to /v1 for a service account. The same routes answer
under /1.0 and /v1, but a Bearer token sent to the legacy spelling fails in a way
that reads as "bad credentials" — which sends somebody off to regenerate a
perfectly good secret. An operator should not have to know that the spelling and
the credential go together.

Nothing in config_schema is required any more. The schema cannot express "one set
or the other", and keeping the historical three required would refuse every
service-account instance; the scripts refuse an instance with neither and name
both sets, which is an error somebody can act on — where a form that will not
submit is one they can only fight.

Two things the shared block now caches for the life of the process, both of which
matter on the ladder path that runs every time the rota turns: the access token
(a token request in front of each of a dozen calls would triple the traffic) and
the clock offset against OVH's time (it was a round trip per signed request).

Verified offline for both schemes: the base URL each one talks to, the headers
that land on the wire, the single token request, and the refusal when neither set
is present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 18:31:31 +00:00
f3nris 864300d7c5 feat(ovhcloud): hold Riposte's on-call ladder in a VoIP call queue (1.1.0)
Riposte's pager can hand its on-call ladder to a marketplace instance, so that a
number published to clients rings whoever is on duty without the call ever
crossing the SOC's own trunk — and without spending one of its simultaneous
calls, which are sold in blocks and are the reason the PBX-side mode is too
expensive for a small deployment.

Three commands, and they are a contract rather than this integration's own
vocabulary: set_oncall_ladder (ordered numbers + ring_seconds), get_oncall_ladder
(what the carrier ACTUALLY holds, so a comparison can be honest) and list_calls
(the journal, after the fact). The pager offers whichever instances declare the
first one; the README now documents the contract for whoever writes the next
router.

Extends the existing ovhcloud integration rather than adding a second one: it
already carries the signed-request authentication, and making an operator enter
the same application key twice would serve nobody. Three telephony_* config
fields are added; every existing command is untouched, and an instance that
leaves them empty is exactly the OVHcloud reader it was before.

set_oncall_ladder RECONCILES rather than rebuilds. Agents are created when a
number has none, reordered by position, and anybody who drops off the ladder is
LOGGED OUT and removed from the queue — never deleted, because deleting throws
away whatever else the operator configured on that agent and they are on the rota
again next week. It also enforces the sequential strategy: publishing an ordered
list into a queue set to ring everybody at once is a lie that is invisible from
Riposte.

Two details that would each have cost an evening: OVH accepts no "+" in a
phoneNumber field, so E.164 is converted to the 00-prefixed form both ways; and a
service holding several queues is REFUSED rather than guessed, because guessing
publishes the on-call ladder into whichever queue the API happened to list first.

Verified against a stubbed API for the first publication and for a rota handover.
The signature, the error codes and the exact number format are only proved by the
first real call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 18:00:33 +00:00
f3nris 11089e9e80 rapid7-insightidr 1.13.0: comment an investigation and read the thread back
add_comment (POST /idr/v1/comments) and list_comments (GET /idr/v1/comments)
close the gap the Demisto integration never covered: the investigation comment
thread is where an analyst's reasoning is recorded, and a playbook could neither
write into it nor read it back.

The Comments API is v1-only and has no multi-customer parameter, so neither
command takes api_version. It addresses its target by RRN; the short
investigation ID the other v1 endpoints accept is refused with a 400 that names
no parameter, so both commands reject a non-RRN up front and say why.

list_comments walks the pages (size capped at 100 by the API) up to `limit`, or
all of them with all_results, with a 50-page backstop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:22:58 +00:00
f3nris 26d6273f60 feat(webhook): outbound webhooks to any third-party solution (v1.0.0)
The catalog could POST to Discord and to Teams, and to nothing else: every
other receiver needed its own package. This is the generic one — the
destination is instance config, the body is the command's.

Four senders and a test:
  post_json   a JSON document, the receiver defines the schema
  send_event  a normalised Riposte envelope (id, title, severity, link)
  post_form   x-www-form-urlencoded, for receivers older than JSON webhooks
  post_raw    a body verbatim with the Content-Type you choose

Three decisions worth naming:

- The destination is pinned on the instance and a per-call `url` is refused
  unless allow_url_override is set. An integration that always took its URL
  from the command inputs would be an arbitrary HTTP client for anything that
  can call it, the agent included. The SSRF guard (public addresses only,
  https only) sits behind the same kind of explicit opt-in.

- `url` is sensitive. Slack, Teams, Discord, Zapier and n8n all carry the
  token in the path, so command output echoes scheme://host and never the
  path or the query.

- test_connection opens a TCP/TLS connection and sends nothing. The health
  checker runs it on a schedule on every enabled instance; a receiver should
  not have to filter a Riposte ping every few minutes to keep one green.
  test_sends_ping opts into the real POST.

Optional HMAC-SHA256 over the body (or over `<ts>.<body>`, the replay-
resistant scheme Slack and Stripe use) lets the receiver prove the call came
from Riposte. Retries cover 408/425/429/5xx and connection failures, honour
Retry-After, and stay inside the sandbox's 60s ceiling. A non-2xx that
survives them fails the command, so a playbook block never reports a
delivery that did not happen.

Verified against a local receiver: 52 checks over payload shapes, the three
auth modes, both signature schemes, header precedence, retry and give-up,
every guard, path/method handling, and both test modes.
2026-09-18 08:25:34 +00:00
f3nris dd2b4629a1 sentinelone: count_agents takes agentVersion__gte and __lt too
Folded into the unreleased 1.16.0 entry, which now covers the three
comparators added beside the agentVersion__gt the command already had.
Sent together they bound a range, so a fleet can be counted on a band
of builds rather than only above one. Empty ones are not sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:45:22 +00:00
f3nris 741db61543 sentinelone 1.16.0: count_agents takes agentVersion__lte
The other end of the agentVersion__gt range the command already had:
agents on the given version or older, so a target build counts as
still to be upgraded rather than as already done. Left empty the
filter is not sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:43:00 +00:00
f3nris 086150b200 rapid7-insightidr 1.12.0: ingest LEQL query results as alerts
A tenant whose basic detection rules only notify — email, Slack — creates no
investigation and no alert any API can read: /investigations stays empty and the
Alert Triage API is MDR-gated. The rule's LEQL query is the only artefact left,
so replay it on a schedule and turn every matching line into an alert.

query_log and query_log_set gain:

- an ingest block (results_path events, dedup_key event_id, watermark start_time);
- event_id = log_id + the line's sequence number, the line's own address in Log
  Search. It has to be stable across runs because the scheduler deliberately
  re-reads the last minute of the previous window on every tick, and anything
  derived from the fetch time would make that overlap a second incident. A hash
  of (log_id, timestamp, message) covers a source that does not number entries;
- start_time read as ISO-8601, UNIX seconds or milliseconds alike. The API only
  takes milliseconds, but which one the scheduler sends is an operator setting on
  the rule — the feed must not fetch the wrong window because of a dropdown;
- a bounded page walk (limit, default 1000): unattended every few minutes, a
  query matching more than intended would otherwise hold the run open long
  enough for the next tick to find it in flight and skip itself;
- detection_name, stamped on every event. The log line does not know which rule
  it is being replayed for, and without it an incident is titled by its own first
  characters where the source console shows an alert name.

Plus mappers/query_log{,_set}.yaml: the parsed event lives in `message` as a
JSON-encoded string, so account, addresses, asset and process come out through
$parseJson. A raw unparsed line is not JSON — those entries resolve to nothing
and are skipped, and custom:rapid7_log_line still carries the line verbatim.
Severity is left unmapped: a log line has no priority of its own, so the alert
rule's default applies.

Known limits, by construction: this endpoint rejects calculations, so a rule with
a threshold (N occurrences in M minutes) cannot be reproduced — only pattern
rules are 1:1 — and an inactivity rule never can, since it fires on the absence
of events. The query also becomes a copy: editing the rule in InsightIDR leaves
the feed reading the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjtRtCzw6FEajDDHJ3mTQf
2026-09-16 15:06:02 +00:00
f3nris a32992d781 docs: document bundled mappers, jsonata: expressions and the reshape-in-the-mapper convention
The README never described mappers/ or incident-types/ although the
sync collects both. Documents the mapper file format, the jsonata:
prefix with $parseJson/$toTable, the singleton [] trap, and the
convention that new integrations reshape in the mapper rather than
pre-building display structures in scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1fWBoEKvAw3k78XDkpRQo
2026-09-11 21:34:25 +00:00
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