Compare commits

...

36 Commits

Author SHA1 Message Date
f3nris bf273b959a feat(cortex-xdr): watermark incidents on modification_time, not creation
An XDR incident is not finished when it is created. Alerts keep joining it, an
analyst changes its status, its severity is raised. A creation_time watermark
fetches it once, the watermark moves past it, and nothing that happens
afterwards ever reaches Riposte — which is precisely the content the full fetch
exists to bring in.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:01:43 +02:00
Guillaume BOURGEOIS 7efc873867 feat(sentinelone): scope threat and alert fetches to given accounts (v1.4.0)
A multi-tenant console returns every account's threats and alerts at once.
get_threats and get_alerts now take an optional account_ids input, passed
through as the accountIds query parameter, so an ingestion can be pinned to
the accounts the SOC actually watches.

The input is optional, so existing instances and running ingestions keep
their current behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:56:47 +02:00
f3nris 144089a099 fix(harfanglab): flag get_alerts as ingestion-ready (v1.3.1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 23:19:58 +02:00
f3nris a4b5b44157 fix(harfanglab): null guards and stable attacks array in get_threats mapper
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:52:11 +02:00
f3nris 4ea3af6c1d fix(harfanglab): rework get_threats OCSF mapper from live threat sample
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:49:31 +02:00
f3nris 952dc7efda feat(harfanglab): v1.3.0 — response & triage expansion (27 commands + alert mappers)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:33:59 +02:00
f3nris cba746a9f9 feat(harfanglab): exhaustive alert OCSF mappers (get_alerts + upgraded get_security_events)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:26:19 +02:00
f3nris 5d6f823189 feat(harfanglab): threat triage commands (status, level, comment, note, history)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:20:54 +02:00
f3nris 098b90d6f3 feat(harfanglab): alert triage commands (tag, comment, details, history)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:18:01 +02:00
f3nris db902c35bc feat(harfanglab): alert search and single-threat read commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:14:25 +02:00
f3nris 59e9211c0d feat(harfanglab): threat-level isolation and job lifecycle commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:09:56 +02:00
f3nris fcbd2ff135 feat(harfanglab): quarantine management commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:04:47 +02:00
f3nris ef60cec0fa feat(harfanglab): file/service/scheduled-task deletion commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:01:22 +02:00
f3nris 6d993f584f feat(harfanglab): on-demand AV and YARA scan commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 21:57:37 +02:00
f3nris 6d311740f9 feat(harfanglab): kill/dump process response commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 21:52:39 +02:00
f3nris f6b8c84285 feat(edr): OCSF mappers + ingestion for 6 EDR integrations
Every EDR (category: endpoint) integration is now audited for an OCSF mapper.
Six had a fetch command but no mapper — add a bundled OCSF mapper for each,
wire the fetch command as an ingestion source (ingest: results_path/dedup_key
[/incremental_field]) and add the top-level ingestion block. Field mappings
were derived from each vendor's fetch output and cross-referenced against the
demisto/content reference for that vendor.

- carbon-black-cloud: search_alerts (results_path results, dedup id) — 35 fields
- cisco-secure-endpoint: list_events (data, id) — 21 fields
- cybereason: query_malops (malops, guid, incr start_time) — 6 fields
- sophos-central: list_alerts (items, id) — 14 fields
- trend-vision-one: list_alerts (items, id, incr start_datetime) — 19 fields
- withsecure: list_incidents (items, incidentId) — 7 fields

Validated: every manifest passes ParseManifest+ValidateManifest and every mapper
passes ParseMapperFile with all JSONata source_path/field expressions compiling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 22:37:07 +02:00
f3nris 5ee7352652 fix(ransomware-live): keyless — drop optional API key, no auth required
Ransomware.live's v2 API is free and needs no authentication. Remove the
optional api_key config field and the X-API-KEY header logic from all commands
so instance creation requires no credentials. Only an optional base_url override
remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:18:49 +02:00
f3nris 534748f16a feat(ransomware-live): add Ransomware.live OSINT integration
Ransomware.live API v2 — OSINT tracking of ransomware/extortion groups and
their claimed victims. 9 read-only commands: recent victims, list groups,
group profile, group victims, and search victims by keyword, country, or date,
plus recent cyberattacks and a test-connection check. Useful for third-party /
supply-chain exposure checks (is a partner or domain listed as a victim?).

API key is optional (sent as X-API-KEY when configured; keyless otherwise).
stdlib-only Python (urllib), no extra dependencies. category: threat_intel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:56:50 +02:00
Guillaume BOURGEOIS a2c9f998c3 feat(feed): add Botvrij.eu OSINT feed connector (IOC lists by type) 2026-07-13 12:15:50 +02:00
Guillaume BOURGEOIS ae7c494658 feat(feed): add Microsoft 365 endpoints feed connector (allowlist / known infra) 2026-07-13 12:15:50 +02:00
Guillaume BOURGEOIS 40252dadcb feat(feed): add generic RSS/Atom IOC feed connector (URL/IP/hash extraction, defang-aware) 2026-07-13 12:15:49 +02:00
127 changed files with 5726 additions and 482 deletions
@@ -84,7 +84,7 @@ commands:
outputs_schema: { properties: {} }
- id: search_alerts
name: cbc-search-alerts
description: "Search alerts."
description: "Search alerts. Used for ingestion: results path = results."
risk: read
inputs_schema:
properties:
@@ -92,6 +92,9 @@ commands:
rows: { type: number, description: "Max alerts (default 50)" }
required: []
outputs_schema: { properties: {} }
ingest:
results_path: results
dedup_key: id
- id: ban_hash
name: cbc-ban-hash
description: "Ban a file by SHA-256 hash (reputation deny-list override)."
@@ -119,3 +122,8 @@ commands:
properties: {}
required: []
outputs_schema: { properties: {} }
ingestion:
command: search_alerts
mapper: search_alerts
default_incident_type: "Carbon Black Cloud Alert"
@@ -0,0 +1,52 @@
name: "VMware Carbon Black Cloud Alerts → OCSF"
description: "Exhaustive map of a Carbon Black Cloud Alert Search v7 alert (POST /api/alerts/v7/orgs/{org_key}/alerts/_search, results_path = results) to OCSF. Field paths validated against the CBC Platform Alerts v7 API. The alerting process = actor (actor.*); the child/target process spawned as a result = process.*; parent lineage under actor.process.parent_process.*."
field_mappings:
title: "reason"
severity: "severity >= 9 ? 5 : (severity >= 7 ? 4 : (severity >= 5 ? 3 : (severity >= 3 ? 2 : 1)))"
description: "type"
# results_path = results; source_path is JSONata over ONE alert object.
# Paths absent from a given alert (e.g. no childproc.* on a non-process alert type) are
# skipped at ingestion, so extra entries are safe.
ocsf:
# ── Finding / alert identity ───────────────────────────────────────
- { source_path: "id", ocsf_field: "finding_info.uid" }
- { source_path: "reason", ocsf_field: "finding_info.title" }
- { source_path: "type", ocsf_field: "finding_info.desc" }
- { source_path: "first_event_timestamp", ocsf_field: "finding_info.created_time" }
- { source_path: "backend_update_timestamp", ocsf_field: "finding_info.modified_time" }
- { source_path: "alert_url", ocsf_field: "finding_info.src_url" }
- { source_path: "workflow.status", ocsf_field: "status" }
- { source_path: "determination.value", ocsf_field: "status_detail" }
# ── Endpoint / device ──────────────────────────────────────────────
- { source_path: "device_id", ocsf_field: "device.uid" }
- { source_path: "device_os_version", ocsf_field: "device.os.build" }
- { source_path: "device_name", ocsf_field: "src_endpoint.hostname" }
- { source_path: "device_os", ocsf_field: "src_endpoint.os.name" }
- { source_path: "device_internal_ip", ocsf_field: "src_endpoint.ip" }
- { source_path: "device_username", ocsf_field: "user.name" }
- { source_path: "org_key", ocsf_field: "cloud.account.uid" }
# ── Triggering process — the actor ─────────────────────────────────
- { source_path: "process_name", ocsf_field: "actor.process.name" }
- { source_path: "process_pid", ocsf_field: "actor.process.pid" }
- { source_path: "process_cmdline", ocsf_field: "actor.process.cmd_line" }
- { source_path: "process_sha256", ocsf_field: "actor.process.file.hashes.sha256" }
- { source_path: "process_md5", ocsf_field: "actor.process.file.hashes.md5" }
- { source_path: "process_guid", ocsf_field: "metadata.original_event_uid" }
- { source_path: "process_username", ocsf_field: "actor.user.name" }
# ── Parent process ──────────────────────────────────────────────────
- { source_path: "parent_name", ocsf_field: "actor.process.parent_process.name" }
- { source_path: "parent_pid", ocsf_field: "actor.process.parent_process.pid" }
- { source_path: "parent_cmdline", ocsf_field: "actor.process.parent_process.cmd_line" }
- { source_path: "parent_sha256", ocsf_field: "actor.process.parent_process.file.hashes.sha256" }
# ── Child / target process ──────────────────────────────────────────
- { source_path: "childproc_name", ocsf_field: "process.name" }
- { source_path: "childproc_pid", ocsf_field: "process.pid" }
- { source_path: "childproc_cmdline", ocsf_field: "process.cmd_line" }
- { source_path: "childproc_guid", ocsf_field: "process.uid" }
# ── Detection rule / watchlist / report ─────────────────────────────
- { source_path: "watchlists[0].name", ocsf_field: "rule.name" }
- { source_path: "watchlists[0].id", ocsf_field: "rule.uid" }
- { source_path: "report_name", ocsf_field: "rule.desc" }
# ── IOC / observable ──────────────────────────────────────────────
- { source_path: "ioc_hit", ocsf_field: "observables.value" }
- { source_path: "ioc_field", ocsf_field: "observables.type" }
@@ -62,7 +62,7 @@ commands:
outputs_schema: { properties: {} }
- id: list_events
name: cisco-amp-list-events
description: "List events."
description: "List events. Used for ingestion: results path = data."
risk: read
inputs_schema:
properties:
@@ -70,6 +70,9 @@ commands:
limit: { type: number, description: "Max events (default 50)" }
required: []
outputs_schema: { properties: {} }
ingest:
results_path: data
dedup_key: id
- id: get_trajectory
name: cisco-amp-get-trajectory
description: "Get a computer's device trajectory."
@@ -88,3 +91,8 @@ commands:
properties: {}
required: []
outputs_schema: { properties: {} }
ingestion:
command: list_events
mapper: list_events
default_incident_type: "Cisco Secure Endpoint Event"
@@ -0,0 +1,35 @@
name: "Cisco Secure Endpoint Events → OCSF"
description: "Maps a Cisco AMP for Endpoints event object (GET /v1/events, results_path = data) to OCSF. The detected file is the subject (file.*/malware.*); the process that dropped/ran it (file.parent.*) is the actor (actor.process.*). Field paths validated against the AMP API v1 event schema (Cisco docs + demisto/content CiscoAMP pack README)."
field_mappings:
title: "detection ? detection : event_type"
severity: "severity = 'Critical' ? 5 : (severity = 'High' ? 4 : (severity = 'Medium' ? 3 : 2))"
description: "event_type"
# results_path = data; source_path is JSONata over ONE event object.
# Paths absent from a given event are skipped at ingestion, so extra entries are safe.
ocsf:
# ── Endpoint / device ──────────────────────────────────────────────
- { source_path: "computer.hostname", ocsf_field: "src_endpoint.hostname" }
- { source_path: "computer.external_ip", ocsf_field: "src_endpoint.ip" }
- { source_path: "computer.connector_guid", ocsf_field: "device.uid" }
- { source_path: "computer.user", ocsf_field: "user.name" }
# ── Finding / analytic ─────────────────────────────────────────────
- { source_path: "id", ocsf_field: "finding_info.uid" }
- { source_path: "timestamp", ocsf_field: "finding_info.created_time" }
- { source_path: "event_type", ocsf_field: "finding_info.analytic.name" }
- { source_path: "event_type_id", ocsf_field: "finding_info.analytic.uid" }
- { source_path: "file.disposition", ocsf_field: "disposition" }
# ── Malware / detection ────────────────────────────────────────────
- { source_path: "detection", ocsf_field: "malware.name" }
- { source_path: "detection_id", ocsf_field: "malware.uid" }
# ── Detected file — the subject ────────────────────────────────────
- { source_path: "file.file_name", ocsf_field: "file.name" }
- { source_path: "file.file_path", ocsf_field: "file.path" }
- { source_path: "file.identity.sha256", ocsf_field: "file.hashes.sha256" }
- { source_path: "file.identity.sha1", ocsf_field: "file.hashes.sha1" }
- { source_path: "file.identity.md5", ocsf_field: "file.hashes.md5" }
# ── Parent process (dropped/ran the file) — the actor ──────────────
- { source_path: "file.parent.file_name", ocsf_field: "actor.process.name" }
- { source_path: "file.parent.process_id", ocsf_field: "actor.process.pid" }
- { source_path: "file.parent.identity.sha256", ocsf_field: "actor.process.file.hashes.sha256" }
- { source_path: "file.parent.identity.sha1", ocsf_field: "actor.process.file.hashes.sha1" }
- { source_path: "file.parent.identity.md5", ocsf_field: "actor.process.file.hashes.md5" }
@@ -0,0 +1,3 @@
name: "Cortex XDR Alert"
color: "#ef8354"
icon: "alert"
+33 -10
View File
@@ -1,8 +1,8 @@
id: cortex_xdr
name: Cortex XDR
version: 1.2.0
description: "Palo Alto Cortex XDR (public API v1) — incident ingestion + write-back, endpoint isolation/scan/delete/tagging, RTR scripts, hash block/allow lists, file quarantine/restore/retrieval, alert exclusions, external alert push (parsed/CEF), device-control violations, audits, distributions and RBAC/risk."
changelog: "1.2.0 — Incident write-back (update_incident: status/severity/assignment/resolve comment) and external alert push (insert_parsed_alerts, insert_cef_alerts). 1.1.0 — Full command coverage: added delete/alias/tag endpoints, abort scan, original alerts, script metadata/code/snippet/exec-status, file retrieval (+details), alert exclusions, device-control violations, audits, distribution url/status/create and RBAC (users, roles, groups, risk score, risky users/hosts). 1.0.0 — Initial release: incident ingestion (get_incidents) with OCSF mapper, endpoints, isolate/unisolate, scan, hash blocklist/allowlist, quarantine/restore, run script + results, alerts retrieval, distributions and action status. Standard or Advanced API authentication."
version: 1.3.1
description: "Palo Alto Cortex XDR (public API v1) — incident and alert ingestion + write-back, endpoint isolation/scan/delete/tagging, RTR scripts, hash block/allow lists, file quarantine/restore/retrieval, alert exclusions, external alert push (parsed/CEF), device-control violations, audits, distributions and RBAC/risk."
changelog: "1.3.1 — get_incidents_full can watermark on modification_time (modified_after), which is what ingestion wants: an XDR incident keeps growing after creation, and a creation_time watermark fetches it once and never looks again, so every alert that joins it afterwards is lost. The full-incident fetch command is named cortex-xdr-get-incidents-full (id get_incidents_full), not cortex-xdr-fetch-incidents: sitting next to cortex-xdr-get-incidents in the command list, it now reads as what it is — the same call, everything included. A rule created against the old id must be pointed at the new one. 1.3.0 — Richer incident ingestion (get_incidents_full, on get_multiple_incidents_extra_data): incidents now arrive with their alerts in full and their file/network artifacts, where get_incidents only ever answered a 21-field summary carrying neither hosts, users, MITRE nor a single alert. Alert ingestion, and the alerts endpoint answers at last: get_alerts was sending the incidents dialect ({filters, search_from, search_to, sort}) to a grid endpoint that speaks request_data.filter_data (SEARCH_FIELD/SEARCH_TYPE/SEARCH_VALUE blocks, paging.from/to, sort as a list), and every call came back HTTP 500. Body rebuilt from the reference client, rows unwrapped out of their alert_fields envelope, severity code and the dotted status.progress key derived into readable fields. Alert ingestion: get_alerts is now a fetch command (results path reply.alerts, dedup on alert_id, incremental on source_insert_ts) with a bundled OCSF mapper and a Cortex XDR Alert incident type, so detections can be ingested alongside — or instead of — incidents. The incident mapper is fixed on the way past: its severity expression compared strings, which the mapping engine cannot do, so every ingested incident silently took the rule's default severity; it also now carries a detection anchor so MTTD is measurable. It pages past the API's 100-results-per-call ceiling, and sorts oldest-first on an incremental fetch so a truncated window drops the alerts the next poll can still see. 1.2.1 — Connection troubleshooting: the URL is normalised to the tenant host (a pasted /public_api/v1 or console path no longer breaks the call), a non-JSON reply reports the status, content type and body instead of a bare JSON parse error, missing key/key ID is caught up front, nonce and timestamp are sent in both auth modes as the reference client does, and test_connection now probes get_incidents. 1.2.0 — Incident write-back (update_incident: status/severity/assignment/resolve comment) and external alert push (insert_parsed_alerts, insert_cef_alerts). 1.1.0 — Full command coverage: added delete/alias/tag endpoints, abort scan, original alerts, script metadata/code/snippet/exec-status, file retrieval (+details), alert exclusions, device-control violations, audits, distribution url/status/create and RBAC (users, roles, groups, risk score, risky users/hosts). 1.0.0 — Initial release: incident ingestion (get_incidents) with OCSF mapper, endpoints, isolate/unisolate, scan, hash blocklist/allowlist, quarantine/restore, run script + results, alerts retrieval, distributions and action status. Standard or Advanced API authentication."
category: endpoint
# Per-instance configuration. The base URL is the tenant API root, e.g.
@@ -11,7 +11,7 @@ config_schema:
properties:
url:
type: string
description: "Cortex XDR API base URL, e.g. https://api-<tenant>.xdr.<region>.paloaltonetworks.com"
description: "Tenant API host — Settings → Configurations → API Keys → Copy URL, e.g. https://api-<tenant>.xdr.<region>.paloaltonetworks.com. Not the console URL you log into (that one answers with HTML, not JSON)."
api_key_id:
type: string
description: "API Key ID (the integer ID shown next to the API key in Settings → API Keys)"
@@ -21,7 +21,7 @@ config_schema:
x-soar-sensitive: true
auth_type:
type: string
description: "Authentication method: standard (key sent as-is) or advanced (nonce + timestamp SHA-256 hash)"
description: "How the key was created in Cortex XDR: standard (key sent as-is) or advanced (nonce + timestamp SHA-256 hash). A mismatch here is rejected with 401."
default: standard
required:
- url
@@ -44,7 +44,7 @@ commands:
# ── Ingestion ─────────────────────────────────────────────────────────────
- id: get_incidents
name: cortex-xdr-get-incidents
description: "Fetch Cortex XDR incidents for ingestion. Returns {reply:{incidents:[...]}}; use reply.incidents as the alert rule results path."
description: "List Cortex XDR incidents as a 21-field summary (no hosts, no users, no MITRE, no alerts). Cheap to poll, but for ingestion prefer cortex-xdr-get-incidents-full, which returns the same incidents with their alerts and artifacts. Returns {reply:{incidents:[...]}}."
risk: read
inputs_schema:
properties:
@@ -57,6 +57,23 @@ commands:
results_path: reply.incidents
dedup_key: incident_id
incremental_field: created_after
- id: get_incidents_full
name: cortex-xdr-get-incidents-full
description: "Fetch incidents WITH their alerts and artifacts (get_multiple_incidents_extra_data) — the ingestion command to prefer. get_incidents answers with a 21-field summary carrying no hosts, no users, no MITRE and none of the alerts; this one returns 39 incident fields, every alert in full (156 fields each) and the file/network artifacts. Records are flattened, so mapping expressions written against get_incidents keep working and alerts[], file_artifacts[], network_artifacts[] sit beside them. Returns {reply:{incidents:[...]}}."
risk: read
inputs_schema:
properties:
status: { type: string, description: "Comma-separated statuses to keep (new, under_investigation, resolved_threat_handled…)" }
created_after: { type: string, description: "Lower bound on creation_time, ISO8601 or epoch ms. Watermarking on this fetches each incident once and never revisits it — alerts joining it later never arrive." }
modified_after: { type: string, description: "Lower bound on modification_time, ISO8601 or epoch ms. The watermark to prefer for ingestion: an incident comes back whenever it changes, and dedup on incident_id turns the second visit into an enrichment." }
limit: { type: number, description: "Maximum incidents to fetch (default 50, paged 50 at a time). A full incident weighs a few KB and up to a few hundred with its alerts, so raise this knowingly." }
exclude_artifacts: { type: boolean, description: "Drop the file and network artifact blocks, keeping the alerts (lighter payload)" }
required: []
outputs_schema: { properties: {} }
ingest:
results_path: reply.incidents
dedup_key: incident_id
incremental_field: modified_after
- id: get_incident_extra_data
name: cortex-xdr-get-incident-extra-data
description: "Get full incident data including its alerts and network artifacts by incident ID."
@@ -83,15 +100,21 @@ commands:
outputs_schema: { properties: {} }
- id: get_alerts
name: cortex-xdr-get-alerts
description: "Retrieve alerts using a custom filter (get_alerts_by_filter_data). Returns rich alert objects."
description: "Fetch Cortex XDR alerts for ingestion (get_alerts_by_filter_data). Returns {reply:{alerts:[...]}}; use reply.alerts as the alert rule results path. Each row is unwrapped out of the API's alert_fields envelope and carries a readable severity_name and status_progress, so alerts-grid field names (internal_id, alert_name, agent_hostname) are what mapping expressions see. Alerts are the detection layer under incidents: ingest them alongside get_incidents when the SOC works detections, not only aggregates."
risk: read
inputs_schema:
properties:
severity: { type: string, description: "Comma-separated severities (low, medium, high, critical)" }
created_after: { type: string, description: "Lower bound on alert source_insert_ts, epoch ms" }
limit: { type: number, description: "Maximum alerts to fetch (default 100)" }
severity: { type: string, description: "Comma-separated severities (informational, low, medium, high, critical)" }
created_after: { type: string, description: "Lower bound on alert source_insert_ts, ISO8601 or epoch ms (incremental fetch watermark)" }
limit: { type: number, description: "Maximum alerts to fetch (default 100). The API serves 100 per call at most; above that the script pages until the limit is reached." }
# Left unfiltered, the call is bounded to the last 30 days: the alerts
# grid is not meant to be asked for a tenant's whole retention.
required: []
outputs_schema: { properties: {} }
ingest:
results_path: reply.alerts
dedup_key: internal_id
incremental_field: created_after
- id: insert_parsed_alerts
name: cortex-xdr-insert-parsed-alerts
description: "Push external alerts (parsed JSON objects) into Cortex XDR for correlation."
@@ -0,0 +1,87 @@
name: "Cortex XDR Alerts → OCSF"
description: "Maps one Cortex XDR alert (alerts/get_alerts_by_filter_data/, results_path = reply.alerts) to OCSF Detection Finding fields. Field names are the alerts-grid ones (internal_id, alert_name, agent_hostname…), not the incident ones; the script unwraps the API's alert_fields envelope and derives severity_name and status_progress, which the raw payload cannot express. Alerts are the detection layer under incidents: a tenant ingesting both feeds holds each detection twice, once inside an aggregate and once on its own."
field_mappings:
title: "alert_name"
description: "alert_description"
# severity_name, not severity: the API sends an enum code (SEV_040_HIGH) that
# no severity scale can read, so the script carries the plain name alongside it.
severity: "severity_name"
# Which sensor fired: "XDR Agent", "PAN NGFW", "XDR Analytics"…
source: "alert_source"
# results_path = reply.alerts; source_path is JSONata over ONE alert object.
# Paths absent from a given alert are skipped at ingestion, so entries for fields
# a tenant never emits are safe. Where two entries target the same OCSF field,
# the LAST non-empty one wins — that is how the events[] fallbacks are ordered.
ocsf:
# ── Finding ───────────────────────────────────────────────────────
- { source_path: "internal_id", ocsf_field: "finding_info.uid" }
- { source_path: "external_id", ocsf_field: "finding_info.uid_alt" }
- { source_path: "alert_name", ocsf_field: "finding_info.title" }
- { source_path: "alert_description", ocsf_field: "finding_info.desc" }
- { source_path: "source_insert_ts", ocsf_field: "finding_info.created_time" }
- { source_path: "local_insert_ts", ocsf_field: "finding_info.modified_time" }
- { source_path: "alert_category", ocsf_field: "finding_info.analytic.category" }
- { source_path: "alert_name", ocsf_field: "finding_info.analytic.name" }
- { source_path: "matching_service_rule_id", ocsf_field: "finding_info.analytic.uid" }
# ── Detection time: the MTTD anchor ───────────────────────────────
# `time` is what Riposte measures detection-to-ingestion against. The grid
# exposes when the tenant took the alert in (source_insert_ts); a sensor-side
# detection timestamp, when the tenant sends one, is the better anchor and
# comes last so it wins.
- { source_path: "source_insert_ts", ocsf_field: "time" }
- { source_path: "detection_timestamp", ocsf_field: "time" }
# ── Alert state ───────────────────────────────────────────────────
- { source_path: "severity_name", ocsf_field: "severity" }
- { source_path: "alert_domain", ocsf_field: "activity_name" }
- { source_path: "alert_action_status", ocsf_field: "action" }
- { source_path: "status_progress", ocsf_field: "status" }
- { source_path: "matching_status", ocsf_field: "status_detail" }
- { source_path: "events_length", ocsf_field: "count" }
# The XDR case this alert was folded into — the join back to the incident feed.
- { source_path: "case_id", ocsf_field: "metadata.correlation_uid" }
# ── Product identity ──────────────────────────────────────────────
- { source_path: "'Cortex XDR'", ocsf_field: "metadata.product.name" }
- { source_path: "'Palo Alto Networks'", ocsf_field: "metadata.product.vendor_name" }
- { source_path: "alert_source", ocsf_field: "metadata.log_source" }
# ── MITRE ATT&CK ──────────────────────────────────────────────────
# Both fields arrive as a list on most tenants and as a bare string on some;
# [0] reads the first element either way.
- { source_path: "mitre_tactic_id_and_name[0]", ocsf_field: "attacks.tactic.name" }
- { source_path: "mitre_technique_id_and_name[0]", ocsf_field: "attacks.technique.name" }
# ── Affected endpoint ─────────────────────────────────────────────
- { source_path: "agent_hostname", ocsf_field: "device.hostname" }
- { source_path: "agent_ip_addresses[0]", ocsf_field: "device.ip" }
- { source_path: "agent_id", ocsf_field: "device.uid" }
- { source_path: "agent_os_type", ocsf_field: "device.os.type" }
# Mirrored onto src_endpoint so routers and pre-processing rules written for
# the incident feed (which maps hosts there) match alerts unchanged.
- { source_path: "agent_hostname", ocsf_field: "src_endpoint.hostname" }
- { source_path: "agent_ip_addresses[0]", ocsf_field: "src_endpoint.ip" }
- { source_path: "actor_effective_username", ocsf_field: "user.name" }
# ── What actually happened ────────────────────────────────────────
# Grid columns first, then the same detail read off the first event, which is
# where a tenant that does not flatten these columns puts them.
- { source_path: "actor_process_image_name", ocsf_field: "process.name" }
- { source_path: "actor_process_command_line", ocsf_field: "process.cmd_line" }
- { source_path: "actor_process_image_sha256", ocsf_field: "process.file.hashes.sha256" }
- { source_path: "causality_actor_process_command_line", ocsf_field: "process.parent_process.cmd_line" }
- { source_path: "action_file_path", ocsf_field: "file.path" }
- { source_path: "action_file_sha256", ocsf_field: "file.hashes.sha256" }
- { source_path: "action_file_md5", ocsf_field: "file.hashes.md5" }
- { source_path: "action_registry_key_name", ocsf_field: "reg_key.path" }
- { source_path: "action_registry_data", ocsf_field: "reg_value.data" }
- { source_path: "action_local_ip", ocsf_field: "src_endpoint.ip" }
- { source_path: "action_local_port", ocsf_field: "src_endpoint.port" }
- { source_path: "action_remote_ip", ocsf_field: "dst_endpoint.ip" }
- { source_path: "action_remote_port", ocsf_field: "dst_endpoint.port" }
- { source_path: "dst_action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
- { source_path: "events[0].actor_process_image_name", ocsf_field: "process.name" }
- { source_path: "events[0].actor_process_command_line", ocsf_field: "process.cmd_line" }
- { source_path: "events[0].actor_process_image_path", ocsf_field: "process.path" }
- { source_path: "events[0].actor_process_image_sha256", ocsf_field: "process.file.hashes.sha256" }
- { source_path: "events[0].causality_actor_process_image_name", ocsf_field: "process.parent_process.name" }
- { source_path: "events[0].action_file_path", ocsf_field: "file.path" }
- { source_path: "events[0].action_file_sha256", ocsf_field: "file.hashes.sha256" }
- { source_path: "events[0].action_remote_ip", ocsf_field: "dst_endpoint.ip" }
- { source_path: "events[0].action_remote_port", ocsf_field: "dst_endpoint.port" }
- { source_path: "events[0].action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
@@ -2,7 +2,10 @@ name: "Cortex XDR Incidents → OCSF"
description: "Maps a Cortex XDR incident (incidents/get_incidents/, results_path = reply.incidents) to OCSF finding fields. Incidents are aggregates; use get_incident_extra_data for per-alert detail."
field_mappings:
title: "incident_name"
severity: "severity = 'critical' ? 5 : (severity = 'high' ? 4 : (severity = 'medium' ? 3 : 2))"
# The raw string, not a ternary: the mapping engine compares numbers only, so
# every string test read as false and every incident landed on the rule's
# default severity. Riposte reads critical/high/medium/low onto 1-5 itself.
severity: "severity"
description: "description"
# results_path = reply.incidents; source_path is JSONata over ONE incident object.
# Paths absent from a given incident are skipped at ingestion, so extra entries are safe.
@@ -15,6 +18,9 @@ ocsf:
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
- { source_path: "status", ocsf_field: "status" }
# `time` is the MTTD anchor — when XDR opened the incident, as opposed to when
# Riposte ingested it. Without it the detection delay column stays empty.
- { source_path: "creation_time", ocsf_field: "time" }
- { source_path: "alert_count", ocsf_field: "count" }
# ── MITRE ATT&CK (first aggregated tactic/technique) ──────────────
- { source_path: "mitre_tactics_ids_and_names[0]", ocsf_field: "attacks.tactic.name" }
@@ -0,0 +1,83 @@
name: "Cortex XDR Incidents (full) → OCSF"
description: "Maps one Cortex XDR incident fetched with its alerts and artifacts (incidents/get_multiple_incidents_extra_data/, results_path = reply.incidents) to OCSF finding fields. The script flattens the record, so incident fields sit at the top level — every expression written against get_incidents keeps working — while alerts[], file_artifacts[] and network_artifacts[] are plain lists beside them. Incident-level values are mapped first and the first alert's equivalents last, so the alert wins wherever the aggregate says nothing."
field_mappings:
# incident_name is null on most tenants (it is only set when someone renames
# the incident), and a mapping that resolves to nothing leaves the title to
# the incident type's fallback. description is the sentence XDR itself shows.
title: "description"
description: "description"
severity: "severity"
# incident_source, not incident_sources[0]: this mapper reads dotted paths and
# cannot index a list, so the script lifts the first sensor out for it.
source: "incident_source"
# results_path = reply.incidents; source_path is JSONata over ONE flattened
# incident. Paths absent from a given incident are skipped at ingestion, so
# entries for fields a tenant never emits are safe. Where two entries target the
# same OCSF field, the LAST non-empty one wins.
ocsf:
# ── Finding ───────────────────────────────────────────────────────
- { source_path: "incident_id", ocsf_field: "finding_info.uid" }
- { source_path: "incident_name ? incident_name : description", ocsf_field: "finding_info.title" }
- { source_path: "description", ocsf_field: "finding_info.desc" }
- { source_path: "creation_time", ocsf_field: "finding_info.created_time" }
- { source_path: "modification_time", ocsf_field: "finding_info.modified_time" }
- { source_path: "xdr_url", ocsf_field: "finding_info.src_url" }
- { source_path: "alert_categories[0]", ocsf_field: "finding_info.analytic.category" }
- { source_path: "alerts[0].name", ocsf_field: "finding_info.analytic.name" }
# ── Detection time: the MTTD anchor ───────────────────────────────
# Weakest first, strongest last. detection_time is often null on an XDR
# incident, and then the first alert's own detection timestamp is the honest
# anchor; incident creation is the last resort.
- { source_path: "creation_time", ocsf_field: "time" }
- { source_path: "alerts[0].detection_timestamp", ocsf_field: "time" }
- { source_path: "detection_time", ocsf_field: "time" }
# ── Incident state ────────────────────────────────────────────────
- { source_path: "severity", ocsf_field: "severity" }
- { source_path: "status", ocsf_field: "status" }
- { source_path: "resolve_comment", ocsf_field: "status_detail" }
- { source_path: "alert_count", ocsf_field: "count" }
- { source_path: "aggregated_score", ocsf_field: "risk_score" }
- { source_path: "tags", ocsf_field: "metadata.labels" }
- { source_path: "alerts[0].action_pretty", ocsf_field: "action" }
# ── Product identity ──────────────────────────────────────────────
- { source_path: "'Cortex XDR'", ocsf_field: "metadata.product.name" }
- { source_path: "'Palo Alto Networks'", ocsf_field: "metadata.product.vendor_name" }
- { source_path: "incident_sources[0]", ocsf_field: "metadata.log_source" }
# ── MITRE ATT&CK: the aggregate, else the first alert ─────────────
- { source_path: "mitre_tactics_ids_and_names[0]", ocsf_field: "attacks.tactic.name" }
- { source_path: "mitre_techniques_ids_and_names[0]", ocsf_field: "attacks.technique.name" }
- { source_path: "alerts[0].mitre_tactic_id_and_name[0]", ocsf_field: "attacks.tactic.name" }
- { source_path: "alerts[0].mitre_technique_id_and_name[0]", ocsf_field: "attacks.technique.name" }
# ── Affected host / user ──────────────────────────────────────────
# An incident's hosts are 'hostname:agent_id' strings; an alert names them plainly.
- { source_path: "$split(hosts[0], ':')[0]", ocsf_field: "src_endpoint.hostname" }
- { source_path: "$split(hosts[0], ':')[0]", ocsf_field: "device.hostname" }
- { source_path: "alerts[0].host_name", ocsf_field: "src_endpoint.hostname" }
- { source_path: "alerts[0].host_name", ocsf_field: "device.hostname" }
- { source_path: "alerts[0].host_ip[0]", ocsf_field: "device.ip" }
- { source_path: "alerts[0].host_ip[0]", ocsf_field: "src_endpoint.ip" }
- { source_path: "alerts[0].endpoint_id", ocsf_field: "device.uid" }
- { source_path: "alerts[0].agent_os_type", ocsf_field: "device.os.type" }
- { source_path: "users[0]", ocsf_field: "user.name" }
- { source_path: "alerts[0].user_name", ocsf_field: "user.name" }
# ── What the first alert actually saw ─────────────────────────────
- { source_path: "alerts[0].actor_process_image_name", ocsf_field: "process.name" }
- { source_path: "alerts[0].actor_process_command_line", ocsf_field: "process.cmd_line" }
- { source_path: "alerts[0].actor_process_image_path", ocsf_field: "process.path" }
- { source_path: "alerts[0].actor_process_image_sha256", ocsf_field: "process.file.hashes.sha256" }
- { source_path: "alerts[0].causality_actor_process_image_name", ocsf_field: "process.parent_process.name" }
- { source_path: "alerts[0].action_file_path", ocsf_field: "file.path" }
- { source_path: "alerts[0].action_file_name", ocsf_field: "file.name" }
- { source_path: "alerts[0].action_file_sha256", ocsf_field: "file.hashes.sha256" }
- { source_path: "alerts[0].action_file_md5", ocsf_field: "file.hashes.md5" }
- { source_path: "alerts[0].action_remote_ip", ocsf_field: "dst_endpoint.ip" }
- { source_path: "alerts[0].action_remote_port", ocsf_field: "dst_endpoint.port" }
- { source_path: "alerts[0].action_external_hostname", ocsf_field: "dst_endpoint.hostname" }
# ── The artifact the incident is really about ─────────────────────
# Last, because a file artifact is the incident's verdict on the file, where
# the alert only reports what one detection touched.
- { source_path: "file_artifacts[0].file_name", ocsf_field: "file.name" }
- { source_path: "file_artifacts[0].file_sha256", ocsf_field: "file.hashes.sha256" }
- { source_path: "file_artifacts[0].file_wildfire_verdict", ocsf_field: "malware.classifications" }
- { source_path: "network_artifacts[0].network_remote_ip", ocsf_field: "dst_endpoint.ip" }
- { source_path: "network_artifacts[0].network_domain", ocsf_field: "dst_endpoint.hostname" }
+40 -8
View File
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
+151 -17
View File
@@ -3,29 +3,61 @@ from datetime import datetime
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v):
@@ -44,19 +76,121 @@ def to_ms(v):
return None
# This endpoint speaks the alerts-grid dialect, NOT the incidents one: a body of
# {filters, search_from, search_to, sort} — what incidents/get_incidents/ takes —
# is answered with a bare HTTP 500. It wants request_data.filter_data with
# SEARCH_FIELD/SEARCH_TYPE/SEARCH_VALUE blocks, paging.from/to and a sort LIST.
# Shape taken from the reference client (demisto/content,
# Packs/ApiModules/Scripts/CoreIRApiModule — get_alerts_by_filter_command).
PAGE = 100
# Severity travels as an enum code both ways. Riposte reads plain names onto its
# 1-5 scale, so alerts carry `severity_name` alongside the raw code.
SEVERITY_CODE_TO_NAME = {
"SEV_010_INFO": "informational",
"SEV_020_LOW": "low",
"SEV_030_MEDIUM": "medium",
"SEV_040_HIGH": "high",
"SEV_050_CRITICAL": "critical",
}
SEVERITY_NAME_TO_CODE = dict((v, k) for k, v in SEVERITY_CODE_TO_NAME.items())
SEVERITY_NAME_TO_CODE["info"] = "SEV_010_INFO"
# Our clock and the tenant's are not the same clock. A range that ends exactly
# now silently drops alerts the tenant stamped a few seconds ahead of us.
SKEW_MS = 5 * 60 * 1000
# Window applied when the caller passes no filter at all — see main().
DEFAULT_LOOKBACK_MS = 30 * 24 * 60 * 60 * 1000
def severity_block(value):
"""One EQ block per severity, OR'd together (the reference client's array rule)."""
blocks = [
{"SEARCH_FIELD": "severity", "SEARCH_TYPE": "EQ",
"SEARCH_VALUE": SEVERITY_NAME_TO_CODE.get(s.lower(), s.upper())}
for s in csv(value)
]
if not blocks:
return None
return blocks[0] if len(blocks) == 1 else {"OR": blocks}
def flatten(item):
"""One grid row -> one flat alert.
The API wraps every row as {alert_fields, incident_fields}. Mapping through
that wrapper would put an `alert_fields.` prefix on every expression an
operator writes, so the row is unwrapped here and the two fields Riposte
cannot express are derived: `status.progress` carries a dot INSIDE the key
(unusable as a mapping path) and severity is an enum code.
"""
fields = item.get("alert_fields")
alert = dict(fields) if isinstance(fields, dict) else dict(item)
alert.pop("incident_fields", None)
if "status.progress" in alert:
alert["status_progress"] = alert.pop("status.progress")
name = SEVERITY_CODE_TO_NAME.get(alert.get("severity"))
if name:
alert["severity_name"] = name
incident = item.get("incident_fields")
if isinstance(incident, dict):
alert["incident_fields"] = incident
return alert
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
limit = int(inputs.get("limit") or 100)
filters = []
if inputs.get("severity"):
filters.append({"field": "severity", "operator": "in", "value": csv(inputs["severity"])})
limit = max(1, int(inputs.get("limit") or 100))
conditions = []
sev = severity_block(inputs.get("severity"))
if sev:
conditions.append(sev)
created_ms = to_ms(inputs.get("created_after"))
if created_ms is not None:
filters.append({"field": "source_insert_ts", "operator": "gte", "value": created_ms})
rd = {"search_from": 0, "search_to": limit, "sort": {"field": "source_insert_ts", "keyword": "desc"}}
if filters:
rd["filters"] = filters
print(json.dumps(post("/alerts/get_alerts_by_filter_data/", rd)))
conditions.append({
"SEARCH_FIELD": "source_insert_ts",
"SEARCH_TYPE": "RANGE",
"SEARCH_VALUE": {"from": created_ms, "to": int(time.time() * 1000) + SKEW_MS},
})
if not conditions:
# The reference client refuses a filterless query outright, and an
# unbounded scan of the whole alerts grid is not what the API is for.
# A recent window is a better default than an empty filter the tenant
# may well answer with a 500.
now_ms = int(time.time() * 1000)
conditions.append({
"SEARCH_FIELD": "source_insert_ts",
"SEARCH_TYPE": "RANGE",
"SEARCH_VALUE": {"from": now_ms - DEFAULT_LOOKBACK_MS, "to": now_ms + SKEW_MS},
})
# Oldest first on an incremental fetch, so that a window holding more alerts
# than `limit` drops its most RECENT ones — the only ones the next poll can
# still see. Newest first otherwise, which is what an operator running the
# command by hand is asking for.
order = "ASC" if created_ms is not None else "DESC"
alerts, truncated = [], False
while len(alerts) < limit:
rd = {"filter_data": {
"sort": [{"FIELD": "source_insert_ts", "ORDER": order}],
"paging": {"from": len(alerts), "to": min(len(alerts) + PAGE, limit)},
"filter": {"AND": conditions},
}}
reply = (post("/alerts/get_alerts_by_filter_data/", rd) or {}).get("reply") or {}
page = reply.get("alerts") or []
alerts.extend(flatten(a) for a in page)
if len(page) < PAGE:
break
# Stopped on the ceiling rather than on an exhausted window: whatever is
# left is not coming back on the next poll, and a silent cap reads like
# a quiet feed.
truncated = len(alerts) >= limit
out = {"result_count": len(alerts), "alerts": alerts}
if truncated:
out["truncated"] = True
print(json.dumps({"reply": out}))
try:
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
@@ -3,29 +3,61 @@ from datetime import datetime
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def to_ms(v):
@@ -0,0 +1,184 @@
import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.error
from datetime import datetime
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def to_ms(v):
if v in (None, ""):
return None
s = str(v)
if s.isdigit():
return int(s)
try:
return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() * 1000)
except Exception:
return None
def csv(v):
return [x.strip() for x in str(v or "").split(",") if x.strip()]
# incidents/get_incidents/ answers with a 21-field summary: no hosts, no users,
# no MITRE, and above all not one of the alerts the incident aggregates. This
# endpoint returns the same incident with 39 fields, its alerts in full (156
# fields each) and its file/network artifacts — which is why the reference
# client fetches through it and not through get_incidents (demisto/content,
# CortexXDRIR.get_multiple_incidents_extra_data).
PAGE = 50
# Artifacts are dropped by name, not by omission — the API only understands
# being told which blocks to leave out.
ARTIFACT_BLOCKS = ["network_artifacts", "file_artifacts"]
def flatten(item):
"""One record -> one incident.
Records arrive as {incident, alerts, network_artifacts, file_artifacts},
each nested block wrapped as {total_count, data}. Flattening the incident to
the top level keeps every expression written against get_incidents working
unchanged the summary's 21 fields are a subset of these 39 — while the
alerts and artifacts land beside them as plain lists.
"""
incident = dict(item.get("incident") or {})
for key in ("alerts", "network_artifacts", "file_artifacts"):
block = item.get(key)
if not isinstance(block, dict):
continue
incident[key] = block.get("data") or []
if block.get("total_count") is not None:
# The tenant caps alerts per incident (50 by default), so the count
# says when the list is a sample rather than the whole set.
incident[key + "_total_count"] = block["total_count"]
# The producing sensor is a list here, and the incident-field mapper reads
# dotted paths only — no array indexing — so the first source is lifted out
# for it. The list itself stays, for expressions that can index.
sources = incident.get("incident_sources")
if isinstance(sources, list) and sources:
incident["incident_source"] = sources[0]
return incident
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
limit = max(1, int(inputs.get("limit") or 50))
filters = []
if inputs.get("status"):
statuses = csv(inputs["status"])
filters.append({"field": "status", "operator": "in", "value": statuses})
created_ms = to_ms(inputs.get("created_after"))
if created_ms is not None:
filters.append({"field": "creation_time", "operator": "gte", "value": created_ms})
# An XDR incident keeps growing after it is created: alerts join it, an
# analyst changes its status. Watermarking on creation_time fetches it once
# and never looks again, so everything that happened afterwards is lost.
# Watermarking on modification_time brings it back on every change, where
# dedup on incident_id turns the second visit into an enrichment.
modified_ms = to_ms(inputs.get("modified_after"))
if modified_ms is not None:
filters.append({"field": "modification_time", "operator": "gte", "value": modified_ms})
incremental = created_ms is not None or modified_ms is not None
# Oldest first on an incremental fetch, so that a window holding more
# incidents than `limit` drops its most RECENT ones — the only ones the next
# poll can still see. Newest first otherwise, for a hand-run command.
sort_field = "modification_time" if modified_ms is not None else "creation_time"
keyword = "asc" if incremental else "desc"
exclude = str(inputs.get("exclude_artifacts") or "").lower() in ("1", "true", "yes")
incidents, total = [], None
while len(incidents) < limit:
rd = {
"search_from": len(incidents),
"search_to": min(len(incidents) + PAGE, limit),
"sort": {"field": sort_field, "keyword": keyword},
# Without this the nested alerts come back trimmed to a handful of
# fields — the very thing this command exists to avoid.
"full_alert_fields": True,
}
if filters:
rd["filters"] = filters
if exclude:
rd["fields_to_exclude"] = ARTIFACT_BLOCKS
reply = (post("/incidents/get_multiple_incidents_extra_data/", rd) or {}).get("reply") or {}
page = reply.get("incidents") or []
if total is None:
total = reply.get("total_count")
incidents.extend(flatten(i) for i in page)
if len(page) < PAGE:
break
out = {"result_count": len(incidents), "incidents": incidents}
if total is not None:
out["total_count"] = total
# Say it when the window held more than the limit: those incidents are
# not coming back on the next poll.
out["truncated"] = total > len(incidents)
print(json.dumps({"reply": out}))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
+40 -8
View File
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
+40 -8
View File
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
+40 -8
View File
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
+40 -8
View File
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
+40 -8
View File
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def csv(v):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post_raw(path, body):
base, headers = _client()
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def post(path, request_data=None):
@@ -2,39 +2,83 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
print(json.dumps(post("/distributions/get_versions/", {})))
# Same probe as the reference client: reading one incident proves the URL,
# the key pair and the "View Cases and Issues" right ingestion depends on.
reply = post("/incidents/get_incidents/", {"search_from": 0, "search_to": 1}).get("reply", {})
print(json.dumps({"ok": True, "total_count": reply.get("total_count")}))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
detail = e.read().decode("utf-8", "replace")
if e.code in (401, 403):
# The two ways a valid-looking key still gets rejected: the wrong auth
# mode for the key, or a host clock too far from the tenant's.
detail += (
" — check auth_type matches how the key was created in Cortex XDR"
" (Standard vs Advanced), that the key has not been revoked, and that"
" this host's clock is in sync"
)
print(json.dumps({"error": "HTTP " + str(e.code), "detail": detail}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
@@ -2,29 +2,61 @@ import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.err
def _client():
s = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
base = s.get("url", "").rstrip("/") + "/public_api/v1"
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
raw = str(s.get("url") or "").strip().rstrip("/")
if not raw:
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
if "://" not in raw:
raw = "https://" + raw
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
# /public_api/v1, a console path) so the API root is built exactly once.
scheme, _, rest = raw.partition("://")
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
key = s.get("api_key", "")
kid = str(s.get("api_key_id", ""))
headers = {"x-xdr-auth-id": kid, "Content-Type": "application/json", "Accept": "application/json"}
if not key or not kid:
raise ValueError("api_key and api_key_id are both required")
# Nonce and timestamp ride along in both modes, as the reference client does.
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers = {
"x-xdr-auth-id": kid,
"x-xdr-nonce": nonce,
"x-xdr-timestamp": ts,
"Content-Type": "application/json",
"Accept": "application/json",
}
if str(s.get("auth_type") or "standard").lower() == "advanced":
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
ts = str(int(time.time()) * 1000)
headers["x-xdr-nonce"] = nonce
headers["x-xdr-timestamp"] = ts
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
else:
headers["Authorization"] = key
return base, headers
def _not_json(r, raw):
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
return (
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
+ " — check the configured url is the tenant API host"
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
)
def post(path, request_data):
base, headers = _client()
data = json.dumps({"request_data": request_data}).encode("utf-8")
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise ValueError(_not_json(r, raw))
def main():
+10 -1
View File
@@ -31,7 +31,7 @@ config_schema:
commands:
- id: query_malops
name: cybereason-query-malops
description: "Query Malops (malicious operations) in a time window."
description: "Query Malops (malicious operations) in a time window. Used for ingestion: results path = malops."
risk: read
inputs_schema:
properties:
@@ -39,6 +39,10 @@ commands:
end_time: { type: number, description: "End time as Unix epoch milliseconds" }
required: []
outputs_schema: { properties: {} }
ingest:
results_path: malops
dedup_key: guid
incremental_field: start_time
- id: list_sensors
name: cybereason-list-sensors
description: "List sensors (endpoints)."
@@ -93,3 +97,8 @@ commands:
properties: {}
required: []
outputs_schema: { properties: {} }
ingestion:
command: query_malops
mapper: query_malops
default_incident_type: "Cybereason Malop"
@@ -0,0 +1,13 @@
name: "Cybereason Malops → OCSF"
description: "Maps the raw items of the 'malops' array returned by POST /rest/detection/inbox (results_path = malops) to OCSF finding fields. Each element is the flat, non-nested Malop-inbox shape (guid, edr, displayName, status, severity, creationTime, machines[], users[]) — not the enriched elementValues/simpleValues shape returned by /rest/crimes/unified."
field_mappings:
title: "displayName"
severity: "severity = 'Critical' ? 5 : (severity = 'High' ? 4 : (severity = 'Medium' ? 3 : (severity = 'Low' ? 2 : 3)))"
description: "\"Cybereason Malop — severity: \" & severity & \", status: \" & status"
ocsf:
- { source_path: "guid", ocsf_field: "finding_info.uid" }
- { source_path: "displayName", ocsf_field: "finding_info.title" }
- { source_path: "creationTime", ocsf_field: "finding_info.created_time" }
- { source_path: "status", ocsf_field: "status" }
- { source_path: "machines[0].displayName", ocsf_field: "src_endpoint.hostname" }
- { source_path: "$count(machines)", ocsf_field: "count" }
+40
View File
@@ -0,0 +1,40 @@
id: feed_botvrij
name: Botvrij.eu Feed
version: 1.0.0
description: "Botvrij.eu OSINT feed connector — pull the free community IOC lists (destination IPs, domains, hostnames, URLs, file hashes, filenames, e-mails) and emit normalized IOCs (value + type) for import into the Threat Indicator Manager. Free, no authentication required; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: fetch a botvrij.eu IOC list by type."
category: feed
# Botvrij.eu publishes free plain-text IOC lists over HTTPS. No key required.
config_schema:
properties:
list:
type: string
description: "Which list: ip-dst, domain, hostname, url, md5, sha1, sha256, filename, email-src. Default ip-dst"
default: "ip-dst"
insecure:
type: boolean
description: "Trust any TLS certificate (not secure)"
default: false
required: []
commands:
- id: fetch_indicators
name: feed-botvrij-fetch-indicators
description: "Fetch a botvrij.eu IOC list and return normalized indicators."
risk: read
inputs_schema:
properties:
list: { type: string, description: "ip-dst | domain | hostname | url | md5 | sha1 | sha256 | filename | email-src (overrides config)" }
max_indicators: { type: number, description: "Max indicators to return (0 = no limit, default 0)" }
required: []
outputs_schema: { properties: {} }
- id: test_connection
name: feed-botvrij-test-connection
description: "Verify the botvrij.eu feed is reachable (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,74 @@
import json, os, sys, ssl, urllib.request, urllib.error
BASE = "https://www.botvrij.eu/data/ioclist."
# botvrij list name -> normalized indicator type
TYPES = {
"ip-dst": "ip",
"domain": "domain",
"hostname": "domain",
"url": "url",
"md5": "hash",
"sha1": "hash",
"sha256": "hash",
"filename": "filename",
"email-src": "email",
}
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _ctx(cfg):
if cfg.get("insecure"):
c = ssl.create_default_context()
c.check_hostname = False
c.verify_mode = ssl.CERT_NONE
return c
return None
def _get(url, cfg):
req = urllib.request.Request(url, headers={"User-Agent": "Riposte-SOAR"})
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
return r.read()
def _run(fn):
try:
print(json.dumps(fn(_cfg(), _inputs())))
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
def main(cfg, inputs):
key = str(inputs.get("list") or cfg.get("list") or "ip-dst").lower()
if key not in TYPES:
key = "ip-dst"
typ = TYPES[key]
raw = _get(BASE + key, cfg).decode("utf-8", "replace")
maxn = int(inputs.get("max_indicators") or 0)
out = []
for line in raw.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
out.append({"value": line, "type": typ, "list": key})
if maxn and len(out) >= maxn:
break
return {"source": "botvrij:" + key, "count": len(out), "indicators": out}
_run(main)
@@ -0,0 +1,47 @@
import json, os, sys, ssl, urllib.request, urllib.error
BASE = "https://www.botvrij.eu/data/ioclist."
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _ctx(cfg):
if cfg.get("insecure"):
c = ssl.create_default_context()
c.check_hostname = False
c.verify_mode = ssl.CERT_NONE
return c
return None
def _get(url, cfg):
req = urllib.request.Request(url, headers={"User-Agent": "Riposte-SOAR"})
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
return r.read()
def _run(fn):
try:
print(json.dumps(fn(_cfg(), _inputs())))
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
def main(cfg, inputs):
key = str(cfg.get("list") or "ip-dst").lower()
raw = _get(BASE + key, cfg).decode("utf-8", "replace")
n = sum(1 for ln in raw.splitlines() if ln.strip() and not ln.startswith("#"))
return {"ok": True, "sample_count": n, "list": key}
_run(main)
+46
View File
@@ -0,0 +1,46 @@
id: feed_office365
name: Microsoft 365 Endpoints Feed
version: 1.0.0
description: "Microsoft 365 endpoints feed connector — pull the official published IP ranges and URLs for Microsoft 365 / Office 365 services and emit normalized IOCs (CIDR and domain + type, with service area) for import into the Threat Indicator Manager. Intended as an allowlist / known-infrastructure feed (mark benign in TIM). Free public endpoint, no authentication required; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: fetch the Microsoft 365 worldwide endpoints (IPs + URLs)."
category: feed
# The endpoints.office.com service is free. instance selects the cloud
# (Worldwide, USGovDoD, USGovGCCHigh, China, Germany).
config_schema:
properties:
instance:
type: string
description: "Which M365 cloud instance: Worldwide, USGovDoD, USGovGCCHigh, China, Germany (default Worldwide)"
default: "Worldwide"
include_urls:
type: boolean
description: "Also emit the published service URLs as domain indicators (default true)"
default: true
insecure:
type: boolean
description: "Trust any TLS certificate (not secure)"
default: false
required: []
commands:
- id: fetch_indicators
name: feed-office365-fetch-indicators
description: "Fetch the Microsoft 365 endpoints and return normalized indicators."
risk: read
inputs_schema:
properties:
instance: { type: string, description: "Cloud instance (overrides config)" }
include_urls: { type: boolean, description: "Emit URLs as domain indicators (overrides config)" }
max_indicators: { type: number, description: "Max indicators to return (0 = no limit, default 0)" }
required: []
outputs_schema: { properties: {} }
- id: test_connection
name: feed-office365-test-connection
description: "Verify the Microsoft 365 endpoints service is reachable (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,89 @@
import json, os, sys, ssl, urllib.parse, urllib.request, urllib.error
BASE = "https://endpoints.office.com/endpoints/"
# A fixed client request id is acceptable for this public, unauthenticated API.
CLIENT_ID = "b10c5ed1-bad1-445f-b386-b919946339a7"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _ctx(cfg):
if cfg.get("insecure"):
c = ssl.create_default_context()
c.check_hostname = False
c.verify_mode = ssl.CERT_NONE
return c
return None
def _get(url, cfg):
req = urllib.request.Request(
url, headers={"User-Agent": "Riposte-SOAR", "Accept": "application/json"}
)
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
return r.read()
def _run(fn):
try:
print(json.dumps(fn(_cfg(), _inputs())))
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
def main(cfg, inputs):
instance = str(inputs.get("instance") or cfg.get("instance") or "Worldwide")
include_urls = inputs.get("include_urls")
if include_urls is None:
include_urls = cfg.get("include_urls")
if include_urls is None:
include_urls = True
url = BASE + urllib.parse.quote(instance, safe="") + "?" + urllib.parse.urlencode({"clientrequestid": CLIENT_ID})
raw = _get(url, cfg)
data = json.loads(raw) if raw else []
if not isinstance(data, list):
data = []
maxn = int(inputs.get("max_indicators") or 0)
seen = set()
out = []
def add(value, typ, area):
key = (typ, value)
if key in seen:
return False
seen.add(key)
out.append({"value": value, "type": typ, "service_area": area, "provider": "microsoft365"})
return not (maxn and len(out) >= maxn)
for ep in data:
if not isinstance(ep, dict):
continue
area = ep.get("serviceArea")
for cidr in ep.get("ips", []) or []:
typ = "cidr" if "/" in str(cidr) else "ip"
if not add(str(cidr), typ, area):
return {"source": "microsoft365:" + instance, "count": len(out), "indicators": out}
if include_urls:
for u in ep.get("urls", []) or []:
dom = str(u).lstrip("*.").strip()
if not dom:
continue
if not add(dom, "domain", area):
return {"source": "microsoft365:" + instance, "count": len(out), "indicators": out}
return {"source": "microsoft365:" + instance, "count": len(out), "indicators": out}
_run(main)
@@ -0,0 +1,52 @@
import json, os, sys, ssl, urllib.parse, urllib.request, urllib.error
BASE = "https://endpoints.office.com/endpoints/"
CLIENT_ID = "b10c5ed1-bad1-445f-b386-b919946339a7"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _ctx(cfg):
if cfg.get("insecure"):
c = ssl.create_default_context()
c.check_hostname = False
c.verify_mode = ssl.CERT_NONE
return c
return None
def _get(url, cfg):
req = urllib.request.Request(
url, headers={"User-Agent": "Riposte-SOAR", "Accept": "application/json"}
)
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
return r.read()
def _run(fn):
try:
print(json.dumps(fn(_cfg(), _inputs())))
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
def main(cfg, inputs):
instance = str(cfg.get("instance") or "Worldwide")
url = BASE + urllib.parse.quote(instance, safe="") + "?" + urllib.parse.urlencode({"clientrequestid": CLIENT_ID})
raw = _get(url, cfg)
data = json.loads(raw) if raw else []
n = len(data) if isinstance(data, list) else 0
return {"ok": True, "endpoint_sets": n, "instance": instance}
_run(main)
+43
View File
@@ -0,0 +1,43 @@
id: feed_rss
name: RSS/Atom IOC Feed
version: 1.0.0
description: "Generic RSS/Atom threat-intel feed connector — fetch a security blog or advisory feed and extract indicators (URLs, IPs and file hashes, including common defanged forms like hxxp and 1[.]2[.]3[.]4) from the item titles and bodies, emitting normalized IOCs for import into the Threat Indicator Manager. One connector, many feeds. Optional bearer token; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: extract URL/IP/hash IOCs from an RSS or Atom feed."
category: feed
# The feed is fetched over HTTP(S) from feed_url and parsed as RSS or Atom XML.
config_schema:
properties:
feed_url:
type: string
description: "URL of the RSS/Atom feed"
api_token:
type: string
description: "Optional bearer token (if the feed requires auth)"
x-soar-sensitive: true
insecure:
type: boolean
description: "Trust any TLS certificate (not secure)"
default: false
required:
- feed_url
commands:
- id: fetch_indicators
name: feed-rss-fetch-indicators
description: "Fetch the RSS/Atom feed and return IOCs extracted from its items."
risk: read
inputs_schema:
properties:
max_indicators: { type: number, description: "Max indicators to return (0 = no limit, default 0)" }
required: []
outputs_schema: { properties: {} }
- id: test_connection
name: feed-rss-test-connection
description: "Verify the feed URL returns parseable XML (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,108 @@
import json, os, sys, re, ssl, urllib.request, urllib.error
import xml.etree.ElementTree as ET
_URL = re.compile(r"https?://[^\s<>\"'\]\)]+", re.I)
_IP = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
_HASH = re.compile(r"\b[a-fA-F0-9]{64}\b|\b[a-fA-F0-9]{40}\b|\b[a-fA-F0-9]{32}\b")
def _defang(t):
for a, b in (("[.]", "."), ("(.)", "."), ("{.}", "."), ("[dot]", "."),
("(dot)", "."), ("[:]", ":"), ("[://]", "://"),
("hxxp", "http"), ("hXXp", "http"), ("hxxps", "https")):
t = t.replace(a, b)
return t
def _valid_ip(ip):
parts = ip.split(".")
return len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts)
def _localname(tag):
return tag.split("}")[-1] if "}" in tag else tag
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _ctx(cfg):
if cfg.get("insecure"):
c = ssl.create_default_context()
c.check_hostname = False
c.verify_mode = ssl.CERT_NONE
return c
return None
def _headers(cfg):
h = {"User-Agent": "Riposte-SOAR", "Accept": "application/rss+xml, application/xml, text/xml"}
t = cfg.get("api_token")
if t:
h["Authorization"] = "Bearer " + str(t)
return h
def _get(url, cfg):
req = urllib.request.Request(url, headers=_headers(cfg))
with urllib.request.urlopen(req, timeout=120, context=_ctx(cfg)) as r:
return r.read()
def _run(fn):
try:
print(json.dumps(fn(_cfg(), _inputs())))
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
def main(cfg, inputs):
url = cfg.get("feed_url")
if not url:
raise Exception("feed_url is required")
raw = _get(url, cfg)
root = ET.fromstring(raw)
texts = []
for el in root.iter():
if _localname(el.tag) in ("item", "entry"):
texts.append(_defang(" ".join(el.itertext())))
blob = " ".join(texts) if texts else _defang(ET.tostring(root, encoding="unicode"))
maxn = int(inputs.get("max_indicators") or 0)
seen = set()
out = []
def add(value, typ):
key = (typ, value)
if key in seen:
return
seen.add(key)
out.append({"value": value, "type": typ})
for m in _URL.finditer(blob):
add(m.group(0).rstrip(".,);]"), "url")
for m in _HASH.finditer(blob):
add(m.group(0), "hash")
for m in _IP.finditer(blob):
v = m.group(0)
if _valid_ip(v):
add(v, "ip")
if maxn and len(out) > maxn:
out = out[:maxn]
return {"source": url, "count": len(out), "indicators": out}
_run(main)
@@ -0,0 +1,61 @@
import json, os, sys, ssl, urllib.request, urllib.error
import xml.etree.ElementTree as ET
def _localname(tag):
return tag.split("}")[-1] if "}" in tag else tag
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _ctx(cfg):
if cfg.get("insecure"):
c = ssl.create_default_context()
c.check_hostname = False
c.verify_mode = ssl.CERT_NONE
return c
return None
def _headers(cfg):
h = {"User-Agent": "Riposte-SOAR", "Accept": "application/rss+xml, application/xml, text/xml"}
t = cfg.get("api_token")
if t:
h["Authorization"] = "Bearer " + str(t)
return h
def _get(url, cfg):
req = urllib.request.Request(url, headers=_headers(cfg))
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
return r.read()
def _run(fn):
try:
print(json.dumps(fn(_cfg(), _inputs())))
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
def main(cfg, inputs):
url = cfg.get("feed_url")
if not url:
raise Exception("feed_url is required")
raw = _get(url, cfg)
root = ET.fromstring(raw)
n = sum(1 for el in root.iter() if _localname(el.tag) in ("item", "entry"))
return {"ok": True, "items": n}
_run(main)
+305 -2
View File
@@ -1,8 +1,8 @@
id: harfanglab
name: HarfangLab EDR
version: 1.2.2
version: 1.4.1
description: "HarfangLab EDR — endpoint detection & response: alert/threat ingestion, endpoint enrichment, isolation, threat-intelligence (IOC/whitelist), telemetry hunting and forensic collection jobs."
changelog: "1.2.2 — Documentation wording cleanup. 1.2.1 — Corrected the get_threats OCSF mapper to native threat fields (top_agents/top_impacted_users/top_rules/mitre_tactics) instead of enriched-only arrays; added agent.osversion to get_security_events. 1.2.0 — Added ingestion commands get_security_events (alerts) and get_threats with bundled OCSF mappers and ingest hints (results_path/dedup/incremental). 1.1.1 — Added test_connection for the instance Test button. 1.1.0 — Command names prefixed with 'harfanglab-' (e.g. harfanglab-isolate-endpoint) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: endpoint/agent management, isolation, policy assignment, IOC & whitelist management, security-event triage, telemetry hunting (processes, network, DNS, authentications, binaries, event logs), threat hunting by hash, and forensic collection jobs (pipes, prefetch, run keys, scheduled tasks, drivers, services, processes, network, sessions, WMI, IOC scan, artifacts, RAM dump) with their result retrieval commands. Compatible with HarfangLab EDR 2.13.7+."
changelog: "1.4.1 — get_alerts now maps image_name, the one process path present on every alert subtype and the only one on those carrying neither a process object nor an event log (yara, binary, ioc). HarfangLab prefixes it with the matched threat name in brackets, so both halves are split out: the clean path feeds actor.process.file.path and its basename actor.process.name, and the threat name feeds malware.name — which now surfaces on every subtype rather than only on Defender event-log detections. Each half is guarded on the bracket being present so an unprefixed path is never written into malware.name. 1.4.0 — Mapper coverage from real payloads: get_alerts now maps the Windows event-log payload sigma alerts carry (alert_subtype = eventlog), which was entirely unmapped — Defender 1116/1117 yields the malware name, classification, severity and provider, the detected file, the process that touched it and the acting user, plus the log provider and event code. Also added the agent IP, DNS domain, device type, agent version and groups, the tenant, the alert subtype and threat type, the string severity and the event time. get_threats gained the threat level (previously never reaching the OCSF document at all), the event time and the top agent's reachability. 1.3.1 — Flagged get_alerts as ingestion-ready (ingest hints: results_path/dedup/incremental on from_date) so it appears in the recommended ingestion commands when creating an instance. 1.3.0 — Response & triage expansion: kill/dump process, on-demand AV/YARA scans, file/service/scheduled-task deletion, quarantine management, threat-level isolation, job cancel/relaunch/wait; alert search (get_alerts) and full alert/threat triage (tag, comments, notes, status, level, history, details); exhaustive alert OCSF mappers; reworked get_threats mapper (fixed MITRE tactics map handling, full top-agent/user/rule and counts coverage, rule-based incident title/description). 1.2.2 — Documentation wording cleanup. 1.2.1 — Corrected the get_threats OCSF mapper to native threat fields (top_agents/top_impacted_users/top_rules/mitre_tactics) instead of enriched-only arrays; added agent.osversion to get_security_events. 1.2.0 — Added ingestion commands get_security_events (alerts) and get_threats with bundled OCSF mappers and ingest hints (results_path/dedup/incremental). 1.1.1 — Added test_connection for the instance Test button. 1.1.0 — Command names prefixed with 'harfanglab-' (e.g. harfanglab-isolate-endpoint) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: endpoint/agent management, isolation, policy assignment, IOC & whitelist management, security-event triage, telemetry hunting (processes, network, DNS, authentications, binaries, event logs), threat hunting by hash, and forensic collection jobs (pipes, prefetch, run keys, scheduled tasks, drivers, services, processes, network, sessions, WMI, IOC scan, artifacts, RAM dump) with their result retrieval commands. Compatible with HarfangLab EDR 2.13.7+."
category: endpoint
# Per-instance configuration. Scripts use <url> as the API base and call /api/... paths.
@@ -733,6 +733,309 @@ commands:
required: [job_id]
outputs_schema: { properties: {} }
# ── Response: process ─────────────────────────────────────────────────────
- id: kill_process
name: harfanglab-kill-process
description: "Kill processes on an endpoint matching a criterion (name, path, pid, hash, command line...). Destructive: matching processes are terminated. Returns the created job; poll with harfanglab-wait-for-job."
risk: destructive
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
field: { type: string, description: "Criterion field: name, path, username, commandline, parent_name, parent_path, parent_commandline, pid, md5, sha1, sha256 (default name)" }
operator: { type: string, description: "Criterion operator: eq, not_eq, contains, not_contains (default eq)" }
value: { type: string, description: "Criterion value to match" }
required: [agent_id, value]
outputs_schema: { properties: {} }
- id: dump_process
name: harfanglab-dump-process
description: "Dump the memory of processes matching a criterion (same criteria as kill-process). Returns the created job."
risk: safe_write
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
field: { type: string, description: "Criterion field: name, path, username, commandline, parent_name, parent_path, parent_commandline, pid, md5, sha1, sha256 (default name)" }
operator: { type: string, description: "Criterion operator: eq, not_eq, contains, not_contains (default eq)" }
value: { type: string, description: "Criterion value to match" }
required: [agent_id, value]
outputs_schema: { properties: {} }
# ── Response: scans ───────────────────────────────────────────────────────
- id: av_scan
name: harfanglab-av-scan
description: "Start an on-demand antivirus scan on an endpoint (full, quick, or resource with explicit paths). Returns the created job."
risk: safe_write
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
scan_type: { type: string, description: "Scan type: full, quick or resource (default quick)" }
paths: { type: string, description: "Comma-separated paths to scan (required for resource scans)" }
required: [agent_id]
outputs_schema: { properties: {} }
- id: yara_scan
name: harfanglab-yara-scan
description: "Start a YARA scan on an endpoint using named YARA sources. Returns the created job."
risk: safe_write
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
sources: { type: string, description: "Comma-separated YARA source names" }
directories: { type: string, description: "Comma-separated directories to scan" }
scan_filesystem: { type: boolean, description: "Scan the filesystem (default true)" }
scan_processes: { type: boolean, description: "Scan running processes (default false)" }
required: [agent_id]
outputs_schema: { properties: {} }
# ── Response: remediation ─────────────────────────────────────────────────
- id: delete_file
name: harfanglab-delete-file
description: "Delete a file on an endpoint by full path. Destructive. Returns the created job."
risk: destructive
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
filepath: { type: string, description: "Full path of the file to delete on the host" }
required: [agent_id, filepath]
outputs_schema: { properties: {} }
- id: delete_service
name: harfanglab-delete-service
description: "Delete a Windows service on an endpoint by service name. Destructive. Returns the created job."
risk: destructive
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
service_name: { type: string, description: "Name of the service to delete" }
required: [agent_id, service_name]
outputs_schema: { properties: {} }
- id: delete_scheduled_task
name: harfanglab-delete-scheduled-task
description: "Delete a scheduled task on an endpoint by task URI. Destructive. Returns the created job."
risk: destructive
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
schtask_uri: { type: string, description: "URI of the scheduled task to delete (e.g. \\Microsoft\\Windows\\...)" }
required: [agent_id, schtask_uri]
outputs_schema: { properties: {} }
# ── Response: quarantine ──────────────────────────────────────────────────
- id: quarantine_file
name: harfanglab-quarantine-file
description: "Move a file on an endpoint into HarfangLab quarantine by full path. Returns the created job."
risk: safe_write
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
file_path: { type: string, description: "Full path of the file to quarantine on the host" }
comment: { type: string, description: "Quarantine comment" }
required: [agent_id, file_path]
outputs_schema: { properties: {} }
- id: unquarantine_file
name: harfanglab-unquarantine-file
description: "Restore a quarantined file to its original location on the endpoint. Returns the created job."
risk: safe_write
inputs_schema:
properties:
agent_id: { type: string, description: "Agent unique identifier from the HarfangLab EDR Manager" }
local_id: { type: string, description: "Quarantined item local UUID (from harfanglab-quarantine-search)" }
overwrite_existing: { type: boolean, description: "Overwrite an existing file at the original path (default true)" }
required: [agent_id, local_id]
outputs_schema: { properties: {} }
- id: quarantine_search
name: harfanglab-quarantine-search
description: "Search quarantined files by hash, hostname or agent. Items include their id; once acquired on the manager the file is downloadable at /api/data/quarantine/item/{id}/download/."
risk: read
inputs_schema:
properties:
hash: { type: string, description: "File hash (md5, sha1 or sha256 — routed by length)" }
agent_hostname: { type: string, description: "Endpoint hostname" }
agent_id: { type: string, description: "Agent unique identifier" }
search: { type: string, description: "Free-text search term" }
limit: { type: number, description: "Max results (default 100)" }
offset: { type: number, description: "Pagination offset (default 0)" }
required: []
outputs_schema: { properties: {} }
- id: quarantine_request_upload
name: harfanglab-quarantine-request-upload
description: "Request upload of a quarantined file from the endpoint to the manager (returns the upload job). Once done, download at /api/data/quarantine/item/{id}/download/."
risk: safe_write
inputs_schema:
properties:
item_id: { type: string, description: "Quarantined item UUID (from harfanglab-quarantine-search)" }
required: [item_id]
outputs_schema: { properties: {} }
# ── Response: threat isolation & job lifecycle ────────────────────────────
- id: isolate_from_threat
name: harfanglab-isolate-from-threat
description: "Network-isolate ALL agents involved in a threat. Destructive: cuts network access on every impacted endpoint."
risk: destructive
inputs_schema:
properties:
threat_id: { type: string, description: "Threat identifier" }
required: [threat_id]
outputs_schema: { properties: {} }
- id: deisolate_from_threat
name: harfanglab-deisolate-from-threat
description: "Remove network isolation from all agents involved in a threat."
risk: safe_write
inputs_schema:
properties:
threat_id: { type: string, description: "Threat identifier" }
required: [threat_id]
outputs_schema: { properties: {} }
- id: job_cancel
name: harfanglab-job-cancel
description: "Cancel a running job by job ID."
risk: safe_write
inputs_schema:
properties:
job_id: { type: string, description: "Job ID to cancel" }
required: [job_id]
outputs_schema: { properties: {} }
- id: job_relaunch
name: harfanglab-job-relaunch
description: "Relaunch a cancelled or errored job by job ID."
risk: safe_write
inputs_schema:
properties:
job_id: { type: string, description: "Job ID to relaunch" }
required: [job_id]
outputs_schema: { properties: {} }
- id: wait_for_job
name: harfanglab-wait-for-job
description: "Poll a job until all its instances finish (done/error/canceled) or the timeout elapses. Returns {done: true|false, job: {...}} — never fails on timeout, so playbooks can branch on done."
risk: read
inputs_schema:
properties:
job_id: { type: string, description: "Job ID to wait for" }
timeout: { type: number, description: "Max seconds to wait (default 300)" }
interval: { type: number, description: "Seconds between polls (default 10)" }
required: [job_id]
outputs_schema: { properties: {} }
# ── Triage: alerts ────────────────────────────────────────────────────────
- id: get_alerts
name: harfanglab-get-alerts
description: "Search security events (alerts) for playbooks: by IDs, hostname, agent, threat, rule name, level/status/type, date range. Returns {count, results}."
risk: read
inputs_schema:
properties:
alert_ids: { type: string, description: "Comma-separated alert IDs (fetched individually; other filters ignored)" }
hostname: { type: string, description: "Endpoint hostname" }
agent_id: { type: string, description: "Agent unique identifier" }
threat_id: { type: string, description: "Threat key — alerts aggregated under this threat" }
rule_name: { type: string, description: "Detection rule name" }
level: { type: string, description: "Comma-separated severities (low, medium, high, critical)" }
status: { type: string, description: "Comma-separated statuses (new, investigating, closed, false_positive)" }
alert_type: { type: string, description: "Comma-separated alert types (sigma, yara, hlai, vt, ransom, ioc...)" }
from_date: { type: string, description: "ISO8601 lower bound on alert_time" }
to_date: { type: string, description: "ISO8601 upper bound on alert_time" }
search: { type: string, description: "Free-text search term" }
ordering: { type: string, description: "Sort field (default -alert_time)" }
limit: { type: number, description: "Max results (default 100)" }
offset: { type: number, description: "Pagination offset (default 0)" }
required: []
outputs_schema: { properties: {} }
ingest:
results_path: results
dedup_key: id
incremental_field: from_date
- id: alert_tag
name: harfanglab-alert-tag
description: "Change the status of one or more alerts in bulk (new, investigating, false_positive, closed), optionally with a comment."
risk: safe_write
inputs_schema:
properties:
alert_ids: { type: string, description: "Comma-separated alert IDs" }
new_status: { type: string, description: "new | investigating | false_positive | closed" }
new_comment: { type: string, description: "Optional comment attached to the status change" }
required: [alert_ids, new_status]
outputs_schema: { properties: {} }
- id: alert_comment
name: harfanglab-alert-comment
description: "Add a comment to a security event (alert)."
risk: safe_write
inputs_schema:
properties:
alert_id: { type: string, description: "Alert ID" }
comment: { type: string, description: "Comment text" }
required: [alert_id, comment]
outputs_schema: { properties: {} }
- id: alert_details
name: harfanglab-alert-details
description: "Get the full details of an alert (raw Sigma/YARA rule, binary availability, process unique id...)."
risk: read
inputs_schema:
properties:
alert_id: { type: string, description: "Alert ID" }
required: [alert_id]
outputs_schema: { properties: {} }
- id: alert_history
name: harfanglab-alert-history
description: "Get the status-change history of an alert."
risk: read
inputs_schema:
properties:
alert_id: { type: string, description: "Alert ID" }
required: [alert_id]
outputs_schema: { properties: {} }
# ── Triage: threats ───────────────────────────────────────────────────────
- id: get_threat
name: harfanglab-get-threat
description: "Get a single threat by ID."
risk: read
inputs_schema:
properties:
threat_id: { type: string, description: "Threat identifier" }
required: [threat_id]
outputs_schema: { properties: {} }
- id: threat_status
name: harfanglab-threat-status
description: "Change the status of one or more threats (new, investigating, false_positive, closed), optionally propagating to their security events."
risk: safe_write
inputs_schema:
properties:
threat_ids: { type: string, description: "Comma-separated threat IDs" }
new_status: { type: string, description: "new | investigating | false_positive | closed" }
tag_security_events: { type: boolean, description: "Also apply the status to the threat's security events (default false)" }
required: [threat_ids, new_status]
outputs_schema: { properties: {} }
- id: threat_level
name: harfanglab-threat-level
description: "Change the severity level of one or more threats (low, medium, high, critical)."
risk: safe_write
inputs_schema:
properties:
threat_ids: { type: string, description: "Comma-separated threat IDs" }
new_level: { type: string, description: "low | medium | high | critical" }
required: [threat_ids, new_level]
outputs_schema: { properties: {} }
- id: threat_comment
name: harfanglab-threat-comment
description: "Add a comment to a threat."
risk: safe_write
inputs_schema:
properties:
threat_id: { type: string, description: "Threat identifier" }
comment: { type: string, description: "Comment text" }
required: [threat_id, comment]
outputs_schema: { properties: {} }
- id: threat_note_set
name: harfanglab-threat-note-set
description: "Create or update the analyst note of a threat (title + content). Upserts: creates the note, or replaces it if one exists."
risk: safe_write
inputs_schema:
properties:
threat_id: { type: string, description: "Threat identifier" }
title: { type: string, description: "Note title" }
content: { type: string, description: "Note content" }
required: [threat_id, title, content]
outputs_schema: { properties: {} }
- id: threat_history
name: harfanglab-threat-history
description: "Get the status-change history of a threat."
risk: read
inputs_schema:
properties:
threat_id: { type: string, description: "Threat identifier" }
required: [threat_id]
outputs_schema: { properties: {} }
# ── Connectivity test ─────────────────────────────────────────────────────
- id: test_connection
name: harfanglab-test-connection
@@ -0,0 +1,136 @@
name: "HarfangLab Alerts → OCSF"
description: "Maps a HarfangLab EDR alert (/api/data/alert/alert/Alert/, results_path = results) to OCSF. The matched process is the actor (actor.*). Detail sub-objects and the eventlog payload are only present for their event kind; absent paths are skipped at ingestion."
field_mappings:
title: "rule_name"
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
description: "alert_type"
# results_path = results; source_path is JSONata over ONE alert object.
# Paths absent from a given alert are skipped at ingestion, so extra entries are safe.
ocsf:
# ── Endpoint ──────────────────────────────────────────────────────
- { source_path: "agent.hostname", ocsf_field: "src_endpoint.hostname" }
- { source_path: "agent.domainname", ocsf_field: "src_endpoint.domain" }
# Before details_connection.SourceIp below, so a network alert's own source
# address wins over the agent's; on every other alert this is the only IP.
- { source_path: "agent.ipaddress", ocsf_field: "src_endpoint.ip" }
- { source_path: "agent.osproducttype", ocsf_field: "src_endpoint.os.name" }
- { source_path: "agent.ostype", ocsf_field: "src_endpoint.os.type" }
- { source_path: "agent.osversion", ocsf_field: "device.os.version" }
- { source_path: "agent.agentid", ocsf_field: "device.uid" }
- { source_path: "agent.ipaddress", ocsf_field: "device.ip" }
- { source_path: "agent.dnsdomainname", ocsf_field: "device.domain" }
- { source_path: "agent.producttype", ocsf_field: "device.type" } # workstation | server
# ── Finding ───────────────────────────────────────────────────────
- { source_path: "id", ocsf_field: "finding_info.uid" }
- { source_path: "alert_unique_id", ocsf_field: "metadata.uid" }
- { source_path: "rule_name", ocsf_field: "finding_info.title" }
- { source_path: "msg", ocsf_field: "finding_info.desc" }
- { source_path: "alert_time", ocsf_field: "finding_info.created_time" }
- { source_path: "alert_time", ocsf_field: "time" }
- { source_path: "detection_date", ocsf_field: "finding_info.first_seen_time" }
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" }
- { source_path: "ingestion_date", ocsf_field: "metadata.logged_time" }
- { source_path: "alert_type", ocsf_field: "finding_info.analytic.type" }
- { source_path: "rule_id", ocsf_field: "finding_info.analytic.uid" }
- { source_path: "rule_content", ocsf_field: "finding_info.analytic.desc" }
- { source_path: "detection_origin", ocsf_field: "finding_info.analytic.category" }
- { source_path: "alert_subtype", ocsf_field: "unmapped.alert_subtype" } # eventlog | connection | dns | file | registry | url_request
- { source_path: "status", ocsf_field: "status" }
- { source_path: "maturity", ocsf_field: "status_detail" }
- { source_path: "threat_type", ocsf_field: "unmapped.threat_type" }
- { source_path: "level", ocsf_field: "severity" } # low | medium | high | critical
- { source_path: "tenant ? tenant", ocsf_field: "metadata.tenant_uid" }
- { source_path: "agent.version", ocsf_field: "unmapped.agent_version" }
# groups is an array of {id, name}; the [ ] keeps an array shape when a single
# group matches, since JSONata collapses a singleton sequence to a scalar.
- { source_path: "groups ? [groups.name]", ocsf_field: "unmapped.agent_groups" }
- { source_path: "score", ocsf_field: "risk_score" }
- { source_path: "confidence_int", ocsf_field: "confidence_score" }
- { source_path: "threat_key", ocsf_field: "finding_info.uid_alt" } # threat aggregation key
- { source_path: "mitre_cells", ocsf_field: "attacks" } # ["TAxxxx:Tyyyy", ...] best-effort
- { source_path: "tags", ocsf_field: "metadata.labels" }
- { source_path: "log_type", ocsf_field: "metadata.log_name" }
# ── Matched process — the actor ───────────────────────────────────
# image_name is the one process path present on EVERY alert subtype, and the
# only one on those carrying neither a `process` object nor an event log. When
# a threat matched, HarfangLab prefixes it with the threat name in brackets —
# "[PUADlManager:Win32/OfferCore] C:\Windows\explorer.exe" — so both halves are
# pulled out, each guarded on the bracket actually being there: without the
# $contains guard, $substringBefore/$substringAfter fall through on an
# unprefixed path and write the whole path into malware.name.
# These come first in the block so the more specific sources below (the matched
# process object, then the event log's own fields) overwrite them when present.
- { source_path: "$contains(image_name, '] ') ? $substringAfter(image_name, '] ') : image_name", ocsf_field: "actor.process.file.path" }
- { source_path: "$split($contains(image_name, '] ') ? $substringAfter(image_name, '] ') : image_name, '\\\\')[-1]", ocsf_field: "actor.process.name" }
- { source_path: "$contains(image_name, '[') ? $substringBefore($substringAfter(image_name, '['), ']')", ocsf_field: "malware.name" }
- { source_path: "process.process_name", ocsf_field: "actor.process.name" }
- { source_path: "process.image_name", ocsf_field: "actor.process.file.path" }
- { source_path: "process.commandline", ocsf_field: "actor.process.cmd_line" }
- { source_path: "process.username", ocsf_field: "actor.user.name" }
- { source_path: "process.integrity_level", ocsf_field: "actor.process.integrity" }
- { source_path: "process.pid", ocsf_field: "actor.process.pid" }
- { source_path: "process.hashes.sha256", ocsf_field: "actor.process.file.hashes.sha256" }
- { source_path: "process.hashes.sha1", ocsf_field: "actor.process.file.hashes.sha1" }
- { source_path: "process.hashes.md5", ocsf_field: "actor.process.file.hashes.md5" }
- { source_path: "process.parent_image", ocsf_field: "actor.process.parent_process.name" }
- { source_path: "process.parent_commandline", ocsf_field: "actor.process.parent_process.cmd_line" }
# ── Event details: network connection ─────────────────────────────
- { source_path: "details_connection.SourceIp", ocsf_field: "src_endpoint.ip" }
- { source_path: "details_connection.SourcePort", ocsf_field: "src_endpoint.port" }
- { source_path: "details_connection.DestinationIp", ocsf_field: "dst_endpoint.ip" }
- { source_path: "details_connection.DestinationPort", ocsf_field: "dst_endpoint.port" }
- { source_path: "details_connection.Protocol", ocsf_field: "connection_info.protocol_name" }
# ── Event details: DNS ────────────────────────────────────────────
- { source_path: "details_dns_resolution.requested_name", ocsf_field: "query.hostname" }
- { source_path: "details_dns_resolution.query_type", ocsf_field: "query.type" }
# ── Event details: file ───────────────────────────────────────────
- { source_path: "details_file.target_filename", ocsf_field: "file.path" }
# ── Event details: registry ───────────────────────────────────────
- { source_path: "details_registry.target_object", ocsf_field: "reg_key.path" }
- { source_path: "details_registry.registry_value_type", ocsf_field: "reg_value.type" }
- { source_path: "details_registry.data_string_added", ocsf_field: "reg_value.data" }
# ── Event details: URL request ────────────────────────────────────
- { source_path: "details_url_request.url", ocsf_field: "url.url_string" }
- { source_path: "details_url_request.host", ocsf_field: "url.hostname" }
- { source_path: "details_url_request.user_agent", ocsf_field: "http_request.user_agent" }
- { source_path: "details_url_request.verb", ocsf_field: "http_request.http_method" }
# ── Event details: Windows event log (alert_subtype = eventlog) ───
# A sigma rule matching a Windows event log carries the whole record under
# `eventlog`, and the provider's own field names — spaces included — under
# eventlog.event_data. Backticks quote those names in JSONata. The block below
# is Microsoft Defender (events 1116/1117), where the detection names the
# malware, the file it was found in, the process that touched it and the user
# it ran as. None of it exists on other subtypes, so it is skipped there.
- { source_path: "eventlog.source_name", ocsf_field: "metadata.log_provider" }
- { source_path: "eventlog.event_id", ocsf_field: "metadata.event_code" }
- { source_path: "eventlog.event_data.`Product Name`", ocsf_field: "metadata.product.name" }
- { source_path: "eventlog.event_data.`Product Version`", ocsf_field: "metadata.product.version" }
- { source_path: "eventlog.event_data.`Threat Name`", ocsf_field: "malware.name" }
- { source_path: "eventlog.event_data.`Threat ID`", ocsf_field: "malware.uid" }
- { source_path: "eventlog.event_data.`Severity Name`", ocsf_field: "malware.severity" }
- { source_path: "eventlog.event_data.`Product Name`", ocsf_field: "malware.provider" }
- { source_path: "eventlog.event_data.`Category Name` ? [eventlog.event_data.`Category Name`]", ocsf_field: "malware.classifications" }
# Defender prefixes the detected path with "file:_". $substringAfter returns the
# whole string when the prefix is absent, so stripping it is safe either way.
- { source_path: "$substringAfter(eventlog.event_data.`Path`, 'file:_')", ocsf_field: "file.path" }
- { source_path: "$substringAfter(eventlog.event_data.`Path`, 'file:_')", ocsf_field: "malware.path" }
- { source_path: "$split($substringAfter(eventlog.event_data.`Path`, 'file:_'), '\\\\')[-1]", ocsf_field: "file.name" }
- { source_path: "eventlog.event_data.`Process Name`", ocsf_field: "actor.process.file.path" }
- { source_path: "$split(eventlog.event_data.`Process Name`, '\\\\')[-1]", ocsf_field: "actor.process.name" }
- { source_path: "eventlog.event_data.`Detection User`", ocsf_field: "actor.user.name" }
- { source_path: "eventlog.event_data.`Action Name`", ocsf_field: "action" }
- { source_path: "eventlog.event_data.`Detection ID`", ocsf_field: "unmapped.defender.detection_uid" }
- { source_path: "eventlog.event_data.`Source Name`", ocsf_field: "unmapped.defender.detection_source" } # real-time protection, scheduled scan…
- { source_path: "eventlog.event_data.`Execution Name`", ocsf_field: "unmapped.defender.execution_state" }
- { source_path: "eventlog.event_data.`Security intelligence Version`", ocsf_field: "unmapped.defender.signature_version" }
# Alert fields deliberately left unmapped: `username` (actor.user.name is taken by the
# more specific process.username), remaining details_* sub-objects (no clean OCSF home),
# eventlog.threat_process_name (the same prefixed string as image_name, already split
# above), `eventlog.user` (the account that WROTE the
# record, usually SYSTEM — indexing it would create a junk user entity),
# `eventlog.computer_name` (the FQDN of the host already carried by agent.hostname; two
# spellings of one machine split correlation), `level_int` / `quarantine` / `execution`
# and Defender's Action ID / Severity ID / Category ID (console enums on scales OCSF does
# not share), `detection_timestamp` and Defender's Detection Time (duplicates of
# detection_date), `confidence` (the string form of confidence_int, already mapped), and
# Defender's Error/Status/Origin/Remediation/Unused fields.
@@ -15,13 +15,26 @@ ocsf:
- { source_path: "agent.osversion", ocsf_field: "device.os.version" }
- { source_path: "agent.agentid", ocsf_field: "device.uid" }
# ── Finding ───────────────────────────────────────────────────────
- { source_path: "id", ocsf_field: "finding_info.uid" }
- { source_path: "rule_name", ocsf_field: "finding_info.title" }
- { source_path: "alert_time", ocsf_field: "finding_info.created_time" }
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" }
- { source_path: "alert_type", ocsf_field: "finding_info.analytic.type" }
- { source_path: "status", ocsf_field: "status" }
- { source_path: "maturity", ocsf_field: "status_detail" }
- { source_path: "id", ocsf_field: "finding_info.uid" }
- { source_path: "alert_unique_id", ocsf_field: "metadata.uid" }
- { source_path: "rule_name", ocsf_field: "finding_info.title" }
- { source_path: "msg", ocsf_field: "finding_info.desc" }
- { source_path: "alert_time", ocsf_field: "finding_info.created_time" }
- { source_path: "detection_date", ocsf_field: "finding_info.first_seen_time" }
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" }
- { source_path: "ingestion_date", ocsf_field: "metadata.logged_time" }
- { source_path: "alert_type", ocsf_field: "finding_info.analytic.type" }
- { source_path: "rule_id", ocsf_field: "finding_info.analytic.uid" }
- { source_path: "rule_content", ocsf_field: "finding_info.analytic.desc" }
- { source_path: "detection_origin", ocsf_field: "finding_info.analytic.category" }
- { source_path: "status", ocsf_field: "status" }
- { source_path: "maturity", ocsf_field: "status_detail" }
- { source_path: "score", ocsf_field: "risk_score" }
- { source_path: "confidence_int", ocsf_field: "confidence_score" }
- { source_path: "threat_key", ocsf_field: "finding_info.uid_alt" } # threat aggregation key
- { source_path: "mitre_cells", ocsf_field: "attacks" } # ["TAxxxx:Tyyyy", ...] best-effort
- { source_path: "tags", ocsf_field: "metadata.labels" }
- { source_path: "log_type", ocsf_field: "metadata.log_name" }
# ── Matched process — the actor ───────────────────────────────────
- { source_path: "process.process_name", ocsf_field: "actor.process.name" }
- { source_path: "process.image_name", ocsf_field: "actor.process.file.path" }
@@ -34,3 +47,25 @@ ocsf:
- { source_path: "process.hashes.md5", ocsf_field: "actor.process.file.hashes.md5" }
- { source_path: "process.parent_image", ocsf_field: "actor.process.parent_process.name" }
- { source_path: "process.parent_commandline", ocsf_field: "actor.process.parent_process.cmd_line" }
# ── Event details: network connection ─────────────────────────────
- { source_path: "details_connection.SourceIp", ocsf_field: "src_endpoint.ip" }
- { source_path: "details_connection.SourcePort", ocsf_field: "src_endpoint.port" }
- { source_path: "details_connection.DestinationIp", ocsf_field: "dst_endpoint.ip" }
- { source_path: "details_connection.DestinationPort", ocsf_field: "dst_endpoint.port" }
- { source_path: "details_connection.Protocol", ocsf_field: "connection_info.protocol_name" }
# ── Event details: DNS ────────────────────────────────────────────
- { source_path: "details_dns_resolution.requested_name", ocsf_field: "query.hostname" }
- { source_path: "details_dns_resolution.query_type", ocsf_field: "query.type" }
# ── Event details: file ───────────────────────────────────────────
- { source_path: "details_file.target_filename", ocsf_field: "file.path" }
# ── Event details: registry ───────────────────────────────────────
- { source_path: "details_registry.target_object", ocsf_field: "reg_key.path" }
- { source_path: "details_registry.registry_value_type", ocsf_field: "reg_value.type" }
- { source_path: "details_registry.data_string_added", ocsf_field: "reg_value.data" }
# ── Event details: URL request ────────────────────────────────────
- { source_path: "details_url_request.url", ocsf_field: "url.url_string" }
- { source_path: "details_url_request.host", ocsf_field: "url.hostname" }
- { source_path: "details_url_request.user_agent", ocsf_field: "http_request.user_agent" }
- { source_path: "details_url_request.verb", ocsf_field: "http_request.http_method" }
# Alert fields deliberately left unmapped: `username` (actor.user.name is taken by the
# more specific process.username), remaining details_* sub-objects (no clean OCSF home).
@@ -1,9 +1,9 @@
name: "HarfangLab Threats → OCSF"
description: "Maps a raw HarfangLab EDR threat (/api/data/alert/alert/Threat/, results_path = results) to OCSF. Uses native threat fields (top_agents/top_impacted_users/top_rules/mitre_tactics); the agents/impacted_users/rules arrays are only populated by external enrichment, which this connector does not perform."
description: "Maps a raw HarfangLab EDR threat (/api/data/alert/alert/Threat/, results_path = results) to OCSF. Uses native threat fields; top_agents/top_impacted_users/top_rules are ranked aggregates — the first (most impacted) entry feeds the endpoint/user/rule fields. mitre_tactics is a map keyed by tactic slug (e.g. attack.persistence); its keys are extracted with $keys()."
field_mappings:
title: "slug"
title: "top_rules[0].rule_name ? top_rules[0].rule_name & ' — ' & slug : slug"
severity: "level = 'critical' ? 5 : (level = 'high' ? 4 : (level = 'medium' ? 3 : 2))"
description: "status"
description: "top_rules[0].rule_msg ? top_rules[0].rule_msg : status"
# results_path = results; source_path is JSONata over ONE threat object.
# Paths absent from a given threat are skipped at ingestion, so extra entries are safe.
ocsf:
@@ -11,13 +11,54 @@ ocsf:
- { source_path: "id", ocsf_field: "finding_info.uid" }
- { source_path: "slug", ocsf_field: "finding_info.title" }
- { source_path: "creation_date", ocsf_field: "finding_info.created_time" }
- { source_path: "creation_date", ocsf_field: "time" }
- { source_path: "first_seen", ocsf_field: "finding_info.first_seen_time" }
- { source_path: "last_seen", ocsf_field: "finding_info.last_seen_time" }
- { source_path: "last_update", ocsf_field: "finding_info.modified_time" }
- { source_path: "status", ocsf_field: "status" }
# ── MITRE ATT&CK (native mitre_tactics list) ──────────────────────
- { source_path: "mitre_tactics[0]", ocsf_field: "attacks.tactic.name" }
# ── Top impacted endpoint / user / rule (native aggregates) ───────
- { source_path: "top_agents[0].agent_hostname", ocsf_field: "src_endpoint.hostname" }
# x ? x guards: these are null on open/mono-tenant threats — null is falsy in
# JSONata, so the condition yields undefined and the entry is skipped instead
# of writing a literal null into the OCSF document.
- { source_path: "closed_date ? closed_date", ocsf_field: "end_time" }
- { source_path: "status", ocsf_field: "status" }
# field_mappings.severity turns this into the incident's 1-5 number; the string
# itself has no other home, so without this entry the threat's own level never
# reaches the OCSF document at all.
- { source_path: "level", ocsf_field: "severity" } # low | medium | high | critical
- { source_path: "linked_threat ? linked_threat", ocsf_field: "finding_info.uid_alt" }
- { source_path: "tenant ? tenant", ocsf_field: "metadata.tenant_uid" }
# ── Aggregate counts ──────────────────────────────────────────────
- { source_path: "total_security_event_count", ocsf_field: "finding_info.related_events_count" }
- { source_path: "agent_count", ocsf_field: "unmapped.agent_count" }
- { source_path: "impacted_user_count", ocsf_field: "unmapped.impacted_user_count" }
- { source_path: "rule_count", ocsf_field: "unmapped.rule_count" }
# ── MITRE ATT&CK — mitre_tactics is a MAP {"attack.persistence": {...}} ──
# [$keys(...)] keeps an array shape even for a single tactic (JSONata collapses
# singleton sequences to a scalar); the guard skips threats without tactics.
- { source_path: "mitre_tactics ? [$keys(mitre_tactics)]", ocsf_field: "attacks" }
# ── Top impacted endpoint (most security events first) ────────────
- { source_path: "top_agents[0].agent_hostname", ocsf_field: "src_endpoint.hostname" }
- { source_path: "top_agents[0].agent_id", ocsf_field: "device.uid" }
- { source_path: "top_agents[0].agent_osproducttype", ocsf_field: "src_endpoint.os.name" }
- { source_path: "top_agents[0].agent_ostype", ocsf_field: "src_endpoint.os.type" }
- { source_path: "top_agents[0].agent_osversion", ocsf_field: "device.os.version" }
# Whether the agent is still reachable decides what response is even possible
# (isolate, collect, kill) — the first thing asked of a threat, so it travels
# with the endpoint rather than being left to the console.
- { source_path: "top_agents[0].agent_status", ocsf_field: "unmapped.agent_status" }
# ── Top impacted user ─────────────────────────────────────────────
- { source_path: "top_impacted_users[0].user_name", ocsf_field: "user.name" }
- { source_path: "top_rules[0].rule_name", ocsf_field: "rule.name" }
- { source_path: "top_impacted_users[0].user_sid", ocsf_field: "user.uid" }
# ── Top rule ──────────────────────────────────────────────────────
- { source_path: "top_rules[0].rule_name", ocsf_field: "rule.name" }
- { source_path: "top_rules[0].rule_id", ocsf_field: "rule.uid" }
- { source_path: "top_rules[0].rule_type", ocsf_field: "finding_info.analytic.type" }
- { source_path: "top_rules[0].rule_msg", ocsf_field: "finding_info.desc" }
# Deliberately unmapped: old_id/origin_stack (legacy/console internals),
# top_rules[0].id (numeric console id — rule_id is the stable uid, mapped above),
# per-item security_event_count (equals the total while agent_count is 1, and the total
# is already mapped), top_rules[0].rule_level (the same value as the threat's own level,
# mapped to severity above), rule_os (the same value as the agent's ostype), the rule's
# creation_date/last_update (rule bookkeeping, not incident evidence),
# mitre_tactics[*].security_event_percentage (the tactic names are what pivot; the
# per-tactic weights have no OCSF home), top_rules[0].description (duplicate of rule_msg),
# and entries [1..] of the top_* arrays (OCSF detection_finding models a single primary
# endpoint/user/rule; counts convey scale).
@@ -0,0 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Alert/" + str(inputs.get("alert_id")) + "/comment/"
print(json.dumps(request("POST", url, headers, {"comment": inputs.get("comment")})))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Alert/" + str(inputs.get("alert_id")) + "/details/"
print(json.dumps(request("GET", url, headers)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Alert/" + str(inputs.get("alert_id")) + "/history/"
print(json.dumps(request("GET", url, headers)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,41 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def csv(v):
return [x.strip() for x in str(v or "").split(",") if x.strip()]
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
body = {
"ids": csv(inputs.get("alert_ids")),
"new_status": inputs.get("new_status"),
}
if inputs.get("new_comment"):
body["new_comment"] = inputs.get("new_comment")
print(json.dumps(request("POST", base + "/api/data/alert/alert/Alert/tag/", headers, body)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,47 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def csv(v):
return [x.strip() for x in str(v or "").split(",") if x.strip()]
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
scan_type = str(inputs.get("scan_type") or "quick").lower()
if scan_type not in ("full", "quick", "resource"):
print(json.dumps({"error": "invalid scan_type: " + scan_type + " (expected full, quick or resource)"}))
sys.exit(1)
params = {"type": scan_type}
paths = csv(inputs.get("paths"))
if paths:
params["paths"] = paths
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "avScan", "params": params}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/deisolation/"
print(json.dumps(request("POST", url, headers)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,35 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "filepathDeleter", "params": [{"filepath": inputs.get("filepath")}]}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,35 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "deleteScheduledTask", "params": [{"schtask_uri": inputs.get("schtask_uri")}]}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,35 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "deleteService", "params": [{"service_name": inputs.get("service_name")}]}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,55 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
FIELD_TYPES = {
"name": 0, "path": 1, "username": 3, "commandline": 4,
"parent_name": 10, "parent_path": 11, "parent_commandline": 14,
"pid": 15, "md5": 16, "sha1": 17, "sha256": 18,
}
OPERATORS = {"eq": 0, "not_eq": 1, "contains": 2, "not_contains": 3}
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
field = str(inputs.get("field") or "name").lower()
operator = str(inputs.get("operator") or "eq").lower()
if field not in FIELD_TYPES:
print(json.dumps({"error": "invalid field: " + field + " (expected one of " + ", ".join(sorted(FIELD_TYPES)) + ")"}))
sys.exit(1)
if operator not in OPERATORS:
print(json.dumps({"error": "invalid operator: " + operator + " (expected one of eq, not_eq, contains, not_contains)"}))
sys.exit(1)
criteria = [{
"param_type": FIELD_TYPES[field],
"param_operator": OPERATORS[operator],
"param_value": str(inputs.get("value") or ""),
}]
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "searchProcessDumper", "params": {"values": criteria}}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,66 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def qs(params):
clean = {}
for k, v in params.items():
if v is None or v == "" or v == []:
continue
if isinstance(v, bool):
v = "true" if v else "false"
clean[k] = v
return ("?" + urllib.parse.urlencode(clean)) if clean else ""
def csv(v):
return [x.strip() for x in str(v or "").split(",") if x.strip()]
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
ids = csv(inputs.get("alert_ids"))
if ids:
results = [request("GET", base + "/api/data/alert/alert/Alert/" + i + "/", headers) for i in ids]
print(json.dumps({"count": len(results), "results": results}))
return
params = {
"ordering": inputs.get("ordering") or "-alert_time",
"limit": int(inputs.get("limit") or 100),
"offset": int(inputs.get("offset") or 0),
"search": inputs.get("search"),
"agent.hostname": inputs.get("hostname"),
"agent.agentid": inputs.get("agent_id"),
"threat_key": inputs.get("threat_id"),
"rule_name": inputs.get("rule_name"),
"level": ",".join(csv(inputs.get("level"))) or None,
"status": ",".join(csv(inputs.get("status"))) or None,
"alert_type": ",".join(csv(inputs.get("alert_type"))) or None,
"alert_time__gte": inputs.get("from_date"),
"alert_time__lte": inputs.get("to_date"),
}
print(json.dumps(request("GET", base + "/api/data/alert/alert/Alert/" + qs(params), headers)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/"
print(json.dumps(request("GET", url, headers)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/isolation/"
print(json.dumps(request("POST", url, headers)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/Job/" + str(inputs.get("job_id")) + "/cancel/"
print(json.dumps(request("POST", url, headers)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/Job/" + str(inputs.get("job_id")) + "/relaunch/"
print(json.dumps(request("POST", url, headers)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,55 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
FIELD_TYPES = {
"name": 0, "path": 1, "username": 3, "commandline": 4,
"parent_name": 10, "parent_path": 11, "parent_commandline": 14,
"pid": 15, "md5": 16, "sha1": 17, "sha256": 18,
}
OPERATORS = {"eq": 0, "not_eq": 1, "contains": 2, "not_contains": 3}
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
field = str(inputs.get("field") or "name").lower()
operator = str(inputs.get("operator") or "eq").lower()
if field not in FIELD_TYPES:
print(json.dumps({"error": "invalid field: " + field + " (expected one of " + ", ".join(sorted(FIELD_TYPES)) + ")"}))
sys.exit(1)
if operator not in OPERATORS:
print(json.dumps({"error": "invalid operator: " + operator + " (expected one of eq, not_eq, contains, not_contains)"}))
sys.exit(1)
criteria = [{
"param_type": FIELD_TYPES[field],
"param_operator": OPERATORS[operator],
"param_value": str(inputs.get("value") or ""),
}]
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "wildcardProcessFinderKiller", "params": {"values": criteria}}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,38 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
params = {"values": [{"file_path": inputs.get("file_path")}]}
if inputs.get("comment"):
params["comment"] = inputs.get("comment")
body = {
"targets": {"agents": [inputs.get("agent_id")]},
"actions": [{"value": "quarantineAdd", "params": params}],
}
print(json.dumps(request("POST", base + "/api/data/Job/", headers, body)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,33 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
item_id = inputs.get("item_id")
url = base + "/api/data/quarantine/item/" + str(item_id) + "/request_upload/"
print(json.dumps(request("POST", url, headers)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,57 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def qs(params):
clean = {}
for k, v in params.items():
if v is None or v == "" or v == []:
continue
if isinstance(v, bool):
v = "true" if v else "false"
clean[k] = v
return ("?" + urllib.parse.urlencode(clean)) if clean else ""
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
params = {
"limit": int(inputs.get("limit") or 100),
"offset": int(inputs.get("offset") or 0),
"search": inputs.get("search"),
"agent.hostname": inputs.get("agent_hostname"),
"agent.id": inputs.get("agent_id"),
}
h = str(inputs.get("hash") or "").strip().lower()
if h:
key = {32: "item_md5", 40: "item_sha1", 64: "item_sha256"}.get(len(h))
if not key:
print(json.dumps({"error": "unrecognized hash length (expected md5, sha1 or sha256)"}))
sys.exit(1)
params[key] = h
out = request("GET", base + "/api/data/quarantine/item/" + qs(params), headers)
print(json.dumps(out))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/comment/"
print(json.dumps(request("POST", url, headers, {"comment": inputs.get("comment")})))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,32 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
url = base + "/api/data/alert/alert/Threat/" + str(inputs.get("threat_id")) + "/history/"
print(json.dumps(request("GET", url, headers)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,40 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
def csv(v):
return [x.strip() for x in str(v or "").split(",") if x.strip()]
def request(method, url, headers, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def main():
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
base = secrets.get("url", "").rstrip("/")
headers = {
"Authorization": "Token " + secrets.get("api_token", ""),
"Accept": "application/json",
"Content-Type": "application/json",
}
body = {
"threat_ids": csv(inputs.get("threat_ids")),
"new_level": inputs.get("new_level"),
"update_by_query": False,
}
print(json.dumps(request("PATCH", base + "/api/data/alert/alert/Threat/level/", headers, body)))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)

Some files were not shown because too many files have changed in this diff Show More