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>
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>
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
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>
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>
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>
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>
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>
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>
"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>
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>
get_alerts posted the incidents body, {filters, search_from, search_to, sort},
to alerts/get_alerts_by_filter_data/. That endpoint serves the alerts GRID and
speaks another dialect entirely, so every call — before this branch as much as
after it — came back a bare HTTP 500 with no hint as to why.
Shape taken from the reference client (demisto/content, Packs/ApiModules/
Scripts/CoreIRApiModule, get_alerts_by_filter_command):
request_data.filter_data = {
sort: [{FIELD, ORDER}], # a list, uppercase keys
paging: {from, to}, # not search_from/search_to
filter: {AND: [{SEARCH_FIELD, SEARCH_TYPE, SEARCH_VALUE}]},
}
Severity is an enum there (SEV_040_HIGH), and several severities are OR'd, not
passed as a list. The watermark is a RANGE, since the grid has no gte operator;
its upper bound carries five minutes of slack, because our clock and the
tenant's are not the same clock. A filterless query is bounded to the last
thirty days rather than sent empty — the reference client refuses one outright,
and the grid is not meant to be asked for a whole retention.
The response needed as much work as the request. Rows arrive wrapped as
{alert_fields, incident_fields}, and mapping through that wrapper would put an
alert_fields. prefix on every expression an operator writes, so each row is
unwrapped. Two of its fields cannot be mapped as they stand: severity is the
enum code, and status.progress carries a dot INSIDE the key, which no mapping
path can express. Both are derived into severity_name and status_progress.
The mapper follows the grid's own vocabulary — internal_id, alert_name,
agent_hostname, agent_ip_addresses — and dedup moves to internal_id, since
alert_id belongs to the other API. case_id is kept as the correlation UID: it
is the join back to the incident feed.
Verified end to end against the vendor's own recorded response
(test_data/get_alerts_by_filter_results.json): 33 of 54 OCSF entries resolve on
it, severity lands on 3, the detection anchor is set, and the paging walks
0-100, 100-200, 200-250 with the truncation flag raised only when the ceiling,
not the window, ended the fetch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An XDR incident is an aggregate; the SOC works the detections under it. The
alerts endpoint was already exposed as a read command, but nothing could feed
an alert rule with it — no results path, no dedup key, no watermark, no mapper,
no incident type. All five are here now, so an alert rule can be pointed at
reply.alerts the same way it is pointed at reply.incidents.
get_alerts pages past the API's 100-results-per-call ceiling: an alert feed
carries far more than a hundred detections between two polls, and whatever a
single page leaves behind is never fetched again, because the next run's
watermark has already moved past it. On an incremental fetch it also sorts
oldest first, so a window larger than the limit drops its most recent alerts —
the only ones the next poll can still see — and says so via `truncated`.
Two fixes to the incident side while in the same files:
- The severity expression compared strings, which the mapping engine cannot do
(it reads numeric comparisons only). Every test read as false, so every
ingested incident silently took the alert rule's default severity. The bare
field works: Riposte maps critical/high/medium/low onto 1-5 itself.
- The incident mapper carried no `time`, so the detection anchor was missing
and MTTD stayed empty for the whole feed. creation_time fills it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tenant URL that is not the API host answers 200 with the console's HTML
shell, and the client turned that into "Expecting value: line 1 column 1
(char 0)" — a JSON parse error that names neither the URL nor the response.
The shared client now reports the status, content type and first bytes of any
non-JSON reply, and points at the API host the operator was meant to copy.
Around it, the URL is reduced to scheme + host so a pasted /public_api/v1 or
console path cannot double the API root, a missing key or key ID is refused
before the request, and 401/403 recalls the three usual causes (auth mode,
revoked key, clock skew).
Nonce and timestamp now ride along in standard mode too, matching the
reference client, and test_connection probes get_incidents — the read that
ingestion actually depends on — instead of the distribution versions.
Add three commands to the Cortex XDR integration (v1.2.0):
- update_incident: change status, severity, assignment and resolve
comment on an incident (closes the SOAR ingest→action loop).
- insert_parsed_alerts: push external parsed alerts into Cortex XDR.
- insert_cef_alerts: push external CEF-format alerts.
Scripts follow the existing self-contained standard/advanced auth pattern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cortex-xdr, crowdstrike, harfanglab and splunk now ship an `ingestion` block
(fetch command + bundled mapper + default incident type) and a bundled default
incident type under incident-types/, so the instance Collect section is pre-filled
on first configuration and the default type is created on install.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>