815db080f5d1a65a005a4864a0666a82aba3e0c1
331 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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 (
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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> |
||
|
|
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> |
||
|
|
e8b2166d30 |
feat(cortex-xdr): query the data lake in XQL (v1.4.0)
Four commands reach the XQL engine: cortex-xdr-xql-query runs a query and waits for its rows, cortex-xdr-xql-start-query and cortex-xdr-xql-get-query-results split that same work in two when the query outlives a command, and cortex-xdr-xql-get-quota says what the day's hunting has already cost — an XQL query is billed by the data it scans. Three things the API makes easy to get wrong are handled here. Past 1000 rows it stops answering with results and hands over a stream id instead: gzipped JSON lines, unpacked into rows rather than dropped on the playbook as an archive it cannot read. A query carrying no limit stage is served the tenant's own default, so one is appended (100 rows, configurable) unless the query already has its own. The reference client does the same on a looser substring test, which also fires on a field whose name merely contains "limit". Waiting is a budget, not a promise: an integration command is killed at 60 seconds, so a query still running when the budget expires comes back PENDING with its execution_id instead of failing, and the results command collects the rows afterwards. Re-running the query would be charged against the quota a second time — which is why the pending answer names the command to call rather than inviting a retry. The relative window is parsed here (24 hours, 7 days, 3 months) since the scripts have no dateparser; an unreadable one is refused before the query is started, rather than silently widening to the tenant default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9f9ee5344d |
fix(cortex-xdr): put the actor where the other EDR mappers put it
HarfangLab and SentinelOne both send the process that acted to actor.* — process.username and sourceProcessInfo.user to actor.user.name, the process itself to actor.process.* — and keep user.name for the person the event happened to: HarfangLab's top_impacted_users, SentinelOne's login account. Cortex XDR filled neither slot correctly. XDR labels the acting process actor_process_* and its account actor_effective_username; the mappers sent the process to process.* and the account to user.name, which the OCSF catalogue calls Target User. A rule written on actor.user.name or actor.process.name matched HarfangLab and SentinelOne and skipped Cortex, and a rule reading user.name as the victim read the attacker. process.* is not free-for-all either: in the SentinelOne (targetProcessInfo) and Carbon Black (childproc_*) mappers it holds the process acted upon, so Cortex's actor was sitting in the target's seat. get_incidents_full had the collision in the open: the incident's impacted users[] and the first alert's user_name both wrote user.name, and last non-empty wins, so the actor overwrote the impacted user on every incident carrying both. Tenants using the shipped templates pick this up on the next sync; a forked mapper, or a column or rule pointed at the old Cortex paths, needs repointing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bf273b959a |
feat(cortex-xdr): watermark incidents on modification_time, not creation
An XDR incident is not finished when it is created. Alerts keep joining it, an analyst changes its status, its severity is raised. A creation_time watermark fetches it once, the watermark moves past it, and nothing that happens afterwards ever reaches Riposte — which is precisely the content the full fetch exists to bring in. modified_after filters and sorts on modification_time instead, so an incident comes back on every change and dedup on incident_id turns the second visit into an enrichment of the incident already there. It is now what the ingest hint prefills; created_after stays for a one-shot backfill. Worth knowing about that enrichment: it merges context and can fill a detection anchor that was missing, but it does not restate the incident's severity or status. An incident XDR later raises to critical stays at the severity it was ingested with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ba68d19e51 |
refactor(cortex-xdr): name the full fetch get-incidents-full (v1.3.1)
"fetch" said nothing next to a list of commands that all start with "get". The command sits beside cortex-xdr-get-incidents in the picker, and the only thing an operator needs to read there is which of the two carries everything — so the name says it: get-incidents-full. The id moves with it (fetch_incidents -> get_incidents_full), since the script and the bundled mapper are bound to a command by filename. Anyone who created a rule against the old id in the few minutes 1.3.0 was up has to point it at the new command; the changelog says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a6e1245c79 |
feat(cortex-xdr): fetch incidents with their alerts, not a 21-field summary
incidents/get_incidents/ answers with a summary and nothing else: 21 fields,
no hosts, no users, no MITRE, no tags, and not one of the alerts the incident
aggregates. Ingesting through it leaves an incident whose raw payload says
almost nothing about what happened — and the shipped mapper had been written
for a richer shape than the endpoint ever returns, mapping hosts[0], users[0]
and mitre_* that simply are not in that response.
incidents/get_multiple_incidents_extra_data/ returns the same incidents with
39 fields, every alert in full — 156 fields each — and the file and network
artifacts. It is what the reference client fetches through (demisto/content,
CortexXDRIR.get_multiple_incidents_extra_data), and full_alert_fields must be
set or the nested alerts come back trimmed to a handful of fields.
Records arrive as {incident, alerts, network_artifacts, file_artifacts} with
each nested block wrapped as {total_count, data}. The script flattens them, so
every expression written against get_incidents keeps working — the summary's
21 fields are a subset of these 39 — while the alerts and artifacts land beside
them as plain lists, and their total_count says when a list is a sample rather
than the whole set. incident_sources is lifted into a scalar for the same
reason severity was on the alerts side: the incident-field mapper reads dotted
paths and cannot index a list.
get_incidents stays, for cheap polling, and now says in its description what it
does and does not carry.
The mapper maps the aggregate first and the first alert last, so the alert
fills in whatever the aggregate leaves silent — including the detection anchor,
since an XDR incident's detection_time is usually null while its alerts carry
theirs. Verified against the vendor's recorded response
(test_data/get_multiple_incidents_extra_data.json): 33 of 52 entries resolve,
severity critical lands on 5, source reads "XDR Agent", and the anchor falls
through to the alert's detection timestamp.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|