Commit Graph

300 Commits

Author SHA1 Message Date
f3nris d2742ec6fb feat: CIRCL hashlookup, RDAP and crt.sh — free IOC lookups, no API key
Three keyless sources for the questions an analyst asks first:

- circl-hashlookup 1.0.0: is this hash known software (NSRL, distro
  packages, Windows updates)? `score` 0 for a known file, 100 when CIRCL
  flags it KnownMalicious, so an enrichment rule with benign 0 marks it
  benign; an unknown hash is an HTTP 404, read as "no record". Single and
  bulk lookups; base_url for a self-hosted instance.
- rdap 1.0.0: who registered a domain, IP block or AS, and when — with
  age_days for the phishing question. Authoritative server from the IANA
  bootstrap, rdap.org as fallback; a host name walks up to its registered
  domain; GDPR placeholders are not reported as registrants; a TLD with no
  RDAP (.ch) is an error, not a fake "unregistered".
- crtsh 1.0.0: Certificate Transparency — sub-domains with first/last seen,
  issuers, recent certificates, and a starts_with mode for look-alikes of a
  brand (crt.sh no longer searches with a leading wildcard). Retries the
  502/503/504 its shared front throws under load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmWT9wtWHGtZpMS64Hp4q5
2026-10-09 22:33:42 +00:00
f3nris 5cf870309f feat(harfanglab): describe an incident with the rule's message (1.7.1)
The get_security_events and get_alerts mappers set an incident's
description to alert_type alone — "sigma", "yara" — which says nothing
an analyst can act on. It now takes msg, the rule's own account of what
it detects and what to check, and falls back to the alert type only on
alerts that carry no message. Folded into the unreleased 1.7.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CXFMuDDLohs4qnL12mBJoP
2026-10-07 19:11:35 +00:00
f3nris 45df291eeb fix(harfanglab): drop the 50 KB filters catalogue from list outputs (1.7.1)
HarfangLab's alert and threat list endpoints echo, on every call, the
catalogue of every filterable field — about 50 KB, unrelated to the
results. get_security_events, get_alerts and get_threats now drop it
before printing, so a fetch carries the alerts and little else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CXFMuDDLohs4qnL12mBJoP
2026-10-07 12:42:32 +00:00
f3nris db9a45c9bc docs(mappers): MTTD is not retired, only re-anchored
The previous commit said MTTD was retired. Migration 0256 did retire it,
but 0257 restored it at once: what stays is its anchor, the moment
Riposte records the alert. The OCSF `time` field is still not read to
date detection — that part was right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:58:20 +00:00
f3nris ece2c41340 docs(mappers): time no longer dates detection
Eight mappers (Cortex XDR, CrowdStrike, Rapid7) still called the OCSF
`time` field "the MTTD anchor" that Riposte reads to stamp detected_at.
Riposte retired MTTD in migration 0256 and stamps detection at
ingestion for every alert; the mappings stay, as the alert's own record
of when the source saw the event. Comments only: no mapping changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:56:50 +00:00
f3nris ea04ea586c fix(sekoia-xdr): mapper reviewed on a real alert (1.2.0)
- incident source is 'Sekoia XDR'; Sekoia's `source` (the alert's source
  address) goes to src_endpoint.ip/.hostname, `target` to dst_endpoint;
- description: the alert's sentence with {rule:<uuid>} replaced by the rule
  name; the rule documentation (`details`) moves to analytic.desc;
- every TTP mapped into attacks; kill_chain_short_id no longer written as
  an ATT&CK tactic uid;
- OCSF times in epoch ms; finding_info.types, analytic.category,
  metadata.product added.

Checked with Riposte's mapper preview on the alert and a variant (IPv4
source, named target, one TTP): no expression error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:13:03 +00:00
f3nris d831d0973a feat(harfanglab): fetch a threat's security events (1.7.0)
Since HarfangLab merged alerts and security events, a threat's events
are the alerts whose threat_key is the threat id — the filter the
vendor's XSOAR integration uses. get_alerts could filter on it but
stops at one page of 100; the new threat_security_events command pages
through up to max_results and returns the threat's total with a
truncated flag.

threat_status, threat_level and alert_tag offer their status and level
as drop-downs, and threat_status now reads as the place to close a
threat or mark it a false positive, HarfangLab's only verdict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:35:31 +00:00
f3nris 91f6b8158b fix(cortex-xdr): give a full incident its own title (1.9.0)
Title and description both read the incident's description, so every
ticket showed the same sentence twice. The title is now the rule that
fired, with "(+N other alerts)" when several are folded in, or the
incident's console name; the description adds what each distinct rule
means.

network_artifacts[0] no longer overwrites the alert's own flow: the
artifacts are the fallback, and the destination country comes from the
artifact matching the alert's remote IP. Also maps the rule description
and id, and the XDR assignee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:25:32 +00:00
f3nris 0f5f4a900e feat(crowdstrike): set a detection's verdict (1.5.0)
Falcon records a verdict as a tag — true_positive, false_positive or
ignored, which the console's Resolution column reads — and update_status
only takes new, in_progress, reopened and closed, so resolve_detection
had no way to record one. Its new verdict input adds the tag after
removing the other two, add_tag never replacing one; status and verdict
are drop-downs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhDQSYZd1mFtRsXK4ixtyB
2026-10-02 14:25:22 +00:00
f3nris 820bab8e5c feat(jira): pick the project, issue type and priority from the site (1.3.0)
create_issue and edit_issue declare options_from on project_key,
issue_type_name and priority, and link_issues on link_type, so the
playbook editor lists the Jira site's own values. Three read commands
feed them: list_projects, list_issue_types (per project) and
list_priorities. The README documents enum and options_from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhDQSYZd1mFtRsXK4ixtyB
2026-10-02 10:16:41 +00:00
f3nris 41aa6723c5 feat(cortex-xdr): list the incident statuses and severities as enums (1.8.0)
cortex-xdr-update-incident declares the API's nine status values and
the four severities, so the playbook editor offers them in a drop-down
and the command bar refuses a misspelt one. resolved_other joins the
list; resolved_auto leaves it, Cortex XDR setting it itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhDQSYZd1mFtRsXK4ixtyB
2026-10-02 07:50:12 +00:00
f3nris 89ffbc3c7c feat(sentinelone): list the UAM alert statuses as an enum (1.18.0)
sentinelone-update-uam-alert-status declares NEW, IN_PROGRESS and
RESOLVED, folded into the unreleased 1.18.0, whose changelog entry
the previous commit left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Azv82i33uiPff2PFj2PLi7
2026-10-01 20:26:45 +00:00
f3nris 7750744abb feat(sentinelone): list the UAM analyst verdicts as an enum (1.18.0)
sentinelone-update-uam-alert-verdict declares the console's 20
AnalystVerdict values, so the playbook editor offers them in a
drop-down and the command bar refuses a misspelt one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Azv82i33uiPff2PFj2PLi7
2026-10-01 20:25:24 +00:00
f3nris 4554354e06 fix(sentinelone): accept a skipped UAM update when the alert is already set (1.17.3)
The console skips an alert already in the requested state, and 1.17.2
failed every skip: re-assigning an alert to its current holder, as a
second run of the same playbook does, broke the block.

A skipped alert is now read back. Already assigned to that user, or
already carrying that status or verdict, the block succeeds with
already_assigned / already_set; otherwise it fails naming the state the
console holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013u8PFdkYuuPiC6JdZBV5x3
2026-09-28 14:48:09 +00:00
f3nris 71f8cd970e fix(sentinelone): fail a UAM update the console did not apply (1.17.2)
The assignee mutation came back with the alert under success while the
console kept its previous assignee, and the playbook block passed. The
script now reads the alert back and fails, naming the assignee the
console holds, when it is not the requested user.

The status, verdict and assignee updates also fail when the alert is
listed under failure or skip: the console answers those with a 200 and
no errors array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013u8PFdkYuuPiC6JdZBV5x3
2026-09-28 14:43:53 +00:00
f3nris 232fa2f0a6 fix(sentinelone): type the assignee mutation as the console does (1.17.1)
update_uam_alert_assignee declared the action id as String! and the user
as ID!; the console types them ID! and Long and refused the mutation at
validation (VariableTypeMismatch), so no alert was ever assigned.

The user id now goes out as a JSON number, and a value that is not the
numeric console id is refused with an error naming assignee_user_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013u8PFdkYuuPiC6JdZBV5x3
2026-09-28 14:37:00 +00:00
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 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 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