Compare commits

...

120 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
Guillaume BOURGEOIS ac2203f9a5 feat(feed): add FIRST EPSS feed connector (exploit-prediction scores) 2026-07-13 12:12:35 +02:00
Guillaume BOURGEOIS 8248c60da8 feat(feed): add NVD CVE feed connector (recent CVEs, NVD 2.0 API) 2026-07-13 12:12:34 +02:00
Guillaume BOURGEOIS 4206b44e78 feat(feed): add CISA KEV feed connector (known exploited vulnerabilities) 2026-07-13 12:12:34 +02:00
Guillaume BOURGEOIS 9cffefbb36 feat(feed): add GCP IP ranges feed connector (allowlist / known infra) 2026-07-13 12:08:49 +02:00
Guillaume BOURGEOIS f7791402a3 feat(feed): add Cloudflare IPs feed connector (allowlist / known infra) 2026-07-13 12:08:49 +02:00
Guillaume BOURGEOIS c42a9cef03 feat(feed): add AWS IP ranges feed connector (allowlist / known infra) 2026-07-13 12:08:48 +02:00
Guillaume BOURGEOIS f6ff018f09 feat(feed): add OpenPhish feed connector (community phishing URLs) 2026-07-12 23:02:11 +02:00
Guillaume BOURGEOIS c091800434 feat(feed): add Emerging Threats feed connector (ET Open reputation lists) 2026-07-12 23:02:11 +02:00
Guillaume BOURGEOIS 929b4166d6 feat(feed): add generic JSON feed connector (configurable field mapping) 2026-07-12 23:02:10 +02:00
Guillaume BOURGEOIS 381b42b6c7 feat(feed): add CINS Army feed connector (CI Army bad-guys list) 2026-07-12 22:59:20 +02:00
Guillaume BOURGEOIS 5715f21603 feat(feed): add Blocklist.de feed connector (fail2ban attacker lists) 2026-07-12 22:59:19 +02:00
Guillaume BOURGEOIS 7543a95711 feat(feed): add DShield block list feed connector (SANS ISC) 2026-07-12 22:59:18 +02:00
Guillaume BOURGEOIS 7187e7b607 feat(feed): add SSL Blacklist feed connector (abuse.ch C2 IP/fingerprint lists) 2026-07-12 22:56:36 +02:00
Guillaume BOURGEOIS 5bb465eb18 feat(feed): add ThreatFox feed connector (abuse.ch IOC export) 2026-07-12 22:56:36 +02:00
Guillaume BOURGEOIS a4dcd00cc7 feat(feed): add URLhaus feed connector (abuse.ch malware URL feed) 2026-07-12 22:56:35 +02:00
Guillaume BOURGEOIS cf923301a7 feat(feed): add Spamhaus DROP feed connector (DROP/DROPv6/ASN-DROP) 2026-07-12 22:49:30 +02:00
Guillaume BOURGEOIS f833bfe3a5 feat(feed): add Tor exit nodes feed connector 2026-07-12 22:49:29 +02:00
Guillaume BOURGEOIS d6129f5d79 feat(feed): add Feodo Tracker feed connector (abuse.ch C2 IP blocklist) 2026-07-12 22:49:28 +02:00
Guillaume BOURGEOIS c779c38dab feat(feed-taxii2): new generic TAXII 2.1 / STIX feed connector
Pulls STIX 2.x indicators from any TAXII 2.1 server, parses the STIX patterns
into normalized IOCs {value,type} for TIM import. list-collections + fetch.
Basic/bearer/none auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:43:17 +02:00
Guillaume BOURGEOIS 4f94b59e17 feat(feed-plaintext): new generic plain-text threat-intel feed connector
Fetches a line-per-indicator blocklist URL and emits normalized IOCs {value,type}
for TIM import. Comment-char/field-index/type config, auto type-detection.
Optional bearer auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:43:17 +02:00
Guillaume BOURGEOIS f09d00114f feat(feed-csv): new generic CSV threat-intel feed connector
Fetches a delimited feed URL and emits normalized IOCs {value,type} for TIM
import (works with the /indicators/feed-extract -> /indicators/bulk flow).
Column/delimiter/comment/type config, auto type-detection. New 'feed' category.
Optional bearer auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:43:16 +02:00
Guillaume BOURGEOIS a4241a5a5d feat(cisco-ise): new Cisco ISE NAC integration
ISE ERS + ANC API, 6 commands: list/get endpoints, apply/clear ANC policy
(quarantine containment), list ANC policies. HTTP Basic auth, stdlib-only.
py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:59:07 +02:00
Guillaume BOURGEOIS 232e310f77 feat(automox): new Automox patch-management integration
Automox API, 5 commands: list/get devices, list policies, run device command.
API-key auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:59:06 +02:00
Guillaume BOURGEOIS b231b648bc feat(domaintools): new DomainTools DNS-intel integration
DomainTools API, 5 commands: whois, whois history, reputation, reverse IP.
API-key auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:59:05 +02:00
Guillaume BOURGEOIS 087b9abd19 feat(thinkst-canary): new Thinkst Canary deception integration
Canary Console API v1, 5 commands: list/get incidents, acknowledge incident,
list devices. Auth-token auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:53:59 +02:00
Guillaume BOURGEOIS e91febd319 feat(intune): new Microsoft Intune MDM integration
Microsoft Graph device management, 6 commands: list/get managed devices,
retire/wipe/remote-lock device (containment). OAuth2 client-credentials auth,
stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:53:59 +02:00
Guillaume BOURGEOIS 374e4184ff feat(meraki): new Cisco Meraki network integration
Meraki Dashboard API v1, 5 commands: list organizations/networks/devices, list
network clients. API-key auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:53:58 +02:00
Guillaume BOURGEOIS 82353947d6 feat(mailinblack): new Mailinblack email-security integration (French vendor)
Mailinblack API, 5 commands: list quarantine, release message, block/allow
sender. Bearer auth, stdlib-only. py_compile clean. NOTE: API paths best-effort
— verify against vendor docs before production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:37:32 +02:00
Guillaume BOURGEOIS ac47315b30 feat(cybelangel): new CybelAngel attack-surface integration (French vendor)
CybelAngel platform API, 4 commands: list/get reports, update report status.
OAuth2 client-credentials auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:37:31 +02:00
Guillaume BOURGEOIS 631292fcc7 feat(scaleway): new Scaleway cloud integration (French vendor)
Scaleway Cloud API, 5 commands: list/get instances, list security groups, list
projects. Secret-key (X-Auth-Token) auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:37:31 +02:00
Guillaume BOURGEOIS d8bb41b4c5 feat(vade): new Vade email-security integration (French vendor)
Vade API, 4 commands: search messages, get message, remediate message. Bearer
auth, stdlib-only. py_compile clean. NOTE: API paths best-effort — verify
against vendor docs before production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:13:24 +02:00
Guillaume BOURGEOIS 855f1b132c feat(ovhcloud): new OVHcloud integration (French vendor)
OVH API v1, 5 commands: get account, list cloud projects, list/get dedicated
servers. SHA-1 signed application-key auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:13:23 +02:00
Guillaume BOURGEOIS 74bf6126fb feat(efficientip): new EfficientIP SOLIDserver DNS/DDI integration (French vendor)
SOLIDserver REST API, 5 commands: add RPZ record (DNS block/sinkhole), list DNS
zones, search IP, list networks. HTTP Basic auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:13:22 +02:00
Guillaume BOURGEOIS dd574e550b feat(gatewatcher): new Gatewatcher AionIQ NDR integration (French vendor)
Gatewatcher AionIQ, 4 commands: list/get alerts, search alerts. API-KEY auth,
stdlib-only. py_compile clean. NOTE: API paths best-effort — verify against
vendor docs before production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:12:38 +02:00
Guillaume BOURGEOIS 8a7e5a33a2 feat(tehtris): new TEHTRIS XDR integration (French vendor)
TEHTRIS XDR, 5 commands: list/get alerts, list agents, isolate agent
(containment). Bearer auth, stdlib-only. py_compile clean. NOTE: API paths
best-effort — verify against vendor docs before production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:12:37 +02:00
Guillaume BOURGEOIS 3c348b7601 feat(glimps): new GLIMPS Detect malware-analysis integration (French vendor)
GDetect API lite v2, 4 commands: submit file (multipart), get result by UUID,
search by SHA-256. Token auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:12:37 +02:00
Guillaume BOURGEOIS 2475c4b56e feat(stormshield): new Stormshield SNS firewall integration (French vendor)
Stormshield SNS API, 5 commands: add/remove block host-group member (containment),
list hosts, monitor query. Session (login) auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:07:38 +02:00
Guillaume BOURGEOIS 3be14f2f00 feat(wallix-bastion): new WALLIX Bastion PAM integration (French vendor)
WALLIX Bastion REST API, 6 commands: list devices/accounts/authorizations,
list/get sessions. X-Auth-User/X-Auth-Key auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:07:38 +02:00
Guillaume BOURGEOIS 3a4d06441c feat(crowdsec): new CrowdSec CTI integration (French vendor)
CrowdSec CTI API v2, 4 commands: IP reputation, multi-IP search, fire list.
API-key auth, stdlib-only. py_compile clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:07:37 +02:00
Guillaume BOURGEOIS ce319c2fea feat(intel471): new Intel 471 threat-intel integration
Intel 471 Titan API v1, 5 commands: search reports/indicators, get report,
list CVE reports. HTTP Basic auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:49:17 +02:00
Guillaume BOURGEOIS 8f3d9607df feat(threatq): new ThreatQ threat-intel integration
ThreatQ REST API, 5 commands: search/get indicators, create indicator, list
events. OAuth2 password-grant auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:49:17 +02:00
Guillaume BOURGEOIS a2f2b8efa0 feat(eclecticiq): new EclecticIQ threat-intel integration
EclecticIQ Intelligence Center API v2, 4 commands: search entities/observables,
get entity. Bearer-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:49:16 +02:00
Guillaume BOURGEOIS 3c282e8e53 feat(signalsciences): new Signal Sciences (Fastly NGWAF) integration
Signal Sciences API v0, 5 commands: list sites, get suspicious IPs, list events,
add IP to list (block). API-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:44:30 +02:00
Guillaume BOURGEOIS 771c90ef0d feat(imperva): new Imperva Cloud WAF integration
Cloud Application Security API v1, 5 commands: list sites, get site status,
block/whitelist IPs (site ACL). API-ID/key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:44:29 +02:00
Guillaume BOURGEOIS 9fb5b65e4a feat(f5-bigip): new F5 BIG-IP integration
iControl REST, 6 commands: list virtual servers/pools, get pool members,
disable pool member, add data-group record (block). HTTP Basic auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:44:28 +02:00
Guillaume BOURGEOIS 16fe51ee99 feat(sailpoint): new SailPoint IdentityNow IGA integration
IdentityNow API v3, 6 commands: list/get identities, list accounts, disable/
enable account (containment). OAuth2 client-credentials auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:39:19 +02:00
Guillaume BOURGEOIS 5089e4466d feat(beyondtrust-password-safe): new BeyondTrust Password Safe PAM integration
Password Safe REST API v3, 5 commands: list managed accounts/systems, create
release request, get credential. PS-Auth session auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:39:18 +02:00
Guillaume BOURGEOIS 8fa1ade945 feat(delinea-secret-server): new Delinea Secret Server PAM integration
Secret Server REST API v1, 5 commands: search secrets, get secret, get secret
field, list folders. OAuth2 password-grant auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:39:18 +02:00
Guillaume BOURGEOIS 54b077bfc9 feat(confluence): new Confluence runbook/knowledge integration
Confluence REST API, 6 commands: CQL search, get page, create/update page,
list spaces. Basic (email + API token) auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:23:56 +02:00
Guillaume BOURGEOIS 6d38896892 feat(dropbox): new Dropbox file/evidence integration
Dropbox API v2, 5 commands: search, get metadata, list folder, create shared
link. Bearer-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:23:56 +02:00
Guillaume BOURGEOIS 223dfd34e3 feat(box): new Box file/evidence integration
Box Content API v2, 6 commands: search, get file/folder info, list folder
items, create shared link. Bearer-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:23:56 +02:00
Guillaume BOURGEOIS 83c1736d47 feat(snyk): new Snyk appsec-posture integration
Snyk API v1 + REST, 5 commands: list/get projects, list project issues, list
org issues. API-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:18:48 +02:00
Guillaume BOURGEOIS bc100eef67 feat(gitlab): new GitLab project-security integration
GitLab API v4, 6 commands: list vulnerabilities, list/create issues, get
project, list audit events. Private-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:18:48 +02:00
Guillaume BOURGEOIS 5f2e946c0a feat(github): new GitHub code-security integration
GitHub REST API, 6 commands: list secret/code/Dependabot scanning alerts,
get repo, create issue. PAT (Bearer) auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:18:47 +02:00
Guillaume BOURGEOIS 86fdd8483a feat(pingone): new PingOne identity-containment integration
PingOne Platform API, 6 commands: list/get users, enable/disable user, list
populations. OAuth2 client-credentials auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:14:01 +02:00
Guillaume BOURGEOIS cd02c26b45 feat(jumpcloud): new JumpCloud identity integration
JumpCloud API v1, 6 commands: list/get users, suspend/activate user, list
systems. API-key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:14:01 +02:00
Guillaume BOURGEOIS ab68b3ae71 feat(onelogin): new OneLogin identity-containment integration
OneLogin API v1/v2, 6 commands: list/get users, lock user, logout user (revoke
sessions), list events. OAuth2 client-credentials auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:14:00 +02:00
Guillaume BOURGEOIS 046fb79008 feat(datadog): new Datadog observability integration
Datadog API v1/v2, 6 commands: search logs, list/get monitors, submit event,
list events. API-key + Application-key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:08:51 +02:00
Guillaume BOURGEOIS 6548740890 feat(sumologic): new Sumo Logic log-search integration
Sumo Logic REST API, 4 commands: search (job create+poll+messages, cookie
session), list/get collectors. Access-key (Basic) auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:08:51 +02:00
Guillaume BOURGEOIS 16deec38f8 feat(graylog): new Graylog log-search integration
Graylog REST API, 5 commands: relative message search, list/get streams, get
message. API-token (Basic) auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:08:50 +02:00
Guillaume BOURGEOIS cf950cf6e4 feat(withsecure): new WithSecure Elements endpoint integration
WithSecure Elements Connect API, 7 commands: list/get devices, isolate/release
(containment), scan, list incidents. OAuth2 client-credentials auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:04:02 +02:00
Guillaume BOURGEOIS 1949c98cf7 feat(sophos-central): new Sophos Central endpoint integration
Sophos Central Endpoint API, 7 commands: list/get endpoints, isolate/de-isolate
(containment), scan, list alerts. OAuth2 client-credentials + tenant discovery
(whoami), stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:04:02 +02:00
Guillaume BOURGEOIS addaeadae5 feat(cisco-secure-endpoint): new Cisco Secure Endpoint (AMP) integration
AMP for Endpoints API v1, 7 commands: list/get computers, isolate/stop-isolation
(containment), list events, get trajectory. HTTP Basic auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:04:01 +02:00
Guillaume BOURGEOIS b137e49be4 feat(yaraify): new YARAify file/YARA-intel integration
YARAify (abuse.ch) API v1, 4 commands: lookup hash, get YARA rule, recent
matches. Auth-Key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:58:08 +02:00
Guillaume BOURGEOIS c5699dd01a feat(threatfox): new ThreatFox IOC-intel integration
ThreatFox (abuse.ch) API v1, 5 commands: search IOC, recent IOCs, search hash,
tag IOCs. Auth-Key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:58:07 +02:00
Guillaume BOURGEOIS 015131a2d1 feat(malwarebazaar): new MalwareBazaar sample-intel integration
MalwareBazaar (abuse.ch) API v1, 5 commands: sample info by hash, recent
samples, tag info, signature info. Auth-Key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:58:07 +02:00
Guillaume BOURGEOIS 82ad3a9369 feat(armis): new Armis device-visibility integration
Armis API v1, 5 commands: search devices/alerts (AQL), get device, update alert
status. Secret-key token-exchange auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:41:43 +02:00
Guillaume BOURGEOIS df8a2c1187 feat(axonius): new Axonius asset-management integration
Axonius REST API, 5 commands: list/get devices, device count, list users (AQL
filters). API-key + API-secret auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:41:43 +02:00
Guillaume BOURGEOIS 088694fdc2 feat(runzero): new runZero asset-inventory integration
runZero API v1.0, 6 commands: search/get assets, search services, list sites,
list tasks. Bearer-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:41:42 +02:00
Guillaume BOURGEOIS c1867a62a3 feat(rocketchat): new Rocket.Chat ChatOps integration
Rocket.Chat REST API v1, 6 commands: post message, get channel info, list
channels, create channel, get user. X-Auth-Token/X-User-Id auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:22:06 +02:00
Guillaume BOURGEOIS c2cc4da675 feat(mattermost): new Mattermost ChatOps integration
Mattermost REST API v4, 6 commands: create post, get channel by name, search
posts, create channel, get user. Bearer-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:22:05 +02:00
Guillaume BOURGEOIS 2b5e7f37b4 feat(discord): new Discord notification/ChatOps integration
Discord webhook + bot API, 5 commands: webhook message, bot channel message,
list channel messages, get channel. Webhook + bot-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:22:04 +02:00
Guillaume BOURGEOIS d469ed1d8f feat(urlhaus): new URLhaus malicious-URL intel integration
URLhaus (abuse.ch) API v1, 5 commands: URL/host/payload lookup, recent URLs.
Auth-Key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:15:54 +02:00
Guillaume BOURGEOIS 1ec065e91b feat(binaryedge): new BinaryEdge exposure-intel integration
BinaryEdge API v2, 6 commands: host lookup (current/historical), search, domain
subdomains, data-leak email check, subscription. API-key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:15:54 +02:00
Guillaume BOURGEOIS ee58358330 feat(hunter): new Hunter.io email-OSINT integration
Hunter.io API v2, 5 commands: domain search, email finder, email verifier,
account. API-key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:15:53 +02:00
Guillaume BOURGEOIS 5bf679cfe0 feat(symantec-dlp): new Symantec DLP integration
Symantec DLP Enforce REST API v2, 5 commands: list/get incidents, get incident
history, update incident (status/severity). OAuth2 password-grant auth,
stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:10:27 +02:00
Guillaume BOURGEOIS 70c66f7386 feat(forcepoint-dlp): new Forcepoint DLP integration
Forcepoint DLP REST API v1, 4 commands: list incidents, get incident, update
incident (status/severity/assignee). Token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:10:26 +02:00
Guillaume BOURGEOIS 4020e13df9 feat(microsoft-purview): new Microsoft Purview DLP integration
DLP alerts via Microsoft Graph Security API, 6 commands: list/get DLP alerts,
update alert (status/assignee/determination), list/get incidents. Azure AD
OAuth2 client-credentials auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:10:25 +02:00
Guillaume BOURGEOIS 2a1eb73115 feat(hibp): new Have I Been Pwned breach-enrichment integration
HIBP API v3 + Pwned Passwords, 6 commands: account breaches/pastes, list/get
breach, password pwned check (k-anonymity, password never transmitted).
API-key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:57:25 +02:00
Guillaume BOURGEOIS 81e39e8148 feat(telegram): new Telegram bot alerting integration
Telegram Bot API, 5 commands: send message, send photo, get chat, get updates.
Bot-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:57:24 +02:00
Guillaume BOURGEOIS d06ea406d3 feat(twilio): new Twilio SMS/voice alerting integration
Twilio REST API, 5 commands: send SMS, make voice call (TwiML), get/list
messages. HTTP Basic auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:57:23 +02:00
Guillaume BOURGEOIS fe7dae8bd3 feat(wiz): new Wiz CNAPP integration
Wiz GraphQL API, 6 commands: list/get issues, update issue status, list
vulnerabilities, list cloud resources. OAuth2 client-credentials auth,
stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:53:02 +02:00
Guillaume BOURGEOIS 52356523c9 feat(prisma-cloud): new Prisma Cloud CSPM integration
Prisma Cloud REST API, 7 commands: list/get alerts, dismiss alerts, list
policies, list cloud accounts, config (RQL) search. Access-key login auth,
stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:53:01 +02:00
Guillaume BOURGEOIS a567558341 feat(vault): new HashiCorp Vault secrets integration
Vault KV v2 + sys API, 7 commands: read/write/delete/list secrets, list mounts,
lookup token. Vault-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:53:01 +02:00
587 changed files with 34611 additions and 479 deletions
+70
View File
@@ -0,0 +1,70 @@
id: armis
name: Armis
version: 1.0.0
description: "Armis (API v1) — device and asset visibility: search devices and alerts with AQL, read a device, and update an alert's status. Secret-key (token exchange) authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: search devices/alerts, get device, update alert status."
category: asset_management
# Per-instance configuration. The secret key is exchanged for a short-lived
# access token (sent as the 'Authorization' header).
config_schema:
properties:
base_url:
type: string
description: "Armis instance URL (e.g. https://yourtenant.armis.com)"
secret_key:
type: string
description: "Armis secret key"
x-soar-sensitive: true
required:
- base_url
- secret_key
commands:
- id: search_devices
name: armis-search-devices
description: "Search devices with an AQL expression."
risk: read
inputs_schema:
properties:
aql: { type: string, description: "AQL filter appended to 'in:devices' (e.g. riskLevel:High)" }
length: { type: number, description: "Max results (default 50)" }
required: []
outputs_schema: { properties: {} }
- id: get_device
name: armis-get-device
description: "Get a single device by ID."
risk: read
inputs_schema:
properties:
device_id: { type: string, description: "Device ID" }
required: [device_id]
outputs_schema: { properties: {} }
- id: search_alerts
name: armis-search-alerts
description: "Search alerts with an AQL expression."
risk: read
inputs_schema:
properties:
aql: { type: string, description: "AQL filter appended to 'in:alerts' (e.g. status:Unhandled)" }
length: { type: number, description: "Max results (default 50)" }
required: []
outputs_schema: { properties: {} }
- id: update_alert
name: armis-update-alert
description: "Update an alert's status."
inputs_schema:
properties:
alert_id: { type: string, description: "Alert ID" }
status: { type: string, description: "New status (UNHANDLED, SUPPRESSED, or RESOLVED)" }
required: [alert_id, status]
outputs_schema: { properties: {} }
- id: test_connection
name: armis-test-connection
description: "Verify the secret key via the token exchange (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
+69
View File
@@ -0,0 +1,69 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return str(cfg.get("base_url", "")).rstrip("/") + "/api/v1"
def _token(cfg):
form = urllib.parse.urlencode({"secret_key": str(cfg.get("secret_key", ""))}).encode("utf-8")
req = urllib.request.Request(_base(cfg) + "/access_token/", data=form,
headers={"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
at = (tok.get("data") or {}).get("access_token")
if not at:
raise Exception("Token request failed: " + json.dumps(tok))
return at
def request(method, path, cfg, token, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": token, "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def _run(fn):
try:
cfg = _cfg()
inputs = _inputs()
token = _token(cfg)
print(json.dumps(fn(cfg, token, 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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, token, inputs):
device_id = inputs.get("device_id")
if not device_id:
raise Exception("device_id is required")
return request("GET", "/devices/" + q(device_id) + "/", cfg, token)
_run(main)
@@ -0,0 +1,66 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return str(cfg.get("base_url", "")).rstrip("/") + "/api/v1"
def _token(cfg):
form = urllib.parse.urlencode({"secret_key": str(cfg.get("secret_key", ""))}).encode("utf-8")
req = urllib.request.Request(_base(cfg) + "/access_token/", data=form,
headers={"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
at = (tok.get("data") or {}).get("access_token")
if not at:
raise Exception("Token request failed: " + json.dumps(tok))
return at
def request(method, path, cfg, token, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": token, "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def _run(fn):
try:
cfg = _cfg()
inputs = _inputs()
token = _token(cfg)
print(json.dumps(fn(cfg, token, 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, token, inputs):
aql = inputs.get("aql")
length = inputs.get("length")
aql_str = "in:alerts" + ((" " + aql) if aql else "")
return request("GET", "/search/", cfg, token, params={"aql": aql_str, "length": int(length or 50)})
_run(main)
@@ -0,0 +1,66 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return str(cfg.get("base_url", "")).rstrip("/") + "/api/v1"
def _token(cfg):
form = urllib.parse.urlencode({"secret_key": str(cfg.get("secret_key", ""))}).encode("utf-8")
req = urllib.request.Request(_base(cfg) + "/access_token/", data=form,
headers={"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
at = (tok.get("data") or {}).get("access_token")
if not at:
raise Exception("Token request failed: " + json.dumps(tok))
return at
def request(method, path, cfg, token, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": token, "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def _run(fn):
try:
cfg = _cfg()
inputs = _inputs()
token = _token(cfg)
print(json.dumps(fn(cfg, token, 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, token, inputs):
aql = inputs.get("aql")
length = inputs.get("length")
aql_str = "in:devices" + ((" " + aql) if aql else "")
return request("GET", "/search/", cfg, token, params={"aql": aql_str, "length": int(length or 50)})
_run(main)
@@ -0,0 +1,64 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return str(cfg.get("base_url", "")).rstrip("/") + "/api/v1"
def _token(cfg):
form = urllib.parse.urlencode({"secret_key": str(cfg.get("secret_key", ""))}).encode("utf-8")
req = urllib.request.Request(_base(cfg) + "/access_token/", data=form,
headers={"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
at = (tok.get("data") or {}).get("access_token")
if not at:
raise Exception("Token request failed: " + json.dumps(tok))
return at
def request(method, path, cfg, token, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": token, "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def _run(fn):
try:
cfg = _cfg()
inputs = _inputs()
token = _token(cfg)
print(json.dumps(fn(cfg, token, 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, token, inputs):
request("GET", "/search/", cfg, token, params={"aql": "in:devices", "length": 1})
return {"ok": True}
_run(main)
@@ -0,0 +1,75 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return str(cfg.get("base_url", "")).rstrip("/") + "/api/v1"
def _token(cfg):
form = urllib.parse.urlencode({"secret_key": str(cfg.get("secret_key", ""))}).encode("utf-8")
req = urllib.request.Request(_base(cfg) + "/access_token/", data=form,
headers={"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
at = (tok.get("data") or {}).get("access_token")
if not at:
raise Exception("Token request failed: " + json.dumps(tok))
return at
def request(method, path, cfg, token, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": token, "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def _run(fn):
try:
cfg = _cfg()
inputs = _inputs()
token = _token(cfg)
print(json.dumps(fn(cfg, token, 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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, token, inputs):
alert_id = inputs.get("alert_id")
status = inputs.get("status")
if not alert_id:
raise Exception("alert_id is required")
if not status:
raise Exception("status is required")
resp = request("PATCH", "/alerts/" + q(alert_id) + "/", cfg, token, body={"status": status})
if not resp:
return {"ok": True, "alert_id": alert_id}
return resp
_run(main)
+66
View File
@@ -0,0 +1,66 @@
id: automox
name: Automox
version: 1.0.0
description: "Automox (API) — endpoint patch and configuration management: list and read devices, list policies, and queue a command (e.g. install updates or run a policy) on a device. API-key authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: list/get devices, list policies, run device command."
category: endpoint
# Per-instance configuration. Auth header 'Authorization: Bearer <api_key>'.
config_schema:
properties:
api_key:
type: string
description: "Automox API key"
x-soar-sensitive: true
org_id:
type: string
description: "Organization ID"
required:
- api_key
- org_id
commands:
- id: list_devices
name: automox-list-devices
description: "List devices."
risk: read
inputs_schema:
properties:
limit: { type: number, description: "Max devices (default 50)" }
required: []
outputs_schema: { properties: {} }
- id: get_device
name: automox-get-device
description: "Get a single device by ID."
risk: read
inputs_schema:
properties:
device_id: { type: string, description: "Device (server) ID" }
required: [device_id]
outputs_schema: { properties: {} }
- id: list_policies
name: automox-list-policies
description: "List policies."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
- id: run_command
name: automox-run-command
description: "Queue a command on a device (e.g. InstallUpdate, Reboot)."
inputs_schema:
properties:
device_id: { type: string, description: "Device (server) ID" }
command_type: { type: string, description: "Command type (e.g. InstallUpdate, Reboot, GetOS)" }
required: [device_id, command_type]
outputs_schema: { properties: {} }
- id: test_connection
name: automox-test-connection
description: "Verify the API key (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,49 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://console.automox.com/api"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None, params=None):
p = {k: v for k, v in (params or {}).items() if v not in (None, "")}
p["o"] = str(cfg.get("org_id", ""))
url = BASE + path + "?" + urllib.parse.urlencode(p)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + str(cfg.get("api_key", "")), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
device_id = inputs.get("device_id")
if not device_id:
raise Exception("device_id is required")
return request("GET", "/servers/" + q(device_id), cfg)
_run(main)
@@ -0,0 +1,48 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://console.automox.com/api"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None, params=None):
p = {k: v for k, v in (params or {}).items() if v not in (None, "")}
p["o"] = str(cfg.get("org_id", ""))
url = BASE + path + "?" + urllib.parse.urlencode(p)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + str(cfg.get("api_key", "")), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
limit = inputs.get("limit")
limit = int(limit) if limit not in (None, "") else 50
return request("GET", "/servers", cfg, params={"limit": limit})
_run(main)
@@ -0,0 +1,46 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://console.automox.com/api"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None, params=None):
p = {k: v for k, v in (params or {}).items() if v not in (None, "")}
p["o"] = str(cfg.get("org_id", ""))
url = BASE + path + "?" + urllib.parse.urlencode(p)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + str(cfg.get("api_key", "")), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
return request("GET", "/policies", cfg)
_run(main)
@@ -0,0 +1,55 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://console.automox.com/api"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None, params=None):
p = {k: v for k, v in (params or {}).items() if v not in (None, "")}
p["o"] = str(cfg.get("org_id", ""))
url = BASE + path + "?" + urllib.parse.urlencode(p)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + str(cfg.get("api_key", "")), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
device_id = inputs.get("device_id")
if not device_id:
raise Exception("device_id is required")
command_type = inputs.get("command_type")
if not command_type:
raise Exception("command_type is required")
resp = request("POST", "/servers/" + q(device_id) + "/queues", cfg, body={"command_type_name": command_type})
if not resp:
return {"ok": True, "device_id": device_id, "command_type": command_type}
return resp
_run(main)
@@ -0,0 +1,47 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://console.automox.com/api"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None, params=None):
p = {k: v for k, v in (params or {}).items() if v not in (None, "")}
p["o"] = str(cfg.get("org_id", ""))
url = BASE + path + "?" + urllib.parse.urlencode(p)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + str(cfg.get("api_key", "")), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
request("GET", "/servers", cfg, params={"limit": 1})
return {"ok": True}
_run(main)
+76
View File
@@ -0,0 +1,76 @@
id: axonius
name: Axonius
version: 1.0.0
description: "Axonius (REST API) — cybersecurity asset management: query devices and users with AQL filters, get a device by ID, and count devices matching a filter. API-key + API-secret authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: list/get devices, device count, list users."
category: asset_management
# Per-instance configuration. Auth uses the 'api-key' and 'api-secret' headers.
config_schema:
properties:
base_url:
type: string
description: "Axonius instance URL (e.g. https://axonius.example.com)"
api_key:
type: string
description: "API key"
x-soar-sensitive: true
api_secret:
type: string
description: "API secret"
x-soar-sensitive: true
required:
- base_url
- api_key
- api_secret
commands:
- id: list_devices
name: axonius-list-devices
description: "Query devices with an optional AQL filter."
risk: read
inputs_schema:
properties:
filter: { type: string, description: "AQL filter (e.g. specific_data.data.hostname == \"host01\")" }
limit: { type: number, description: "Max devices (default 50)" }
offset: { type: number, description: "Offset (default 0)" }
required: []
outputs_schema: { properties: {} }
- id: get_device
name: axonius-get-device
description: "Get a single device by its internal Axonius ID."
risk: read
inputs_schema:
properties:
device_id: { type: string, description: "Internal Axonius device ID" }
required: [device_id]
outputs_schema: { properties: {} }
- id: device_count
name: axonius-device-count
description: "Count devices matching an AQL filter."
risk: read
inputs_schema:
properties:
filter: { type: string, description: "AQL filter (empty = all devices)" }
required: []
outputs_schema: { properties: {} }
- id: list_users
name: axonius-list-users
description: "Query users with an optional AQL filter."
risk: read
inputs_schema:
properties:
filter: { type: string, description: "AQL filter" }
limit: { type: number, description: "Max users (default 50)" }
offset: { type: number, description: "Offset (default 0)" }
required: []
outputs_schema: { properties: {} }
- id: test_connection
name: axonius-test-connection
description: "Verify connectivity and credentials (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,52 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None):
url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {
"api-key": str(cfg.get("api_key", "")),
"api-secret": str(cfg.get("api_secret", "")),
"Accept": "application/json",
}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
filter_ = inputs.get("filter")
body = {"data": {}}
if filter_:
body["data"]["filter"] = filter_
return request("POST", "/devices/count", cfg, body=body)
_run(main)
@@ -0,0 +1,50 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None):
url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {
"api-key": str(cfg.get("api_key", "")),
"api-secret": str(cfg.get("api_secret", "")),
"Accept": "application/json",
}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
device_id = inputs.get("device_id")
if not device_id:
raise Exception("device_id is required")
return request("GET", "/devices/" + q(device_id), cfg)
_run(main)
@@ -0,0 +1,61 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None):
url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {
"api-key": str(cfg.get("api_key", "")),
"api-secret": str(cfg.get("api_secret", "")),
"Accept": "application/json",
}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
filter_ = inputs.get("filter")
limit = inputs.get("limit")
offset = inputs.get("offset")
body = {
"data": {
"page": {
"limit": int(limit or 50),
"offset": int(offset or 0),
}
}
}
if filter_:
body["data"]["filter"] = filter_
return request("POST", "/devices", cfg, body=body)
_run(main)
@@ -0,0 +1,61 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None):
url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {
"api-key": str(cfg.get("api_key", "")),
"api-secret": str(cfg.get("api_secret", "")),
"Accept": "application/json",
}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
filter_ = inputs.get("filter")
limit = inputs.get("limit")
offset = inputs.get("offset")
body = {
"data": {
"page": {
"limit": int(limit or 50),
"offset": int(offset or 0),
}
}
}
if filter_:
body["data"]["filter"] = filter_
return request("POST", "/users", cfg, body=body)
_run(main)
@@ -0,0 +1,47 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None):
url = str(cfg.get("base_url", "")).rstrip("/") + "/api" + path
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {
"api-key": str(cfg.get("api_key", "")),
"api-secret": str(cfg.get("api_secret", "")),
"Accept": "application/json",
}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=90) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
request("POST", "/devices/count", cfg, body={"data": {}})
return {"ok": True}
_run(main)
@@ -0,0 +1,77 @@
id: beyondtrust_password_safe
name: BeyondTrust Password Safe
version: 1.0.0
description: "BeyondTrust Password Safe (Secrets Safe REST API v3) — privileged access and credential retrieval: list managed accounts and systems, request a credential release, and retrieve the credential. API-key (PS-Auth) session authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: list managed accounts/systems, create release request, get credential."
category: identity
# Per-instance configuration. Auth signs in with an API key + runas user
# (header 'Authorization: PS-Auth key=<api_key>; runas=<runas_user>;'), which
# establishes a session reused for the request.
config_schema:
properties:
base_url:
type: string
description: "BeyondTrust URL (e.g. https://beyondtrust.example.com)"
api_key:
type: string
description: "API registration key"
x-soar-sensitive: true
runas_user:
type: string
description: "Username to run as"
insecure:
type: boolean
description: "Trust any TLS certificate (not secure)"
default: false
required:
- base_url
- api_key
- runas_user
commands:
- id: list_managed_accounts
name: beyondtrust-list-managed-accounts
description: "List managed accounts."
risk: read
inputs_schema:
properties:
account_name: { type: string, description: "Optional account name filter" }
required: []
outputs_schema: { properties: {} }
- id: list_managed_systems
name: beyondtrust-list-managed-systems
description: "List managed systems."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
- id: create_release_request
name: beyondtrust-create-release-request
description: "Request a credential release for a managed account."
inputs_schema:
properties:
system_id: { type: string, description: "Managed system ID" }
account_id: { type: string, description: "Managed account ID" }
duration_minutes: { type: number, description: "Access duration in minutes (default 30)" }
reason: { type: string, description: "Reason for the request" }
required: [system_id, account_id]
outputs_schema: { properties: {} }
- id: get_credential
name: beyondtrust-get-credential
description: "Retrieve the credential for an approved request."
inputs_schema:
properties:
request_id: { type: string, description: "Request ID (from create-release-request)" }
required: [request_id]
outputs_schema: { properties: {} }
- id: test_connection
name: beyondtrust-test-connection
description: "Verify the sign-in (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,103 @@
import json, os, sys, ssl, http.cookiejar
import urllib.parse, urllib.request, urllib.error
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
class Client:
def __init__(self, cfg):
self.cfg = cfg
self.base = str(cfg.get("base_url", "")).rstrip("/") + "/BeyondTrust/api/public/v3"
ctx = _ctx(cfg)
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
urllib.request.HTTPSHandler(context=ctx) if ctx else urllib.request.HTTPSHandler(),
)
def _auth_header(self):
return "PS-Auth key=" + str(self.cfg.get("api_key", "")) + "; runas=" + str(self.cfg.get("runas_user", "")) + ";"
def signin(self):
req = urllib.request.Request(self.base + "/Auth/SignAppin", data=b"",
headers={"Authorization": self._auth_header(), "Accept": "application/json"}, method="POST")
with self.opener.open(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def call(self, method, path, body=None, params=None):
url = self.base + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Accept": "application/json", "Authorization": self._auth_header()}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with self.opener.open(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def signout(self):
try:
req = urllib.request.Request(self.base + "/Auth/Signout", data=b"", method="POST")
self.opener.open(req, timeout=30).read()
except Exception:
pass
def _run(fn):
try:
cfg = _cfg()
inputs = _inputs()
client = Client(cfg)
client.signin()
try:
result = fn(client, inputs)
finally:
client.signout()
print(json.dumps(result))
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(client, inputs):
system_id = inputs.get("system_id")
account_id = inputs.get("account_id")
duration_minutes = inputs.get("duration_minutes")
reason = inputs.get("reason")
if not system_id:
raise Exception("system_id is required")
if not account_id:
raise Exception("account_id is required")
body = {
"SystemId": int(system_id),
"AccountId": int(account_id),
"DurationMinutes": int(duration_minutes) if duration_minutes else 30,
"Reason": reason or "Riposte SOAR",
"AccessType": "View",
}
return client.call("POST", "/Requests", body=body)
_run(main)
@@ -0,0 +1,93 @@
import json, os, sys, ssl, http.cookiejar
import urllib.parse, urllib.request, urllib.error
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
class Client:
def __init__(self, cfg):
self.cfg = cfg
self.base = str(cfg.get("base_url", "")).rstrip("/") + "/BeyondTrust/api/public/v3"
ctx = _ctx(cfg)
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
urllib.request.HTTPSHandler(context=ctx) if ctx else urllib.request.HTTPSHandler(),
)
def _auth_header(self):
return "PS-Auth key=" + str(self.cfg.get("api_key", "")) + "; runas=" + str(self.cfg.get("runas_user", "")) + ";"
def signin(self):
req = urllib.request.Request(self.base + "/Auth/SignAppin", data=b"",
headers={"Authorization": self._auth_header(), "Accept": "application/json"}, method="POST")
with self.opener.open(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def call(self, method, path, body=None, params=None):
url = self.base + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Accept": "application/json", "Authorization": self._auth_header()}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with self.opener.open(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def signout(self):
try:
req = urllib.request.Request(self.base + "/Auth/Signout", data=b"", method="POST")
self.opener.open(req, timeout=30).read()
except Exception:
pass
def _run(fn):
try:
cfg = _cfg()
inputs = _inputs()
client = Client(cfg)
client.signin()
try:
result = fn(client, inputs)
finally:
client.signout()
print(json.dumps(result))
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(client, inputs):
request_id = inputs.get("request_id")
if not request_id:
raise Exception("request_id is required")
q = lambda v: urllib.parse.quote(str(v), safe="")
response = client.call("GET", "/Credentials/" + q(request_id))
return {"request_id": request_id, "credential": response}
_run(main)
@@ -0,0 +1,89 @@
import json, os, sys, ssl, http.cookiejar
import urllib.parse, urllib.request, urllib.error
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
class Client:
def __init__(self, cfg):
self.cfg = cfg
self.base = str(cfg.get("base_url", "")).rstrip("/") + "/BeyondTrust/api/public/v3"
ctx = _ctx(cfg)
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
urllib.request.HTTPSHandler(context=ctx) if ctx else urllib.request.HTTPSHandler(),
)
def _auth_header(self):
return "PS-Auth key=" + str(self.cfg.get("api_key", "")) + "; runas=" + str(self.cfg.get("runas_user", "")) + ";"
def signin(self):
req = urllib.request.Request(self.base + "/Auth/SignAppin", data=b"",
headers={"Authorization": self._auth_header(), "Accept": "application/json"}, method="POST")
with self.opener.open(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def call(self, method, path, body=None, params=None):
url = self.base + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Accept": "application/json", "Authorization": self._auth_header()}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with self.opener.open(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def signout(self):
try:
req = urllib.request.Request(self.base + "/Auth/Signout", data=b"", method="POST")
self.opener.open(req, timeout=30).read()
except Exception:
pass
def _run(fn):
try:
cfg = _cfg()
inputs = _inputs()
client = Client(cfg)
client.signin()
try:
result = fn(client, inputs)
finally:
client.signout()
print(json.dumps(result))
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(client, inputs):
account_name = inputs.get("account_name")
return client.call("GET", "/ManagedAccounts", params={"accountName": account_name})
_run(main)
@@ -0,0 +1,88 @@
import json, os, sys, ssl, http.cookiejar
import urllib.parse, urllib.request, urllib.error
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
class Client:
def __init__(self, cfg):
self.cfg = cfg
self.base = str(cfg.get("base_url", "")).rstrip("/") + "/BeyondTrust/api/public/v3"
ctx = _ctx(cfg)
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
urllib.request.HTTPSHandler(context=ctx) if ctx else urllib.request.HTTPSHandler(),
)
def _auth_header(self):
return "PS-Auth key=" + str(self.cfg.get("api_key", "")) + "; runas=" + str(self.cfg.get("runas_user", "")) + ";"
def signin(self):
req = urllib.request.Request(self.base + "/Auth/SignAppin", data=b"",
headers={"Authorization": self._auth_header(), "Accept": "application/json"}, method="POST")
with self.opener.open(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def call(self, method, path, body=None, params=None):
url = self.base + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Accept": "application/json", "Authorization": self._auth_header()}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with self.opener.open(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def signout(self):
try:
req = urllib.request.Request(self.base + "/Auth/Signout", data=b"", method="POST")
self.opener.open(req, timeout=30).read()
except Exception:
pass
def _run(fn):
try:
cfg = _cfg()
inputs = _inputs()
client = Client(cfg)
client.signin()
try:
result = fn(client, inputs)
finally:
client.signout()
print(json.dumps(result))
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(client, inputs):
return client.call("GET", "/ManagedSystems")
_run(main)
@@ -0,0 +1,89 @@
import json, os, sys, ssl, http.cookiejar
import urllib.parse, urllib.request, urllib.error
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
class Client:
def __init__(self, cfg):
self.cfg = cfg
self.base = str(cfg.get("base_url", "")).rstrip("/") + "/BeyondTrust/api/public/v3"
ctx = _ctx(cfg)
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
urllib.request.HTTPSHandler(context=ctx) if ctx else urllib.request.HTTPSHandler(),
)
def _auth_header(self):
return "PS-Auth key=" + str(self.cfg.get("api_key", "")) + "; runas=" + str(self.cfg.get("runas_user", "")) + ";"
def signin(self):
req = urllib.request.Request(self.base + "/Auth/SignAppin", data=b"",
headers={"Authorization": self._auth_header(), "Accept": "application/json"}, method="POST")
with self.opener.open(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def call(self, method, path, body=None, params=None):
url = self.base + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Accept": "application/json", "Authorization": self._auth_header()}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with self.opener.open(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def signout(self):
try:
req = urllib.request.Request(self.base + "/Auth/Signout", data=b"", method="POST")
self.opener.open(req, timeout=30).read()
except Exception:
pass
def _run(fn):
try:
cfg = _cfg()
inputs = _inputs()
client = Client(cfg)
client.signin()
try:
result = fn(client, inputs)
finally:
client.signout()
print(json.dumps(result))
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(client, inputs):
client.call("GET", "/ManagedSystems")
return {"ok": True}
_run(main)
+74
View File
@@ -0,0 +1,74 @@
id: binaryedge
name: BinaryEdge
version: 1.0.0
description: "BinaryEdge (API v2) — internet exposure intelligence: query current and historical open ports/services for an IP, run a search, enumerate a domain's subdomains, check an email against data leaks, and read the subscription quota. API-key authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: host lookup (current/historical), search, domain subdomains, data-leak email check, subscription."
category: enrichment
# Per-instance configuration. The API key is sent as the 'X-Key' header.
config_schema:
properties:
api_key:
type: string
description: "BinaryEdge API key"
x-soar-sensitive: true
required:
- api_key
commands:
- id: host
name: binaryedge-host
description: "Get the most recent open ports and services for an IP."
risk: read
inputs_schema:
properties:
ip: { type: string, description: "IP address" }
required: [ip]
outputs_schema: { properties: {} }
- id: host_historical
name: binaryedge-host-historical
description: "Get historical open ports and services for an IP."
risk: read
inputs_schema:
properties:
ip: { type: string, description: "IP address" }
required: [ip]
outputs_schema: { properties: {} }
- id: search
name: binaryedge-search
description: "Search hosts/services by a BinaryEdge query."
risk: read
inputs_schema:
properties:
query: { type: string, description: "BinaryEdge search query (e.g. type:elasticsearch)" }
page: { type: number, description: "Page number (default 1)" }
required: [query]
outputs_schema: { properties: {} }
- id: domain_subdomains
name: binaryedge-domain-subdomains
description: "List a domain's known subdomains."
risk: read
inputs_schema:
properties:
domain: { type: string, description: "Domain name" }
page: { type: number, description: "Page number (default 1)" }
required: [domain]
outputs_schema: { properties: {} }
- id: dataleaks_email
name: binaryedge-dataleaks-email
description: "Check whether an email appears in known data leaks."
risk: read
inputs_schema:
properties:
email: { type: string, description: "Email address" }
required: [email]
outputs_schema: { properties: {} }
- id: test_connection
name: binaryedge-test-connection
description: "Verify the API key via the subscription endpoint (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,47 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://api.binaryedge.io/v2"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(path, cfg, params=None):
url = BASE + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
req = urllib.request.Request(url, headers={"X-Key": str(cfg.get("api_key", "")), "Accept": "application/json"}, method="GET")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
email = inputs.get("email")
if not email:
raise Exception("email is required")
return request("/query/dataleaks/email/" + q(email), cfg)
_run(main)
@@ -0,0 +1,48 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://api.binaryedge.io/v2"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(path, cfg, params=None):
url = BASE + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
req = urllib.request.Request(url, headers={"X-Key": str(cfg.get("api_key", "")), "Accept": "application/json"}, method="GET")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
domain = inputs.get("domain")
if not domain:
raise Exception("domain is required")
page = inputs.get("page")
return request("/query/domains/subdomain/" + q(domain), cfg, params={"page": int(page or 1)})
_run(main)
+47
View File
@@ -0,0 +1,47 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://api.binaryedge.io/v2"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(path, cfg, params=None):
url = BASE + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
req = urllib.request.Request(url, headers={"X-Key": str(cfg.get("api_key", "")), "Accept": "application/json"}, method="GET")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
ip = inputs.get("ip")
if not ip:
raise Exception("ip is required")
return request("/query/ip/" + q(ip), cfg)
_run(main)
@@ -0,0 +1,47 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://api.binaryedge.io/v2"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(path, cfg, params=None):
url = BASE + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
req = urllib.request.Request(url, headers={"X-Key": str(cfg.get("api_key", "")), "Accept": "application/json"}, method="GET")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
ip = inputs.get("ip")
if not ip:
raise Exception("ip is required")
return request("/query/ip/historical/" + q(ip), cfg)
_run(main)
+48
View File
@@ -0,0 +1,48 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://api.binaryedge.io/v2"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(path, cfg, params=None):
url = BASE + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
req = urllib.request.Request(url, headers={"X-Key": str(cfg.get("api_key", "")), "Accept": "application/json"}, method="GET")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
query = inputs.get("query")
if not query:
raise Exception("query is required")
page = inputs.get("page")
return request("/query/search", cfg, params={"query": query, "page": int(page or 1)})
_run(main)
@@ -0,0 +1,45 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://api.binaryedge.io/v2"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(path, cfg, params=None):
url = BASE + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
req = urllib.request.Request(url, headers={"X-Key": str(cfg.get("api_key", "")), "Accept": "application/json"}, method="GET")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
request("/user/subscription", cfg)
return {"ok": True}
_run(main)
+74
View File
@@ -0,0 +1,74 @@
id: box
name: Box
version: 1.0.0
description: "Box (Content API v2) — evidence and file handling: search files, read file/folder metadata, list a folder's items, and create a shared link. Bearer-token authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: search, get file/folder info, list folder items, create shared link."
category: productivity
# Per-instance configuration. Auth header 'Authorization: Bearer <access_token>'.
config_schema:
properties:
access_token:
type: string
description: "Box access token (developer token or OAuth2/JWT-issued token)"
x-soar-sensitive: true
required:
- access_token
commands:
- id: search
name: box-search
description: "Search for files and folders by keyword."
risk: read
inputs_schema:
properties:
query: { type: string, description: "Search query" }
limit: { type: number, description: "Max results (default 30)" }
required: [query]
outputs_schema: { properties: {} }
- id: get_file_info
name: box-get-file-info
description: "Get a file's metadata."
risk: read
inputs_schema:
properties:
file_id: { type: string, description: "File ID" }
required: [file_id]
outputs_schema: { properties: {} }
- id: get_folder_info
name: box-get-folder-info
description: "Get a folder's metadata."
risk: read
inputs_schema:
properties:
folder_id: { type: string, description: "Folder ID (0 = root)" }
required: [folder_id]
outputs_schema: { properties: {} }
- id: list_folder_items
name: box-list-folder-items
description: "List the items inside a folder."
risk: read
inputs_schema:
properties:
folder_id: { type: string, description: "Folder ID (0 = root)" }
limit: { type: number, description: "Max items (default 100)" }
required: [folder_id]
outputs_schema: { properties: {} }
- id: create_shared_link
name: box-create-shared-link
description: "Create a shared link for a file."
inputs_schema:
properties:
file_id: { type: string, description: "File ID" }
access: { type: string, description: "open, company, or collaborators (default company)" }
required: [file_id]
outputs_schema: { properties: {} }
- id: test_connection
name: box-test-connection
description: "Verify the access token (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,58 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://api.box.com/2.0"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None, params=None):
url = BASE + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + str(cfg.get("access_token", "")), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
file_id = inputs.get("file_id")
if not file_id:
raise Exception("file_id is required")
access = inputs.get("access")
return request(
"PUT",
"/files/" + q(file_id),
cfg,
body={"shared_link": {"access": (access or "company")}},
params={"fields": "shared_link"},
)
_run(main)
+51
View File
@@ -0,0 +1,51 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://api.box.com/2.0"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None, params=None):
url = BASE + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + str(cfg.get("access_token", "")), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
file_id = inputs.get("file_id")
if not file_id:
raise Exception("file_id is required")
return request("GET", "/files/" + q(file_id), cfg)
_run(main)
@@ -0,0 +1,51 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://api.box.com/2.0"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None, params=None):
url = BASE + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + str(cfg.get("access_token", "")), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
folder_id = inputs.get("folder_id")
if not folder_id:
raise Exception("folder_id is required")
return request("GET", "/folders/" + q(folder_id), cfg)
_run(main)
@@ -0,0 +1,52 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://api.box.com/2.0"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None, params=None):
url = BASE + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + str(cfg.get("access_token", "")), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
folder_id = inputs.get("folder_id")
if not folder_id:
raise Exception("folder_id is required")
limit = inputs.get("limit")
return request("GET", "/folders/" + q(folder_id) + "/items", cfg, params={"limit": int(limit or 100)})
_run(main)
+49
View File
@@ -0,0 +1,49 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://api.box.com/2.0"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None, params=None):
url = BASE + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + str(cfg.get("access_token", "")), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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):
query = inputs.get("query")
if not query:
raise Exception("query is required")
limit = inputs.get("limit")
return request("GET", "/search", cfg, params={"query": query, "limit": int(limit or 30)})
_run(main)
@@ -0,0 +1,46 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "https://api.box.com/2.0"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def request(method, path, cfg, body=None, params=None):
url = BASE + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + str(cfg.get("access_token", "")), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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):
resp = request("GET", "/users/me", cfg)
return {"ok": True, "login": resp.get("login")}
_run(main)
@@ -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" }
+83
View File
@@ -0,0 +1,83 @@
id: cisco_ise
name: Cisco ISE
version: 1.0.0
description: "Cisco Identity Services Engine (ERS + ANC API) — network access containment: list and read endpoints, apply an Adaptive Network Control (ANC) quarantine policy to an endpoint, and clear it. HTTP Basic authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: list/get endpoints, apply/clear ANC policy, list ANC policies."
category: network
# Per-instance configuration. HTTP Basic auth against the ERS API (port 9060).
config_schema:
properties:
base_url:
type: string
description: "ISE URL including the ERS port (e.g. https://ise.example.com:9060)"
username:
type: string
description: "ERS admin username"
password:
type: string
description: "ERS admin password"
x-soar-sensitive: true
insecure:
type: boolean
description: "Trust any TLS certificate (not secure)"
default: false
required:
- base_url
- username
- password
commands:
- id: list_endpoints
name: ise-list-endpoints
description: "List endpoints (optionally filter by MAC)."
risk: read
inputs_schema:
properties:
mac: { type: string, description: "Optional MAC address filter" }
required: []
outputs_schema: { properties: {} }
- id: get_endpoint
name: ise-get-endpoint
description: "Get an endpoint by ID."
risk: read
inputs_schema:
properties:
endpoint_id: { type: string, description: "Endpoint ID" }
required: [endpoint_id]
outputs_schema: { properties: {} }
- id: apply_anc_policy
name: ise-apply-anc-policy
description: "Apply an ANC policy to an endpoint by MAC (quarantine — containment)."
inputs_schema:
properties:
mac: { type: string, description: "Endpoint MAC address" }
policy_name: { type: string, description: "ANC policy name (e.g. Quarantine)" }
required: [mac, policy_name]
outputs_schema: { properties: {} }
- id: clear_anc_policy
name: ise-clear-anc-policy
description: "Clear the ANC policy from an endpoint by MAC."
inputs_schema:
properties:
mac: { type: string, description: "Endpoint MAC address" }
policy_name: { type: string, description: "ANC policy name currently applied" }
required: [mac, policy_name]
outputs_schema: { properties: {} }
- id: list_anc_policies
name: ise-list-anc-policies
description: "List ANC policies."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
- id: test_connection
name: ise-test-connection
description: "Verify connectivity and credentials (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,77 @@
import json, os, sys, base64, ssl, urllib.parse, urllib.request, urllib.error
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 _auth(cfg):
raw = str(cfg.get("username", "")) + ":" + str(cfg.get("password", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = str(cfg.get("base_url", "")).rstrip("/") + "/ers/config" + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
mac = inputs.get("mac")
policy_name = inputs.get("policy_name")
if not mac:
raise Exception("mac is required")
if not policy_name:
raise Exception("policy_name is required")
body = {
"OperationAdditionalData": {
"additionalData": [
{"name": "macAddress", "value": mac},
{"name": "policyName", "value": policy_name},
]
}
}
result = request("PUT", "/ancendpoint/apply", cfg, body=body)
if not result:
return {"ok": True, "mac": mac, "policy": policy_name, "action": "apply"}
return result
_run(main)
@@ -0,0 +1,77 @@
import json, os, sys, base64, ssl, urllib.parse, urllib.request, urllib.error
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 _auth(cfg):
raw = str(cfg.get("username", "")) + ":" + str(cfg.get("password", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = str(cfg.get("base_url", "")).rstrip("/") + "/ers/config" + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
mac = inputs.get("mac")
policy_name = inputs.get("policy_name")
if not mac:
raise Exception("mac is required")
if not policy_name:
raise Exception("policy_name is required")
body = {
"OperationAdditionalData": {
"additionalData": [
{"name": "macAddress", "value": mac},
{"name": "policyName", "value": policy_name},
]
}
}
result = request("PUT", "/ancendpoint/clear", cfg, body=body)
if not result:
return {"ok": True, "mac": mac, "policy": policy_name, "action": "clear"}
return result
_run(main)
@@ -0,0 +1,63 @@
import json, os, sys, base64, ssl, urllib.parse, urllib.request, urllib.error
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 _auth(cfg):
raw = str(cfg.get("username", "")) + ":" + str(cfg.get("password", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = str(cfg.get("base_url", "")).rstrip("/") + "/ers/config" + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
endpoint_id = inputs.get("endpoint_id")
if not endpoint_id:
raise Exception("endpoint_id is required")
return request("GET", "/endpoint/" + q(endpoint_id), cfg)
_run(main)
@@ -0,0 +1,60 @@
import json, os, sys, base64, ssl, urllib.parse, urllib.request, urllib.error
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 _auth(cfg):
raw = str(cfg.get("username", "")) + ":" + str(cfg.get("password", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = str(cfg.get("base_url", "")).rstrip("/") + "/ers/config" + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
return request("GET", "/ancpolicy", cfg)
_run(main)
@@ -0,0 +1,62 @@
import json, os, sys, base64, ssl, urllib.parse, urllib.request, urllib.error
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 _auth(cfg):
raw = str(cfg.get("username", "")) + ":" + str(cfg.get("password", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = str(cfg.get("base_url", "")).rstrip("/") + "/ers/config" + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
mac = inputs.get("mac")
params = {"filter": "mac.EQ." + mac} if mac else {}
return request("GET", "/endpoint", cfg, params=params)
_run(main)
@@ -0,0 +1,61 @@
import json, os, sys, base64, ssl, urllib.parse, urllib.request, urllib.error
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 _auth(cfg):
raw = str(cfg.get("username", "")) + ":" + str(cfg.get("password", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = str(cfg.get("base_url", "")).rstrip("/") + "/ers/config" + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60, context=_ctx(cfg)) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
request("GET", "/endpoint", cfg, params={"size": 1})
return {"ok": True}
_run(main)
@@ -0,0 +1,98 @@
id: cisco_secure_endpoint
name: Cisco Secure Endpoint
version: 1.0.0
description: "Cisco Secure Endpoint (AMP for Endpoints API v1) — endpoint containment: list and read computers, isolate/unisolate a host, list events, and read a computer's trajectory. HTTP Basic authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: list/get computers, isolate/stop-isolation, list events, get trajectory."
category: endpoint
# Per-instance configuration. HTTP Basic auth with the API client ID + key.
config_schema:
properties:
base_url:
type: string
description: "AMP API host (region-specific)"
default: "https://api.amp.cisco.com"
client_id:
type: string
description: "API client ID"
api_key:
type: string
description: "API key"
x-soar-sensitive: true
required:
- client_id
- api_key
commands:
- id: list_computers
name: cisco-amp-list-computers
description: "List computers (endpoints)."
risk: read
inputs_schema:
properties:
hostname: { type: string, description: "Optional hostname filter" }
limit: { type: number, description: "Max computers (default 50)" }
required: []
outputs_schema: { properties: {} }
- id: get_computer
name: cisco-amp-get-computer
description: "Get a single computer by connector GUID."
risk: read
inputs_schema:
properties:
connector_guid: { type: string, description: "Connector GUID" }
required: [connector_guid]
outputs_schema: { properties: {} }
- id: isolate_computer
name: cisco-amp-isolate-computer
description: "Isolate a computer from the network (containment)."
inputs_schema:
properties:
connector_guid: { type: string, description: "Connector GUID" }
comment: { type: string, description: "Optional isolation comment" }
required: [connector_guid]
outputs_schema: { properties: {} }
- id: stop_isolation
name: cisco-amp-stop-isolation
description: "Stop isolation on a computer."
inputs_schema:
properties:
connector_guid: { type: string, description: "Connector GUID" }
required: [connector_guid]
outputs_schema: { properties: {} }
- id: list_events
name: cisco-amp-list-events
description: "List events. Used for ingestion: results path = data."
risk: read
inputs_schema:
properties:
connector_guid: { type: string, description: "Optional connector GUID filter" }
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."
risk: read
inputs_schema:
properties:
connector_guid: { type: string, description: "Connector GUID" }
required: [connector_guid]
outputs_schema: { properties: {} }
- id: test_connection
name: cisco-amp-test-connection
description: "Verify connectivity and credentials (used by the Test button)."
risk: read
inputs_schema:
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,58 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
def _auth(cfg):
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
connector_guid = inputs.get("connector_guid")
if not connector_guid:
raise Exception("connector_guid is required")
return request("GET", "/computers/" + q(connector_guid), cfg)
_run(main)
@@ -0,0 +1,58 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
def _auth(cfg):
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
connector_guid = inputs.get("connector_guid")
if not connector_guid:
raise Exception("connector_guid is required")
return request("GET", "/computers/" + q(connector_guid) + "/trajectory", cfg)
_run(main)
@@ -0,0 +1,64 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
def _auth(cfg):
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
connector_guid = inputs.get("connector_guid")
if not connector_guid:
raise Exception("connector_guid is required")
comment = inputs.get("comment")
return request(
"PUT",
"/computers/" + q(connector_guid) + "/isolation",
cfg,
body=({"comment": comment} if comment else {}),
)
_run(main)
@@ -0,0 +1,59 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
def _auth(cfg):
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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):
hostname = inputs.get("hostname")
limit = inputs.get("limit")
return request(
"GET",
"/computers",
cfg,
params={"hostname[]": hostname, "limit": int(limit or 50)},
)
_run(main)
@@ -0,0 +1,59 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
def _auth(cfg):
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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):
connector_guid = inputs.get("connector_guid")
limit = inputs.get("limit")
return request(
"GET",
"/events",
cfg,
params={"connector_guid[]": connector_guid, "limit": int(limit or 50)},
)
_run(main)
@@ -0,0 +1,58 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
def _auth(cfg):
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
connector_guid = inputs.get("connector_guid")
if not connector_guid:
raise Exception("connector_guid is required")
return request("DELETE", "/computers/" + q(connector_guid) + "/isolation", cfg)
_run(main)
@@ -0,0 +1,53 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return (str(cfg.get("base_url") or "https://api.amp.cisco.com")).rstrip("/") + "/v1"
def _auth(cfg):
raw = str(cfg.get("client_id", "")) + ":" + str(cfg.get("api_key", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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):
request("GET", "/version", cfg)
return {"ok": True}
_run(main)
+86
View File
@@ -0,0 +1,86 @@
id: confluence
name: Confluence
version: 1.0.0
description: "Atlassian Confluence (REST API) — runbook and knowledge handling: search content (CQL), read a page, create and update pages, and list spaces. Basic (email + API token) authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: search content, get page, create/update page, list spaces."
category: productivity
# Per-instance configuration. HTTP Basic auth with an email + API token (Cloud)
# or username + password (Server/DC).
config_schema:
properties:
base_url:
type: string
description: "Confluence base URL (e.g. https://your.atlassian.net/wiki)"
email:
type: string
description: "Account email (Cloud) or username (Server)"
api_token:
type: string
description: "API token (Cloud) or password (Server)"
x-soar-sensitive: true
required:
- base_url
- email
- api_token
commands:
- id: search
name: confluence-search
description: "Search content using CQL."
risk: read
inputs_schema:
properties:
cql: { type: string, description: "CQL query (e.g. text ~ \"phishing runbook\")" }
limit: { type: number, description: "Max results (default 25)" }
required: [cql]
outputs_schema: { properties: {} }
- id: get_page
name: confluence-get-page
description: "Get a page by ID (with body)."
risk: read
inputs_schema:
properties:
page_id: { type: string, description: "Page (content) ID" }
required: [page_id]
outputs_schema: { properties: {} }
- id: create_page
name: confluence-create-page
description: "Create a page in a space."
inputs_schema:
properties:
space_key: { type: string, description: "Space key" }
title: { type: string, description: "Page title" }
body: { type: string, description: "Page body (storage/HTML format)" }
parent_id: { type: string, description: "Optional parent page ID" }
required: [space_key, title, body]
outputs_schema: { properties: {} }
- id: update_page
name: confluence-update-page
description: "Update a page's title/body (increments the version)."
inputs_schema:
properties:
page_id: { type: string, description: "Page ID" }
title: { type: string, description: "New title" }
body: { type: string, description: "New body (storage/HTML)" }
version: { type: number, description: "New version number (current version + 1)" }
required: [page_id, title, body, version]
outputs_schema: { properties: {} }
- id: list_spaces
name: confluence-list-spaces
description: "List spaces."
risk: read
inputs_schema:
properties:
limit: { type: number, description: "Max spaces (default 25)" }
required: []
outputs_schema: { properties: {} }
- id: test_connection
name: confluence-test-connection
description: "Verify connectivity and credentials (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,75 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return str(cfg.get("base_url", "")).rstrip("/") + "/rest/api"
def _auth(cfg):
raw = str(cfg.get("email", "")) + ":" + str(cfg.get("api_token", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
space_key = inputs.get("space_key")
if not space_key:
raise Exception("space_key is required")
title = inputs.get("title")
if not title:
raise Exception("title is required")
body = inputs.get("body")
if not body:
raise Exception("body is required")
parent_id = inputs.get("parent_id")
page = {
"type": "page",
"title": title,
"space": {"key": space_key},
"body": {"storage": {"value": body, "representation": "storage"}},
}
if parent_id not in (None, ""):
page["ancestors"] = [{"id": parent_id}]
return request("POST", "/content", cfg, body=page)
_run(main)
@@ -0,0 +1,58 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return str(cfg.get("base_url", "")).rstrip("/") + "/rest/api"
def _auth(cfg):
raw = str(cfg.get("email", "")) + ":" + str(cfg.get("api_token", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
page_id = inputs.get("page_id")
if not page_id:
raise Exception("page_id is required")
return request("GET", "/content/" + q(page_id), cfg, params={"expand": "body.storage,version,space"})
_run(main)
@@ -0,0 +1,56 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return str(cfg.get("base_url", "")).rstrip("/") + "/rest/api"
def _auth(cfg):
raw = str(cfg.get("email", "")) + ":" + str(cfg.get("api_token", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
limit = inputs.get("limit")
return request("GET", "/space", cfg, params={"limit": int(limit or 25)})
_run(main)
+59
View File
@@ -0,0 +1,59 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return str(cfg.get("base_url", "")).rstrip("/") + "/rest/api"
def _auth(cfg):
raw = str(cfg.get("email", "")) + ":" + str(cfg.get("api_token", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
cql = inputs.get("cql")
if not cql:
raise Exception("cql is required")
limit = inputs.get("limit")
return request("GET", "/content/search", cfg, params={"cql": cql, "limit": int(limit or 25)})
_run(main)
@@ -0,0 +1,56 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return str(cfg.get("base_url", "")).rstrip("/") + "/rest/api"
def _auth(cfg):
raw = str(cfg.get("email", "")) + ":" + str(cfg.get("api_token", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
request("GET", "/space", cfg, params={"limit": 1})
return {"ok": True}
_run(main)
@@ -0,0 +1,78 @@
import json, os, sys, base64, urllib.parse, urllib.request, urllib.error
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _base(cfg):
return str(cfg.get("base_url", "")).rstrip("/") + "/rest/api"
def _auth(cfg):
raw = str(cfg.get("email", "")) + ":" + str(cfg.get("api_token", ""))
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def request(method, path, cfg, body=None, params=None):
url = _base(cfg) + path
if params:
clean = {k: v for k, v in params.items() if v not in (None, "")}
if clean:
url += "?" + urllib.parse.urlencode(clean)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": _auth(cfg), "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
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)
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, inputs):
page_id = inputs.get("page_id")
if not page_id:
raise Exception("page_id is required")
title = inputs.get("title")
if not title:
raise Exception("title is required")
body = inputs.get("body")
if not body:
raise Exception("body is required")
version = inputs.get("version")
if version in (None, ""):
raise Exception("version is required")
return request(
"PUT",
"/content/" + q(page_id),
cfg,
body={
"type": "page",
"title": title,
"version": {"number": int(version)},
"body": {"storage": {"value": body, "representation": "storage"}},
},
)
_run(main)
@@ -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():

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