Compare commits

..

232 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
Guillaume BOURGEOIS 041cadade2 feat(extrahop): new ExtraHop Reveal(x) NDR integration
ExtraHop REST API v1, 6 commands: search/get detections, list/search devices,
get device. ESA API-key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:48:03 +02:00
Guillaume BOURGEOIS 41e8aa8e7e feat(vectra): new Vectra AI NDR integration
Vectra Detect API v2.3, 8 commands: list/get detections, mark detection fixed,
add note, list/get hosts, list accounts. Token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:48:01 +02:00
Guillaume BOURGEOIS 0775500b15 feat(darktrace): new Darktrace NDR integration
Darktrace Threat Visualizer API, 7 commands: get/acknowledge model breaches,
get devices, get/activate Antigena actions (autonomous-response containment).
HMAC-SHA1 signed auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:48:00 +02:00
Guillaume BOURGEOIS 9be28a2ff7 feat(triage): new Hatching Triage sandbox integration
Triage API v0, 7 commands: submit file (multipart _json+file) / URL, get
sample, get report overview, list samples, search. Bearer-token auth,
stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:42:02 +02:00
Guillaume BOURGEOIS 1292a8c798 feat(cape): new CAPE Sandbox integration
CAPE APIv2, 6 commands: submit file (multipart) / URL, get task, get report,
list tasks. Token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:42:01 +02:00
Guillaume BOURGEOIS b6af7d1b68 feat(cuckoo): new Cuckoo Sandbox integration
Cuckoo REST API, 7 commands: submit file (multipart) / URL, get task, get
report, list tasks, delete task. Optional Bearer-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:42:00 +02:00
Guillaume BOURGEOIS d5b7e38dd7 feat(infoblox): new Infoblox NIOS DNS-containment integration
Infoblox WAPI, 7 commands: add RPZ domain (DNS sinkhole/block), list/delete RPZ
records, search hosts, get networks, add A record. HTTP Basic auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:37:12 +02:00
Guillaume BOURGEOIS 6de01f85bb feat(zscaler-zpa): new Zscaler Private Access integration
ZPA config API, 7 commands: list/get application segments, list servers,
server groups, segment groups, access policy rules. OAuth2 client-credentials
(signin) auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:37:11 +02:00
Guillaume BOURGEOIS 86e7f11227 feat(netskope): new Netskope SASE/CASB integration
Netskope REST API v2, 6 commands: list/get URL lists, replace URL list (block
list), deploy URL lists, list users. API-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:37:10 +02:00
Guillaume BOURGEOIS 9f88891457 feat(freshservice): new Freshservice ITSM integration
Freshservice API v2, 7 commands: create/get/update/list tickets, add note
(public/private), list agents. API-key (Basic) auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:31:45 +02:00
Guillaume BOURGEOIS 9892f2cf06 feat(zendesk): new Zendesk ticketing integration
Zendesk Support API v2, 7 commands: create/get/update/search tickets, add
comment (public/internal), list users. API-token (Basic) auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:31:44 +02:00
Guillaume BOURGEOIS be8bf9f822 feat(thehive): new TheHive case-management integration
TheHive 5 API v1, 10 commands: case create/get/update/search, create task,
alert create/get/promote-to-case, add observable. Bearer (API key) auth,
stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:31:43 +02:00
Guillaume BOURGEOIS aca1db5cad feat(chronicle): new Google Chronicle SIEM integration
Chronicle Backstory API, 5 commands: list IOCs, list alerts, list assets,
get IOC details. Service-account RS256 JWT auth (remote engine, PyJWT +
cryptography).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:25:51 +02:00
Guillaume BOURGEOIS d0d72c5171 feat(elasticsearch): new Elasticsearch log-search integration
Elasticsearch REST API, 7 commands: search (DSL or query_string), count, get/
index document, list indices, delete by query. API-key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:25:50 +02:00
Guillaume BOURGEOIS fd64c245f4 feat(qradar): new IBM QRadar SIEM integration
QRadar REST API, 8 commands: list/get offenses, update offense (close/assign),
list reference sets, add/remove reference-set value (block lists), list assets.
SEC-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:25:49 +02:00
Guillaume BOURGEOIS 6cc0bb61bc feat(cisco-fmc): new Cisco Firepower Management Center integration
FMC REST API, 7 commands: list/create/delete host objects, list access
policies/rules, create block access rule. Token auth (generatetoken, token +
domain UUID from response headers), stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:20:41 +02:00
Guillaume BOURGEOIS 496ac6d8ce feat(checkpoint): new Check Point firewall-containment integration
Check Point Management Web API, 7 commands: show/add hosts, show access
rulebase, add access rule (drop), publish, install policy. Session (login)
auth with X-chkp-sid, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:20:41 +02:00
Guillaume BOURGEOIS 44d0836e1b feat(cloudflare): new Cloudflare edge-containment integration
Cloudflare client API v4, 9 commands: list/get zones, block IP (firewall access
rule), list/delete access rules, list/create/delete DNS records. API-token
(Bearer) auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:20:40 +02:00
Guillaume BOURGEOIS 796a95d5f8 feat(mandiant): new Mandiant Advantage threat-intel integration
Mandiant TI API v4, 8 commands: search/get indicator, get actor/malware/
vulnerability, list/get reports. Key+secret OAuth2 token exchange, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:15:12 +02:00
Guillaume BOURGEOIS a5db725529 feat(anomali-threatstream): new Anomali ThreatStream threat-intel integration
ThreatStream API, 9 commands: ip/domain/file/url reputation, indicator search,
passive DNS, threat models, import indicator. apikey auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:15:11 +02:00
Guillaume BOURGEOIS ecc301d95d feat(threatconnect): new ThreatConnect threat-intel integration
ThreatConnect API v3, 9 commands: list/get/create/delete indicators, list/get/
create groups, add indicator tag. HMAC-SHA256 signed auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:15:10 +02:00
Guillaume BOURGEOIS 6b8dd13297 feat(trend-vision-one): new Trend Vision One XDR integration
Vision One API v3.0, 9 commands: list endpoints, isolate/restore endpoint
(containment), terminate process, list/get Workbench alerts, add/remove
suspicious object (block hash/URL/IP/domain). Bearer-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:06:32 +02:00
Guillaume BOURGEOIS 1c3936992b feat(cybereason): new Cybereason EDR integration
Cybereason API, 7 commands: query Malops, list sensors, get machine details,
isolate/un-isolate machine (containment), block file hash. Session (login)
auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:06:32 +02:00
Guillaume BOURGEOIS 61da5ef145 feat(carbon-black-cloud): new VMware Carbon Black Cloud EDR integration
Platform API, 10 commands: device search/get, quarantine/unquarantine
(network-isolate), background scan, update device policy, alert search,
ban/unban file hash (reputation override). API-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:06:31 +02:00
Guillaume BOURGEOIS f34363b450 feat(rapid7-insightvm): new Rapid7 InsightVM vulnerability integration
InsightVM/Nexpose Console API v3, 9 commands: list/get assets, asset
vulnerabilities, list/get vulnerabilities, list sites, start site scan,
get scan. HTTP Basic auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:59:39 +02:00
Guillaume BOURGEOIS fcf30da5fc feat(qualys): new Qualys VMDR vulnerability integration
Qualys API v2 (XML), 6 commands: host list, host detections, knowledge-base
vulnerability details, scan list, launch scan. HTTP Basic auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:59:38 +02:00
Guillaume BOURGEOIS 847d18f79b feat(tenable-io): new Tenable Vulnerability Management integration
Tenable.io API, 9 commands: list/get assets, list vulnerabilities, per-asset
vulnerabilities, vulnerability (plugin) details, list scans, scan status,
launch scan. API-key (access/secret) auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:59:37 +02:00
Guillaume BOURGEOIS 5501297984 feat(gcp-security): new Google Cloud cloud-containment integration
Compute Engine + Security Command Center, 7 commands: list/create/delete VPC
firewall rules (deny to isolate), list/stop instances, list SCC findings.
Service-account RS256 JWT auth (remote engine, PyJWT + cryptography).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:54:02 +02:00
Guillaume BOURGEOIS 31d0a5938a feat(azure-security): new Microsoft Azure cloud-containment integration
Azure Resource Manager (Defender for Cloud + Network), 9 commands: list/get
security alerts, update alert state, secure score, list/get NSGs, create/delete
NSG security rules (deny inbound to isolate). OAuth2 client-credentials, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:54:01 +02:00
Guillaume BOURGEOIS 62a44aea28 feat(aws): new AWS cloud-containment integration
EC2 + IAM + STS via AWS Query protocol (Signature V4), 9 commands: describe
instances/security-groups, authorize/revoke security-group ingress, modify
instance security groups (isolate), stop instances, list/update IAM access keys
(deactivate compromised key), STS caller identity. Stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:54:00 +02:00
Guillaume BOURGEOIS 14e5e102a9 feat(microsoft-defender-o365): new O365 email-remediation integration
Microsoft Graph mail APIs, 7 commands: search mailbox messages, get message,
list attachments, list folders, move message, hard-delete (purge) message.
Azure AD OAuth2 client-credentials auth (Mail.ReadWrite), stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:46:36 +02:00
Guillaume BOURGEOIS 460dfaefba feat(mimecast): new Mimecast anti-phishing integration
Email Security API, 11 commands: held-message list/release/reject, message
search + info, managed URL create/list (block), block sender, create
remediation, URL decode. HMAC-SHA1 signed auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:46:35 +02:00
Guillaume BOURGEOIS f826181704 feat(proofpoint-tap): new Proofpoint TAP email-threat integration
TAP SIEM API v2, 11 commands: SIEM events (all/messages/clicks blocked+delivered+
permitted), threat/campaign forensics, campaign get/list, URL Defense decode,
top clickers. HTTP Basic (service principal) auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:46:35 +02:00
Guillaume BOURGEOIS 9da00ad7b9 feat(cyberark-pas): new CyberArk PAS privileged-access integration
PVWA REST API, 11 commands: list/get accounts, change/verify/reconcile
credentials (CPM rotation), retrieve password, add/delete account, list safes,
list users. Session (logon) auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:37:54 +02:00
Guillaume BOURGEOIS 7bd6ad6a69 feat(duo): new Cisco Duo MFA-containment integration
Duo Admin API, 9 commands: get users/user, modify user status
(disable/enable/bypass), delete user, bypass codes, devices, user devices,
authentication logs. HMAC-SHA1 signed auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:37:53 +02:00
Guillaume BOURGEOIS 26694b512a feat(okta): new Okta identity-containment integration
Okta Core API, 14 commands: user lifecycle (suspend/unsuspend/deactivate/unlock),
clear sessions + tokens, expire password, reset MFA factors, get/list users,
get factors, system log, add/remove group membership. SSWS-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:37:52 +02:00
Guillaume BOURGEOIS e88805ccab feat(opsgenie): new Opsgenie alerting integration
Alert API v2, 13 commands: alert create/close/acknowledge/get/list, add note/tags,
assign, snooze, delete, list schedules, on-calls. GenieKey auth (US/EU), stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:28:26 +02:00
Guillaume BOURGEOIS a279b58290 feat(pagerduty): new PagerDuty alerting integration
Events API v2 + REST API v2, 10 commands: trigger/acknowledge/resolve incident,
list/get incident, add note, list on-calls/users/services. Token + routing-key
auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:28:26 +02:00
Guillaume BOURGEOIS df4cd98a9e feat(microsoft-teams): new Microsoft Teams notification integration
Incoming-webhook / Power Automate Workflows, 3 commands: send message,
send Adaptive Card (title/text/facts/action button). Webhook-URL auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:28:25 +02:00
Guillaume BOURGEOIS e3c15ae972 feat(slack): new Slack notification/ChatOps integration
Slack Web API, 8 commands: send message (text/Block Kit), list/create channel,
invite to channel, set topic, get user, add reaction. Bot-token auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:28:25 +02:00
Guillaume BOURGEOIS 26cd02d77a feat(panos): new PAN-OS / Panorama firewall-containment integration
PAN-OS XML API, 10 commands: register/unregister IP tag (Dynamic Address
Group fast-block, no commit), address create/delete, custom URL category
create/edit, EDL list/refresh, commit. API-key auth, targets a vsys, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:13:35 +02:00
Guillaume BOURGEOIS 53034f35da feat(zscaler): new Zscaler Internet Access containment integration
ZIA REST API, 12 commands: URL/IP block-list add/remove, block-list and
allow-list read, allow-list add, category URL add, Sandbox report, activate
changes. Session-based obfuscated-API-key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:13:34 +02:00
Guillaume BOURGEOIS fb82de7f93 feat(cisco-umbrella-enforcement): new Cisco Umbrella Enforcement integration
Umbrella Enforcement API, 4 commands: add domain (DNS-layer block),
list enforced domains, delete domain. Customer-key auth, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:13:24 +02:00
Guillaume BOURGEOIS 8ee19ffe85 feat(fortigate): new FortiGate network-containment integration
FortiOS REST API v2, 11 commands: ban/unban/list banned IPs (quarantine),
firewall address object create/delete/list, address-group update/list,
firewall policy and service listing. Bearer-token auth, multi-VDOM, stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:13:24 +02:00
Guillaume BOURGEOIS bcc79598e6 feat(ipqualityscore): new IPQualityScore enrichment integration
6 commands: IP/URL/email/phone fraud reputation, leaked-email check.
API-key auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:58:17 +02:00
Guillaume BOURGEOIS 7d0e4aa18a feat(misp): new MISP threat-intel integration
10 commands: event/attribute search, event read/create/publish/delete,
attribute add, event tagging, sightings. API-key auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:58:16 +02:00
Guillaume BOURGEOIS 7bec81ad0a feat(vmray): new VMRay sandbox integration
8 commands: file/URL detonation, sample/submission/analysis retrieval,
sample-by-hash lookup, IOCs. API-key auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:58:15 +02:00
Guillaume BOURGEOIS 1bdc71abee feat(intezer): new Intezer enrichment integration
9 commands: analyze by file/hash/URL, analysis result, IOCs, metadata
and sub-analyses. API-key (token-exchange) auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:58:15 +02:00
Guillaume BOURGEOIS b8afff7678 feat(anyrun): new ANY.RUN sandbox integration
7 commands: file/URL detonation, report + verdict retrieval, analysis
history, user limits, delete task. API-key auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:48:50 +02:00
Guillaume BOURGEOIS 239cc70672 feat(hybrid-analysis): new Hybrid Analysis (Falcon Sandbox) integration
7 commands: file/URL detonation, report summary + state, hash search,
environment listing. API-key auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:48:49 +02:00
Guillaume BOURGEOIS 2655a14bc3 feat(joe-sandbox): new Joe Sandbox integration
8 commands: file/URL detonation, submission + analysis info, search,
report download, account quota. API-key auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:48:49 +02:00
Guillaume BOURGEOIS 9589b4f0d1 feat(securitytrails): new SecurityTrails enrichment integration
8 commands: domain details, subdomains, WHOIS, DNS history, associated
domains, IP neighbors, domain search. API-key auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:48:48 +02:00
Guillaume BOURGEOIS 203273715c feat(censys): new Censys enrichment integration
3 commands: host lookup by IP and Censys Search Language host query.
API ID + secret (Basic) auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:39:29 +02:00
Guillaume BOURGEOIS 1294e3b330 feat(maltiverse): new Maltiverse enrichment integration
5 commands: IP/domain/URL/file threat-intel reputation. Bearer-token
auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:39:29 +02:00
Guillaume BOURGEOIS 79d870a4e8 feat(emailrep): new EmailRep.io enrichment integration
3 commands: email reputation lookup, report malicious address. API-key
auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:39:29 +02:00
Guillaume BOURGEOIS ac6a52cecd feat(pulsedive): new Pulsedive enrichment integration
4 commands: indicator lookup, scan submission, scan-result retrieval.
API-key auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:39:28 +02:00
Guillaume BOURGEOIS 419f891267 feat(urlscan): new urlscan.io enrichment integration
4 commands: submit URL scan, retrieve result, search historical scans.
API-key auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:33:10 +02:00
Guillaume BOURGEOIS 2b3b9fc10c feat(alienvault-otx): new AlienVault OTX enrichment integration
9 commands: IP/domain/URL/file reputation, pulse details + search,
passive DNS and related URLs. API-key auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:33:10 +02:00
Guillaume BOURGEOIS 9ec84905be feat(greynoise): new GreyNoise enrichment integration
7 commands: IP context, quick check, RIOT, GNQL query + stats, IP
timeline. API-key auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:33:09 +02:00
Guillaume BOURGEOIS 9504b22e04 feat(abuseipdb): new AbuseIPDB enrichment integration
5 commands: IP abuse-reputation check, report abusive IP, blacklist
retrieval, CIDR-block check. API-key auth, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:33:03 +02:00
Guillaume BOURGEOIS 1033388518 feat(microsoft-entra-id): new Microsoft Entra ID integration
14 commands (Microsoft Graph users): user get/list/create/update/delete,
account disable/enable, revoke sign-in sessions, reset password, assign
manager, and group/manager/auth-method reads. Azure AD OAuth 2.0
client-credentials, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:22:46 +02:00
Guillaume BOURGEOIS 334ecac83e feat(microsoft-sentinel): new Microsoft Sentinel integration
19 commands (Azure Resource Manager API): incident ingestion + CRUD,
comments, related alerts/entities/relations, watchlists, and threat
indicators. Azure AD OAuth 2.0 client-credentials, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:22:46 +02:00
Guillaume BOURGEOIS 811a85424b feat(microsoft-defender-endpoint): new Defender for Endpoint integration
22 commands (Security Center API): alert ingestion + triage, machine
isolate/unisolate, restrict/unrestrict app execution, AV scan, stop &
quarantine file, collect investigation package, offboard, tag, list
machine actions, advanced hunting (KQL), and custom indicators. Azure
AD OAuth 2.0 client-credentials, stdlib-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:22:45 +02:00
Guillaume BOURGEOIS 306581e70b feat(jira): new Atlassian Jira integration
Jira Cloud and On-Prem/Data Center (27 commands): JQL ingestion with an
OCSF mapper and JQL query, full issue lifecycle (create/get/edit/delete,
transitions, assign), comments, remote web links and issue links,
attachments (upload/download/delete), field and user lookups, and agile
boards/sprints/epics. Basic (email + API token) or Personal Access Token
authentication; API v3 + ADF bodies on Cloud, v2 on On-Prem.
Stdlib-only, no extra Python dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:56:52 +02:00
Guillaume BOURGEOIS da3909e79d feat(mail-sender): new SMTP mail sender integration
Send email over SMTP: plain-text and HTML bodies with inline data-URI
images, base64 file attachment, CC/BCC, Reply-To and custom headers.
Plain / STARTTLS / SSL-TLS with optional authentication. Stdlib-only
(smtplib), no extra Python dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:48:23 +02:00
Guillaume BOURGEOIS 84be770928 feat(gmail-single-user): new single-mailbox Gmail integration
Gmail for one mailbox over OAuth 2.0 (no service account / delegation):
auth-link + exchange-code to obtain a refresh token, connectivity test,
message search/get, send/reply with attachments, attachment retrieval,
and get_incidents ingestion with an OCSF mapper. Refresh-token grant,
stdlib-only (no extra Python dependencies).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:29:12 +02:00
Guillaume BOURGEOIS 27615636e3 feat(gmail): new Gmail integration
Gmail API + Directory API (27 commands): mailbox search/read/send/
trash/label/move, attachments retrieval, vacation auto-reply, filters,
forwarding addresses, delegates, and Workspace user administration;
message ingestion (get_incidents) with a bundled OCSF mapper.
Service-account auth with domain-wide delegation (JWT RS256), runs on
a remote engine (requires PyJWT + cryptography).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:18:07 +02:00
Guillaume BOURGEOIS 3f754d14e9 feat(google-drive): new Google Drive integration
Drive API v3 (25 commands): shared drive management, change tracking,
Drive activity queries, file search/get/create/upload/download/copy/
move/delete, permission list/create/update/delete, and Drive labels.
Service-account auth with domain-wide delegation (JWT RS256), runs on
a remote engine (requires PyJWT + cryptography).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:17:58 +02:00
Guillaume BOURGEOIS dbb740f476 feat(google-calendar): new Google Calendar integration
Calendar API v3 ACL management: create access control rules (grant a
role to a user/group/domain/public) and list a calendar's ACL rules.
Service-account auth with domain-wide delegation (JWT RS256), runs on
a remote engine (requires PyJWT + cryptography).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:17:50 +02:00
Guillaume BOURGEOIS 9f1df6ca7a feat(servicenow-iam): new ServiceNow IAM integration
Identity lifecycle on the sys_user table: get/create/update user
(resolved by sys_id, username or email), enable user (active=true +
locked_out cleared), disable user, and user field discovery via
sys_dictionary for mapping. Basic or OAuth 2.0 (password grant)
authentication, stdlib-only scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:57:57 +02:00
Guillaume BOURGEOIS 527d6f79fd feat(servicenow-cmdb): new ServiceNow CMDB integration
CMDB Instance API: records list by CI class, record get with
attributes + inbound/outbound relations, record create/update with
attributes and discovery source, add/delete relations. Basic or
OAuth 2.0 (password grant) authentication, stdlib-only scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:54:01 +02:00
Guillaume BOURGEOIS 1ca5597453 feat(servicenow): new ServiceNow ITSM integration
31 commands: ticket lifecycle (create/update/resolve/delete, comments,
work notes, links, tags, journal notes, attachments), generic table
records CRUD and discovery, CMDB/user/group queries, service catalog
ordering, standard change from template, AWA queue routing, generic
API call, plus get_incidents ingestion with a bundled OCSF mapper.
Basic or OAuth 2.0 (password grant) authentication, stdlib-only scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:48:50 +02:00
Guillaume BOURGEOIS c406edb5e7 feat(opencti): new OpenCTI threat-intelligence integration
Add a marketplace integration for the OpenCTI platform (GraphQL API,
compatible with OpenCTI 5.x/6.x), built on the Python pycti client and
run from a remote engine.

26 commands: observables (list/create/delete/field update/add/remove),
indicators (list/create/update/field add/remove, types), incidents
(list/create/delete, types) with an OCSF ingestion mapper, relationships
(list/create/delete), and reference data (organizations, labels, marking
definitions, external references).

- Ingestion: get_incidents to an OCSF finding mapper + an OpenCTI Incident type.
- Auth: user API key (Bearer) via pycti; requires pip install pycti on
  the engine host.
- Scripts are self-contained (INTEGRATION_SECRETS/INTEGRATION_INPUTS in,
  JSON out) following the established marketplace pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:24:57 +02:00
Guillaume BOURGEOIS 0263619e30 feat(cortex-xdr): incident write-back and external alert push
Add three commands to the Cortex XDR integration (v1.2.0):
- update_incident: change status, severity, assignment and resolve
  comment on an incident (closes the SOAR ingest→action loop).
- insert_parsed_alerts: push external parsed alerts into Cortex XDR.
- insert_cef_alerts: push external CEF-format alerts.

Scripts follow the existing self-contained standard/advanced auth pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:32:47 +02:00
Guillaume BOURGEOIS b99de4af19 fix(mock-edr-s1): map the OCSF-shaped incident payload
The mock now emits incidents already in an OCSF-aligned shape (src_endpoint,
device, user, rule, dst_endpoint, dns_query, cloud, metadata). Map those nested
fields through to OCSF instead of only the flat OpenAPI fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:20:53 +02:00
Guillaume BOURGEOIS 7e96048446 feat(mock-edr-s1): EDR incident integration from OpenAPI spec
Built from the published OpenAPI spec for mock instance s1 (type: edr).
Incident ingestion (list_incidents) with since/after_id paging and an OCSF
mapper + 'Mock EDR Incident' default type, plus an acknowledge/resolve/dismiss
incident action. X-API-Key auth; the instance path segment is configurable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:00:04 +02:00
Guillaume BOURGEOIS 934f2c52d7 feat(virustotal): complete v3 command coverage (script-based)
Expand the VirusTotal integration from 3 form-based commands to 15 script-based
commands covering the v3 API: ip/domain/file/url reputation (existing
get_ip_report/get_domain_report ids preserved), file rescan, URL scan,
analysis-get, intelligence search, file sandbox (behaviour) report, passive DNS,
and comments get/add/get-by-id/delete. Scripts handle URL base64 ids,
form-encoded URL submission and comment resource routing.

File-content upload (file-scan) and private scanning are intentionally omitted:
they require an XSOAR-style war-room file entry system Riposte does not have.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:53:22 +02:00
Guillaume BOURGEOIS 70dffb3b0a feat: SentinelOne SDL + VirusTotal Hunting integrations
SentinelOne SDL (endpoint): Unified Alerts via the GraphQL API. Alert ingestion
(get_alerts) with rich filtering and an exhaustive OCSF mapper + 'SentinelOne SDL
Alert' default type, full alert details, update (status/verdict/assignee), add
note and trigger mitigation action. ApiToken auth; watermark converted to epoch
ms for the detectedAt filter; alert edges flattened to nodes for ingestion.

VirusTotal Hunting (enrichment, Premium): Livehunt notification-file ingestion
(livehunt_files) with an OCSF mapper + 'VirusTotal Hunting File' default type
(severity bucketed from malicious AV detections), Livehunt notifications listing,
and Retrohunt job + matching-file listing.

The core VT v3 reputation already ships as 'virustotal'; the XSOAR-feed and
Premium file-download/zip/pcap commands were intentionally left out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:34:42 +02:00
Guillaume BOURGEOIS fcf516ca82 feat(recorded-future): Recorded Future + ASI integrations
Recorded Future (enrichment): native ConnectAPI v2 (X-RFToken). ip/domain/url/
file/cve risk reputation, full entity intelligence, and alert ingestion (alerts
search) with an OCSF mapper and a 'Recorded Future Alert' default type, plus
alert lookup and alert-rule search.

Recorded Future ASI (enrichment): Attack Surface Intelligence (SecurityTrails
API, APIKEY header, project-scoped). Project issue ingestion (project_issues)
with an OCSF mapper and a 'Recorded Future ASI Issue' default type, filtered by
a configurable minimum severity, plus recent-issues and recent-issues-by-host
queries.

The XSOAR-gateway packs (alerts/lists) were re-implemented against Recorded
Future's native ConnectAPI rather than the XSOAR-coupled gateway protocol.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:19:40 +02:00
Guillaume BOURGEOIS ef215daa88 feat(sekoia): Sekoia XDR + SEKOIA Intelligence Center integrations
Sekoia XDR (siem): alert ingestion (list_alerts) with an exhaustive OCSF mapper
and a bundled 'Sekoia XDR Alert' default type, plus 20 commands across alerts
(list/get/search, status workflow, comments), event search jobs (create/status/
results + one-shot search_events), cases, asset management, users, kill chains
and a generic authenticated HTTP passthrough. Bearer-token auth, EU host default.

SEKOIA Intelligence Center (enrichment): observable/indicator/indicator-context
CTI queries plus ip/url/domain/file/email reputation lookups (STIX type resolved
automatically). No fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:04:22 +02:00
Guillaume BOURGEOIS 8fd297169f feat(sentinelone): default ingestion (get_threats fetch + OCSF mapper)
Declare get_threats as the default fetch command (results_path data, dedup id,
incremental created_after) with the bundled get_threats OCSF mapper and a
'SentinelOne Threat' default incident type. get_alerts is also marked
ingestion-ready (incremental created_from). Existing filters (mitigation
status, query, created window, limit) let an operator fetch a subset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:10:37 +02:00
Guillaume BOURGEOIS 729c339e2f feat(rapid7-insightidr): InsightIDR integration (19 commands + OCSF ingestion)
REST API integration for Rapid7 InsightIDR. Investigation ingestion
(list_investigations) with an exhaustive OCSF mapper and a bundled default
incident type, plus 18 commands across investigations (list/get/search/
create/update/assign/set-status/bulk-close), investigation alerts and Rapid7
product alerts, custom threat indicators (add/replace), log management and
LEQL log/log-set queries with downloads, and user directory search.

API v1/v2 selectable per instance (is_v2) and per command (api_version);
multi-customer query parameter supported on v2 calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:00:29 +02:00
Guillaume BOURGEOIS 6bccbb5a50 feat(integrations): declare default ingestion (command, mapper, default type)
cortex-xdr, crowdstrike, harfanglab and splunk now ship an `ingestion` block
(fetch command + bundled mapper + default incident type) and a bundled default
incident type under incident-types/, so the instance Collect section is pre-filled
on first configuration and the default type is created on install.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 10:38:09 +02:00
Guillaume BOURGEOIS 3608cbb74e feat(microsoft-graph-files): O365 file management integration (Graph API)
Microsoft Graph file management for OneDrive / SharePoint / Teams with app-only
(client credentials) authentication, stateless over urllib (no dependency). 19
commands: browse sites/drives/content, create folders, delete/upload/replace/
download files (content passed via base64 or source URL; download returns the
pre-authenticated Graph URL), site permission management (list/create/update/
delete), SharePoint list reading (lists/items/get-item), and Excel worksheet
editing (append row, read range, update cell).

Each script obtains a bearer token via the client-credentials grant and calls
Graph directly. Re-implemented cleanly from a customized source: dropped the
hosted-proxy/auth-code/managed-identity/certificate flows, the duplicated and
broken Excel helpers, and the platform-specific file-entry handling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:13:43 +02:00
Guillaume BOURGEOIS b98d315fe4 feat(openldap): LDAP authentication integration (OpenLDAP / Active Directory)
LDAP authentication over the ldap3 library, designed to run on a remote engine
inside the directory network. Auto-detects the vendor (OpenLDAP or Active
Directory). 5 commands: test connection, ad-authenticate (simple bind),
ad-groups (fetch all or specific groups), ad-authenticate-and-roles (bind +
return the user's groups and attributes), and ad-entries-search (generic LDAP
search with cn/uid/objectClass/description filters, scope, attribute selection
and paging).

Scripts share a ported LdapClient that handles SSL/LDAPS/Start TLS, vendor
detection, OpenLDAP vs AD group/role resolution and paged search. ldap3 is
imported defensively with a clear "pip install ldap3" message when missing. No
ingestion source, so no OCSF mapper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:44:54 +02:00
Guillaume BOURGEOIS 07fe4b50a7 feat(active-directory): LDAP directory administration integration
Active Directory over LDAP/LDAPS via the ldap3 library, designed to run on a
remote engine inside the AD network. 24 commands: raw search, get
user/computer/group-members (with userAccountControl decoding), enable/disable/
unlock accounts, set/expire password, password-never-expire, add/remove group
membership, move user/computer OU, create/update/delete user/contact/group, and
credential testing.

Scripts share an ldap3 connection helper that handles SSL/LDAPS/Start TLS, NTLM
bind, certificate trust and paged search. ldap3 is imported defensively: if it
is missing on the engine host the command returns a clear "pip install ldap3"
message instead of crashing. No ingestion source, so no OCSF mapper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:22:22 +02:00
Guillaume BOURGEOIS dd1ca83d7d feat(splunk): full Splunk REST integration (search, jobs, KV Store, events)
20 commands over the management port (8089) with token or basic auth:
SPL search (oneshot) as an ingestion source with an OCSF mapper for
notable/CIM findings; async search jobs (create/status/results); index
listing; event submission (receivers/simple + HEC); the complete KV Store
command set (collection create/config/delete, list, data list/add/delete,
entry search/delete/update); user list/delete; and a connectivity test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:08:58 +02:00
Guillaume BOURGEOIS 76812ef128 feat(cortex-xdr): full command coverage (50 commands) — endpoints, scripts, exclusions, tags, audits, RBAC, distributions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:41:59 +02:00
Guillaume BOURGEOIS f3b0f269b8 docs: remove vendor-tool wording from mapper descriptions and changelogs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:32:30 +02:00
Guillaume BOURGEOIS c068d9fa9a feat(cortex-xdr): new Cortex XDR integration — incident ingestion + OCSF mapper, IR actions, standard/advanced auth
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:28:43 +02:00
Guillaume BOURGEOIS 0f4852bb54 fix(harfanglab): correct get_threats mapper to native threat fields; add agent.osversion
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:53:16 +02:00
Guillaume BOURGEOIS 44694f6d9e fix(crowdstrike): correct search_detections OCSF mapper against Raptor alert schema
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:44:25 +02:00
Guillaume BOURGEOIS 096fa21808 feat(crowdstrike): mark search_detections as ingestion source (ingest hints)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:41:02 +02:00
Guillaume BOURGEOIS 2c4d40103e feat(harfanglab): add get_security_events + get_threats ingestion commands, OCSF mappers and ingest hints
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:15:57 +02:00
Guillaume BOURGEOIS be7d4a8b7c feat(crowdstrike): bundle exhaustive OCSF mapper for search_detections
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:57:53 +02:00
Guillaume BOURGEOIS b7f88b1a09 feat(sentinelone): exhaustive OCSF mappers using full catalog (actor/target/registry/network/indicators)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:36:34 +02:00
Guillaume BOURGEOIS 1157735be1 feat(sentinelone): re-model OCSF mappers to actor/target semantics
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:20:35 +02:00
Guillaume BOURGEOIS 24e8c652ac feat(sentinelone): expand OCSF mappers with hashes, ids, parent process, identity
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:58:56 +02:00
Guillaume BOURGEOIS 587d7d1340 feat(sentinelone): bundle OCSF mappers for get_threats and get_alerts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:42:51 +02:00
Guillaume BOURGEOIS 826aa63c74 chore: bump ipinfo/shodan/virustotal to regenerate form-based scripts
Patch bump so an Update is offered once the InstallFromSource fix is deployed, regenerating the form-based command scripts without losing instances.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:06 +02:00
Guillaume BOURGEOIS 52a966f4e5 feat(ipinfo): add IPinfo Core API connector
IP enrichment (geolocation, ASN/network, privacy flags): lookup_ip,
lookup_field, lookup_me, batch (code-first), and test_connection.
Token auth via the `token` query parameter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:08:13 +02:00
Guillaume BOURGEOIS 5251441962 feat: add test_connection command to all integrations for the instance Test button
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:00:06 +02:00
Guillaume BOURGEOIS fd6c92047a chore(crowdstrike): bump to 1.1.0 for full command set
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:50:13 +02:00
Guillaume BOURGEOIS 17faa01103 feat(crowdstrike): expand to full command set (81 commands)
Adds device/IOC/process enrichment, host groups, cases, RTR
scripts/files/responders, ML/IOA exclusions, quarantine, Spotlight/CVE,
ODS scans, CSPM, users, IOA rules, CNAPP, detection resolve, workflows,
and identity (GraphQL). OAuth2 client-credentials, code-first scripts.

Excludes XSOAR-only plumbing (fetch/mirroring), deprecated legacy
commands, and binary/long-poll flows (retrieve-file, ngsiem search).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:49:40 +02:00
Guillaume BOURGEOIS d85a955e77 feat(crowdstrike): add CrowdStrike Falcon connector (IR enrichment, IOC, containment, RTR)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:27:37 +02:00
Guillaume BOURGEOIS b5f1254ee8 feat(shodan): add Shodan threat-intel integration
Host enrichment, search/count, DNS resolve/reverse, domain info, api-info
and scan status as form-based GET commands; active scan is code-first
(form-encoded POST) and marked destructive. API key sent as the `key`
query parameter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:06:44 +02:00
f3nris d7df536eaf feat(sentinelone): add 13 commands (threat analysis, UAM alerts, remote-script status/results, PowerQuery, tag rule, fetch-file, endpoint logs)
Brings the SentinelOne integration to 83 commands. New: threat-analysis,
threat-download-from-cloud, abort-endpoint-scan, endpoint-fetch-logs, fetch-file,
get-remote-script-task-status, get-remote-script-task-results, get-service-users,
list-installed-singularity-marketplace-applications, update-uam-alert-status,
update-uam-alert-verdict, run-powerquery (Singularity Data Lake), create-tag-rule.
Each command ships a stdlib-only script against API v2.1.
2026-06-22 21:50:56 +02:00
f3nris e3e363c2f9 feat(marketplace): prefix official command names with the technology
Rename every command 'name' in the official integrations to a
<technology>-<command> slug (e.g. sentinelone-isolate-agent,
harfanglab-isolate-endpoint) so they group together when searching the
toolbox. Command IDs (and thus script files and action refs) are unchanged.
Bump both integrations to 1.1.0.
2026-06-22 14:41:22 +02:00
f3nris 6b65c5f9df feat(harfanglab): HarfangLab EDR integration (71 analyst commands)
Endpoint management & isolation, policy assignment, IOC & whitelist (threat
intelligence), security-event triage, telemetry hunting (processes, network,
DNS, authentications, event logs, binaries), hash threat hunting, and forensic
collection jobs (pipes, prefetch, run keys, scheduled tasks, drivers, services,
processes, network, sessions, WMI, IOC scan, artifacts, RAM dump) plus their
result-retrieval commands. Script-based (urllib, INTEGRATION_SECRETS/INPUTS
contract; Authorization: Token header). Platform-specific XSOAR mirroring/fetch
commands intentionally excluded.
2026-06-22 13:50:20 +02:00
f3nris e5237c0df6 docs(sentinelone): reword changelog to reference the SentinelOne API directly 2026-06-22 12:58:51 +02:00
f3nris 9c04290a34 feat(sentinelone): full command coverage (70 commands, SentinelOne API v2.1)
Expand from 9 to 70 commands covering the official + DEV SentinelOne V2
integrations: agents (actions/info/tags/count), threats & alerts (verdict/status/
notes/mitigate/fetch-file), hash blocklist & exclusions, IOCs, STAR rules,
Deep Visibility, remote scripts, endpoint tags, firewall rules and network
discovery. All script-based (urllib, INTEGRATION_SECRETS/INPUTS contract);
inputs prioritized from the DEV integration.
2026-06-22 12:48:43 +02:00
f3nris a3ac1ee30d feat(sentinelone): curated EDR integration (9 analyst commands, API v2.1)
Endpoints derived from the SentinelOne API v2.1.
Script-based (urllib, INTEGRATION_SECRETS/INPUTS contract) to express S1's nested
filter bodies. Config: console url + api_token (ApiToken header).

Commands:
- enrich: get_threats, list_agents, get_agent, get_hash_verdict
- respond: isolate_agent (disconnect), reconnect_agent (connect), mitigate_threat
  (kill/quarantine/remediate/rollback), initiate_scan, write_threat_note
2026-06-22 12:31:45 +02:00
1726 changed files with 128153 additions and 100 deletions
+67
View File
@@ -0,0 +1,67 @@
id: abuseipdb
name: AbuseIPDB
version: 1.0.0
description: "AbuseIPDB (API v2) — check the abuse reputation of an IP, report abusive IPs, pull the blacklist and check a CIDR block. API-key authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: IP reputation check, report, blacklist retrieval and CIDR-block check."
category: enrichment
config_schema:
properties:
api_key:
type: string
description: "AbuseIPDB API key"
x-soar-sensitive: true
required:
- api_key
commands:
- id: check_ip
name: abuseipdb-check-ip
description: "Check the abuse-confidence reputation of an IP address."
risk: read
inputs_schema:
properties:
ip: { type: string, description: "IP address to check" }
max_age_days: { type: number, description: "Only consider reports within this many days (default 30, max 365)" }
verbose: { type: boolean, description: "Include the detailed report list" }
required: [ip]
outputs_schema: { properties: {} }
- id: report_ip
name: abuseipdb-report-ip
description: "Report an abusive IP address to AbuseIPDB."
inputs_schema:
properties:
ip: { type: string, description: "IP address to report" }
categories: { type: string, description: "Comma-separated AbuseIPDB category IDs (e.g. 18,22)" }
comment: { type: string, description: "Description of the abusive activity (avoid sensitive data)" }
required: [ip, categories]
outputs_schema: { properties: {} }
- id: get_blacklist
name: abuseipdb-get-blacklist
description: "Retrieve the AbuseIPDB blacklist of the most-reported IPs."
risk: read
inputs_schema:
properties:
confidence_minimum: { type: number, description: "Minimum abuse-confidence score (default 100)" }
limit: { type: number, description: "Maximum entries (default 100)" }
required: []
outputs_schema: { properties: {} }
- id: check_block
name: abuseipdb-check-block
description: "Check the reports for every address in a CIDR block (max /24 on the free tier)."
risk: read
inputs_schema:
properties:
network: { type: string, description: "CIDR network, e.g. 192.0.2.0/24" }
max_age_days: { type: number, description: "Only consider reports within this many days (default 30)" }
required: [network]
outputs_schema: { properties: {} }
- id: test_connection
name: abuseipdb-test-connection
description: "Verify the API key (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,45 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://api.abuseipdb.com/api/v2"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None, body=None):
url = API + path
q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if q:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q)
data = urllib.parse.urlencode(body).encode("utf-8") if body is not None else None
headers = {"Accept": "application/json", "Key": str(_cfg().get("api_key") or "")}
if data is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded"
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 main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
network = inputs.get("network")
if not network:
raise Exception("network is required")
max_age_days = inputs.get("max_age_days")
params = {"network": network, "maxAgeInDays": max_age_days or 30}
result = request("GET", "/check-block", params=params)
print(json.dumps(result))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,48 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://api.abuseipdb.com/api/v2"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None, body=None):
url = API + path
q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if q:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q)
data = urllib.parse.urlencode(body).encode("utf-8") if body is not None else None
headers = {"Accept": "application/json", "Key": str(_cfg().get("api_key") or "")}
if data is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded"
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 main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
ip = inputs.get("ip")
if not ip:
raise Exception("ip is required")
max_age_days = inputs.get("max_age_days")
verbose = inputs.get("verbose")
params = {"ipAddress": ip, "maxAgeInDays": max_age_days or 30}
if verbose:
params["verbose"] = ""
result = request("GET", "/check", params=params)
print(json.dumps(result))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,46 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://api.abuseipdb.com/api/v2"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None, body=None):
url = API + path
q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if q:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q)
data = urllib.parse.urlencode(body).encode("utf-8") if body is not None else None
headers = {"Accept": "application/json", "Key": str(_cfg().get("api_key") or "")}
if data is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded"
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 main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
confidence_minimum = inputs.get("confidence_minimum")
limit = inputs.get("limit")
params = {
"confidenceMinimum": confidence_minimum or 100,
"limit": limit or 100,
}
result = request("GET", "/blacklist", params=params)
print(json.dumps(result))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,50 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://api.abuseipdb.com/api/v2"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None, body=None):
url = API + path
q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if q:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q)
data = urllib.parse.urlencode(body).encode("utf-8") if body is not None else None
headers = {"Accept": "application/json", "Key": str(_cfg().get("api_key") or "")}
if data is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded"
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 main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
ip = inputs.get("ip")
if not ip:
raise Exception("ip is required")
categories = inputs.get("categories")
if not categories:
raise Exception("categories is required")
comment = inputs.get("comment")
body = {"ip": ip, "categories": categories}
if comment:
body["comment"] = comment
result = request("POST", "/report", body=body)
print(json.dumps(result))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,40 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://api.abuseipdb.com/api/v2"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None, body=None):
url = API + path
q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if q:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q)
data = urllib.parse.urlencode(body).encode("utf-8") if body is not None else None
headers = {"Accept": "application/json", "Key": str(_cfg().get("api_key") or "")}
if data is not None:
headers["Content-Type"] = "application/x-www-form-urlencoded"
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 main():
params = {"ipAddress": "8.8.8.8", "maxAgeInDays": 1}
result = request("GET", "/check", params=params)
if "data" not in result:
raise Exception("unexpected response")
print(json.dumps({"ok": True}))
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)
+348
View File
@@ -0,0 +1,348 @@
id: active_directory
name: Active Directory
version: 1.0.0
description: "Active Directory over LDAP/LDAPS — query and manage users, computers, groups and contacts (search, enable/disable/unlock, set/expire password, group membership, OU moves, create/update/delete). Runs on a remote engine inside the AD network. Requires the Python 'ldap3' library on the engine host (pip install ldap3)."
changelog: "1.0.0 — Initial release: full directory administration over LDAP (search, get user/computer/group-members, create/update/delete user/contact/group, enable/disable/unlock, set/expire password, password-never-expire, add/remove group membership, move user/computer OU, test credentials)."
category: identity
# Per-instance configuration. The integration binds to a Domain Controller over
# LDAP (389) or LDAPS (636). Secure connection (SSL/Start TLS) is required for
# password operations. Run this integration on a remote engine that can reach the
# Domain Controller. The bundled scripts require the Python 'ldap3' library to be
# installed on the engine host: pip install ldap3
config_schema:
properties:
server_ip:
type: string
description: "Domain Controller host or IP (e.g. dc01.company.com or 192.168.0.1)"
port:
type: string
description: "LDAP port. Default 389 for LDAP / Start TLS, 636 for LDAPS."
username:
type: string
description: "Bind username (e.g. DOMAIN\\\\user or user@company.com)"
password:
type: string
description: "Bind password"
x-soar-sensitive: true
base_dn:
type: string
description: "Base DN (e.g. dc=company,dc=com)"
secure_connection:
type: string
description: "Connection security: None, SSL (LDAPS), TLS, or Start TLS. Password operations require SSL or TLS."
default: SSL
ssl_version:
type: string
description: "SSL/TLS protocol: None, TLS, TLSv1, TLSv1_1, TLSv1_2, or TLS_CLIENT. Default None (let the library negotiate)."
default: None
ntlm:
type: boolean
description: "Use NTLM authentication for the bind"
default: false
unsecure:
type: boolean
description: "Trust any TLS certificate (not secure)"
default: false
page_size:
type: string
description: "LDAP paging size for searches"
default: "500"
default_base_query:
type: string
description: "Default LDAP filter used by ad-get-user when no query argument is given"
default: "(&(objectClass=User)(objectCategory=person))"
required:
- server_ip
- username
- password
- base_dn
commands:
# ── Connectivity ──────────────────────────────────────────────────────────
- id: test_connection
name: ad-test-connection
description: "Bind to the Domain Controller and verify the configured base DN (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
# ── Search / read ─────────────────────────────────────────────────────────
- id: ad_search
name: ad-search
description: "Run a raw LDAP query and return the matching entries. Paging is handled internally up to size-limit."
risk: read
inputs_schema:
properties:
filter: { type: string, description: "LDAP search filter, e.g. (&(objectCategory=person)(objectClass=user))" }
base-dn: { type: string, description: "Search base (defaults to the instance base_dn)" }
attributes: { type: string, description: "CSV list of attributes to return, or ALL for every attribute" }
size-limit: { type: number, description: "Maximum entries to return (default 50)" }
time-limit: { type: number, description: "Maximum search time in seconds" }
page-size: { type: number, description: "Paging size (overrides size-limit when set)" }
required: [filter]
outputs_schema: { properties: {} }
- id: ad_get_user
name: ad-get-user
description: "Retrieve user accounts by DN, name, email, sAMAccountName or a custom attribute. Decodes userAccountControl flags."
risk: read
inputs_schema:
properties:
dn: { type: string, description: "Distinguished Name of the user" }
name: { type: string, description: "Common name (cn) of the user" }
email: { type: string, description: "User email (mail)" }
username: { type: string, description: "sAMAccountName" }
sAMAccountName: { type: string, description: "sAMAccountName (alias of username)" }
custom-field-type: { type: string, description: "Attribute name to query by" }
custom-field-data: { type: string, description: "Attribute value (required with custom-field-type)" }
attributes: { type: string, description: "CSV list of extra attributes to add to the defaults" }
attributes-to-exclude: { type: string, description: "CSV list of attributes to remove from the result" }
limit: { type: number, description: "Maximum users to return (default 20)" }
page-size: { type: number, description: "Paging size (overrides limit when set)" }
required: []
outputs_schema: { properties: {} }
- id: ad_get_computer
name: ad-get-computer
description: "Retrieve computer accounts by DN, name or a custom attribute."
risk: read
inputs_schema:
properties:
dn: { type: string, description: "Distinguished Name of the computer" }
name: { type: string, description: "Computer name" }
custom-field-type: { type: string, description: "Attribute name to query by" }
custom-field-data: { type: string, description: "Attribute value (required with custom-field-type)" }
attributes: { type: string, description: "CSV list of extra attributes to add to the defaults" }
limit: { type: number, description: "Maximum computers to return" }
page-size: { type: number, description: "Paging size (overrides limit when set)" }
required: []
outputs_schema: { properties: {} }
- id: ad_get_group_members
name: ad-get-group-members
description: "List the users, computers or nested groups that are members of a group (recursive by default)."
risk: read
inputs_schema:
properties:
group-dn: { type: string, description: "Distinguished Name of the group" }
member-type: { type: string, description: "Member type to return: person, computer or group (default person)" }
attributes: { type: string, description: "CSV list of extra attributes to add to the defaults" }
time_limit: { type: number, description: "Search time limit in seconds (default 180)" }
disable-nested-search: { type: string, description: "Set true to disable recursive membership resolution (default false)" }
sAMAccountName: { type: string, description: "Filter members by sAMAccountName (default *)" }
limit: { type: number, description: "Maximum members to return" }
page-size: { type: number, description: "Paging size (overrides limit when set)" }
required: [group-dn]
outputs_schema: { properties: {} }
# ── Account state ─────────────────────────────────────────────────────────
- id: ad_enable_account
name: ad-enable-account
description: "Enable a previously disabled user account."
inputs_schema:
properties:
username: { type: string, description: "sAMAccountName of the account to enable" }
base-dn: { type: string, description: "Search base (defaults to the instance base_dn)" }
restore_user: { type: string, description: "Set true to restore the account's previous userAccountControl flags" }
required: [username]
outputs_schema: { properties: {} }
- id: ad_disable_account
name: ad-disable-account
description: "Disable a user account."
inputs_schema:
properties:
username: { type: string, description: "sAMAccountName of the account to disable" }
base-dn: { type: string, description: "Search base (defaults to the instance base_dn)" }
required: [username]
outputs_schema: { properties: {} }
- id: ad_unlock_account
name: ad-unlock-account
description: "Unlock a locked-out user account."
inputs_schema:
properties:
username: { type: string, description: "sAMAccountName of the account to unlock" }
base-dn: { type: string, description: "Search base (defaults to the instance base_dn)" }
required: [username]
outputs_schema: { properties: {} }
- id: ad_set_new_password
name: ad-set-new-password
description: "Set a new password for a user. Requires a secure connection (SSL or TLS)."
inputs_schema:
properties:
username: { type: string, description: "sAMAccountName whose password will be set" }
password: { type: string, description: "New password", x-soar-sensitive: true }
base-dn: { type: string, description: "Search base (defaults to the instance base_dn)" }
required: [username, password]
outputs_schema: { properties: {} }
- id: ad_expire_password
name: ad-expire-password
description: "Force a user to change their password at next login."
inputs_schema:
properties:
username: { type: string, description: "sAMAccountName of the user" }
base-dn: { type: string, description: "Search base (defaults to the instance base_dn)" }
required: [username]
outputs_schema: { properties: {} }
- id: ad_modify_password_never_expire
name: ad-modify-password-never-expire
description: "Set or clear the 'Password Never Expire' flag on a user account."
inputs_schema:
properties:
username: { type: string, description: "sAMAccountName of the user" }
value: { type: string, description: "true to set 'Password Never Expire', false to clear it" }
required: [username, value]
outputs_schema: { properties: {} }
# ── Group membership ──────────────────────────────────────────────────────
- id: ad_add_to_group
name: ad-add-to-group
description: "Add a user, computer or nested group to a group."
inputs_schema:
properties:
username: { type: string, description: "Username(s) to add (single or CSV). Mutually exclusive with computer-name." }
computer-name: { type: string, description: "Computer name(s) to add (single or CSV)" }
nested_group_cn: { type: string, description: "A group CN to add as a nested member" }
group-cn: { type: string, description: "Target group CN" }
base-dn: { type: string, description: "Search base (defaults to the instance base_dn)" }
required: [group-cn]
outputs_schema: { properties: {} }
- id: ad_remove_from_group
name: ad-remove-from-group
description: "Remove a user or computer from a group."
inputs_schema:
properties:
username: { type: string, description: "Username to remove. Mutually exclusive with computer-name." }
computer-name: { type: string, description: "Computer name to remove" }
group-cn: { type: string, description: "Target group CN" }
base-dn: { type: string, description: "Search base (defaults to the instance base_dn)" }
required: [group-cn]
outputs_schema: { properties: {} }
# ── Organizational unit ───────────────────────────────────────────────────
- id: ad_modify_user_ou
name: ad-modify-user-ou
description: "Move a user to a different organizational unit within the domain."
inputs_schema:
properties:
user-name: { type: string, description: "Name of the user to move" }
full-superior-dn: { type: string, description: "Target OU DN, e.g. OU=users,DC=domain,DC=com" }
base-dn: { type: string, description: "Search base (defaults to the instance base_dn)" }
required: [user-name, full-superior-dn]
outputs_schema: { properties: {} }
- id: ad_modify_computer_ou
name: ad-modify-computer-ou
description: "Move a computer to a different organizational unit within the domain."
inputs_schema:
properties:
computer-name: { type: string, description: "Name of the computer to move" }
full-superior-dn: { type: string, description: "Target OU DN, e.g. OU=computers,DC=domain,DC=com" }
base-dn: { type: string, description: "Search base (defaults to the instance base_dn)" }
required: [computer-name, full-superior-dn]
outputs_schema: { properties: {} }
# ── Object lifecycle: users ───────────────────────────────────────────────
- id: ad_create_user
name: ad-create-user
description: "Create a user account, set its initial password and enable it. Requires a secure connection (SSL or TLS)."
inputs_schema:
properties:
username: { type: string, description: "sAMAccountName for the new user" }
password: { type: string, description: "Initial password (user must change at next login)", x-soar-sensitive: true }
user-dn: { type: string, description: "Full DN of the new user" }
display-name: { type: string, description: "Display name" }
description: { type: string, description: "Description" }
email: { type: string, description: "Email (mail)" }
telephone-number: { type: string, description: "Telephone number" }
title: { type: string, description: "Job title" }
custom-attributes: { type: string, description: "JSON object of extra attributes, e.g. {\"company\":\"ACME\"}" }
required: [username, password, user-dn]
outputs_schema: { properties: {} }
- id: ad_update_user
name: ad-update-user
description: "Replace a single attribute on an existing user."
inputs_schema:
properties:
username: { type: string, description: "sAMAccountName of the user" }
attribute-name: { type: string, description: "Attribute to modify (e.g. sn, displayName, mail)" }
attribute-value: { type: string, description: "New value" }
base-dn: { type: string, description: "Search base (defaults to the instance base_dn)" }
required: [username, attribute-name, attribute-value]
outputs_schema: { properties: {} }
- id: ad_delete_user
name: ad-delete-user
description: "Delete a user (or any object) by its DN."
inputs_schema:
properties:
user-dn: { type: string, description: "DN of the object to delete" }
required: [user-dn]
outputs_schema: { properties: {} }
# ── Object lifecycle: contacts ────────────────────────────────────────────
- id: ad_create_contact
name: ad-create-contact
description: "Create a contact object."
inputs_schema:
properties:
contact-dn: { type: string, description: "Full DN of the new contact" }
display-name: { type: string, description: "Display name" }
description: { type: string, description: "Description" }
email: { type: string, description: "Email (mail)" }
telephone-number: { type: string, description: "Telephone number" }
title: { type: string, description: "Job title" }
custom-attributes: { type: string, description: "JSON object of extra attributes" }
required: [contact-dn]
outputs_schema: { properties: {} }
- id: ad_update_contact
name: ad-update-contact
description: "Replace a single attribute on an existing contact."
inputs_schema:
properties:
contact-dn: { type: string, description: "DN of the contact" }
attribute-name: { type: string, description: "Attribute to modify" }
attribute-value: { type: string, description: "New value" }
required: [contact-dn, attribute-name, attribute-value]
outputs_schema: { properties: {} }
# ── Object lifecycle: groups ──────────────────────────────────────────────
- id: ad_create_group
name: ad-create-group
description: "Create a security or distribution group."
inputs_schema:
properties:
name: { type: string, description: "Group name (sAMAccountName)" }
group-type: { type: string, description: "security or distribution" }
dn: { type: string, description: "Full DN of the new group" }
members: { type: array, description: "DNs of initial members" }
required: [name, group-type, dn]
outputs_schema: { properties: {} }
- id: ad_update_group
name: ad-update-group
description: "Replace a single attribute on an existing group."
inputs_schema:
properties:
groupname: { type: string, description: "Group name (cn) to update" }
attributename: { type: string, description: "Attribute to modify (e.g. description, displayName)" }
attributevalue: { type: string, description: "New value" }
basedn: { type: string, description: "Search base (defaults to the instance base_dn)" }
required: [attributename, attributevalue]
outputs_schema: { properties: {} }
- id: ad_delete_group
name: ad-delete-group
description: "Delete a security or distribution group by its DN."
inputs_schema:
properties:
dn: { type: string, description: "DN of the group" }
required: [dn]
outputs_schema: { properties: {} }
# ── Credentials ───────────────────────────────────────────────────────────
- id: ad_test_credentials
name: ad-test-credentials
description: "Test whether a username/password can bind to the Domain Controller."
risk: read
inputs_schema:
properties:
username: { type: string, description: "Username to test (user or SERVER\\\\user)" }
password: { type: string, description: "Password to test", x-soar-sensitive: true }
required: [username, password]
outputs_schema: { properties: {} }
@@ -0,0 +1,240 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
sb = base_dn_arg()
if I.get("username") and I.get("computer-name"):
fail("Provide either username or computer-name, not both")
member_dns = []
if I.get("username"):
member_dns = [user_dn(conn, u, sb) for u in _list(I["username"])]
elif I.get("computer-name"):
member_dns = [computer_dn(conn, cn, sb) for cn in _list(I["computer-name"])]
elif I.get("nested_group_cn"):
member_dns = [group_dn(conn, I["nested_group_cn"], sb)]
else:
fail("Provide username, computer-name, or nested_group_cn")
grp = group_dn(conn, I["group-cn"], sb)
if not microsoft.addMembersToGroups.ad_add_members_to_groups(conn, member_dns, [grp], True):
fail("Failed to add member(s) to group " + I["group-cn"])
out({"added": member_dns, "group": I["group-cn"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,239 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
contact_dn = I["contact-dn"]
attributes = {}
if I.get("custom-attributes"):
try:
attributes = json.loads(I["custom-attributes"])
except Exception as e:
fail("Failed to parse custom-attributes JSON: " + str(e))
field_map = {"display-name": "displayName", "description": "description", "email": "mail",
"telephone-number": "telephoneNumber", "title": "title"}
for arg, attr in field_map.items():
if I.get(arg):
attributes[attr] = I[arg]
if not conn.add(contact_dn, ["top", "person", "organizationalPerson", "contact"], attributes):
fail("Failed to create contact: " + json.dumps(conn.result, default=str))
out({"created": contact_dn})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,235 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
dn = I["dn"]
name = I["name"]
type_map = {"security": "2147483650", "distribution": "2"}
if I["group-type"] not in type_map:
fail("group-type must be 'security' or 'distribution'")
attributes = {"samAccountName": name, "groupType": type_map[I["group-type"]]}
if I.get("members"):
attributes["member"] = _list(I["members"])
if not conn.add(dn, ["top", "group"], attributes):
fail("Failed to create group: " + json.dumps(conn.result, default=str))
out({"created": dn})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,246 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
if c["secure"] not in ("SSL", "TLS"):
fail("Creating a user requires a secure connection (SSL or TLS).")
user_dn_val = I["user-dn"]
object_classes = ["top", "person", "organizationalPerson", "user"]
attributes = {"sAMAccountName": I["username"]}
field_map = {"display-name": "displayName", "description": "description", "email": "mail",
"telephone-number": "telephoneNumber", "title": "title"}
for arg, attr in field_map.items():
if I.get(arg):
attributes[attr] = I[arg]
if I.get("custom-attributes"):
try:
attributes.update(json.loads(I["custom-attributes"]))
except Exception as e:
fail("Failed to parse custom-attributes JSON: " + str(e))
if not conn.add(user_dn_val, object_classes, attributes):
fail("Failed to create user: " + json.dumps(conn.result, default=str))
if not conn.extend.microsoft.modify_password(user_dn_val, I["password"]):
fail("User created but failed to set password: " + json.dumps(conn.result, default=str))
modify(conn, user_dn_val, {"userAccountControl": [(MODIFY_REPLACE, [512])],
"pwdLastSet": [(MODIFY_REPLACE, ["0"])]})
out({"created": user_dn_val})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,227 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
if not conn.delete(I["dn"]):
fail("Failed to delete group: " + json.dumps(conn.result, default=str))
out({"deleted": I["dn"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,228 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
dn = I["user-dn"]
if not conn.delete(dn):
fail("Failed to delete: " + json.dumps(conn.result, default=str))
out({"deleted": dn})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,229 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
sb = I.get("base-dn") or base_dn_arg()
dn = user_dn(conn, I["username"], sb)
options = current_uac(conn, sb)
modify(conn, dn, {"userAccountControl": [(MODIFY_REPLACE, [options | 0x2])]})
out({"disabled": I["username"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,232 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
sb = I.get("base-dn") or base_dn_arg()
dn = user_dn(conn, I["username"], sb)
options = 512
if str(I.get("restore_user")).lower() == "true":
options = current_uac(conn, sb)
options = options & ~0x2
modify(conn, dn, {"userAccountControl": [(MODIFY_REPLACE, [options])]})
out({"enabled": I["username"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,227 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
dn = user_dn(conn, I["username"], I.get("base-dn") or base_dn_arg())
modify(conn, dn, {"pwdLastSet": [(MODIFY_REPLACE, ["0"])]})
out({"expired": I["username"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,243 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
defaults = ["name", "memberOf"]
query = "(&(objectClass=user)(objectCategory=computer))"
esc = escape_filter_chars
if I.get("dn"):
query = f"(&(objectClass=user)(objectCategory=computer)(distinguishedName={esc(I['dn'])}))"
elif I.get("name"):
query = f"(&(objectClass=user)(objectCategory=computer)(name={esc(I['name'])}))"
elif I.get("custom-field-type"):
if not I.get("custom-field-data"):
fail("custom-field-data is required when custom-field-type is given")
query = f"(&(objectClass=user)(objectCategory=computer)({esc(I['custom-field-type'])}={esc(I['custom-field-data'])}))"
custom = I["attributes"].split(",") if I.get("attributes") else []
attributes = list(set(custom) | set(defaults))
size_limit = int(I.get("limit") or 0)
page_size = int(I.get("page-size") or c["page_size"])
if I.get("page-size"):
size_limit = page_size
flat = search_paging(conn, query, base_dn_arg(), attributes=attributes, page_size=page_size, size_limit=size_limit)
out({"computers": flat, "count": len(flat)})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,248 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
member_type = I.get("member-type") or "person"
group = I["group-dn"]
nested = "" if str(I.get("disable-nested-search")).lower() == "true" else ":1.2.840.113556.1.4.1941:"
account = I.get("sAMAccountName") or "*"
time_limit = int(I.get("time_limit") or 180)
defaults = {
"person": ["name", "displayName", "memberOf", "mail", "sAMAccountName", "manager", "userAccountControl"],
"group": ["name", "memberOf"],
"computer": ["name", "memberOf"],
}.get(member_type, ["name", "memberOf"])
custom = I["attributes"].split(",") if I.get("attributes") else []
attributes = list(set(custom) | set(defaults))
if member_type == "group":
query = f"(&(objectCategory={member_type})(memberOf{nested}={group})(sAMAccountName={account}))"
else:
query = f"(&(objectCategory={member_type})(objectClass=user)(memberOf{nested}={group})(sAMAccountName={account}))"
size_limit = int(I.get("limit") or 0)
page_size = int(I.get("page-size") or c["page_size"])
if I.get("page-size"):
size_limit = page_size
flat = search_paging(conn, query, base_dn_arg(), attributes=attributes,
page_size=page_size, size_limit=size_limit, time_limit=time_limit)
members = [{"dn": e["dn"], "category": member_type} for e in flat]
out({"group_dn": group, "members": members, "entries": flat, "count": len(flat)})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,254 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
defaults = ["name", "displayName", "memberOf", "mail", "sAMAccountName", "manager", "userAccountControl"]
query = S.get("default_base_query") or "(&(objectClass=User)(objectCategory=person))"
esc = escape_filter_chars
if I.get("dn"):
query = f"(&(objectClass=User)(objectCategory=person)(distinguishedName={esc(I['dn'])}))"
elif I.get("name"):
query = f"(&(objectClass=User)(objectCategory=person)(cn={esc(I['name'])}))"
elif I.get("email"):
query = f"(&(objectClass=User)(objectCategory=person)(mail={esc(I['email'])}))"
elif I.get("username") or I.get("sAMAccountName"):
u = esc(I.get("username") or I.get("sAMAccountName"))
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={u}))"
elif I.get("custom-field-type"):
if not I.get("custom-field-data"):
fail("custom-field-data is required when custom-field-type is given")
query = f"(&(objectClass=User)(objectCategory=person)({esc(I['custom-field-type'])}={esc(I['custom-field-data'])}))"
custom = I["attributes"].split(",") if I.get("attributes") else []
exclude = [a.strip() for a in (I.get("attributes-to-exclude") or "").split(",") if a.strip()]
attributes = list((set(custom) | set(defaults)) - set(exclude))
limit = int(I.get("limit") or 20)
if limit <= 0:
limit = 20
page_size = int(I.get("page-size") or c["page_size"])
if I.get("page-size"):
limit = int(I["page-size"])
flat = search_paging(conn, query, base_dn_arg(), attributes=attributes, size_limit=limit, page_size=page_size)
for user in flat:
if user.get("userAccountControl"):
user["userAccountControlFields"] = uac_fields(first_uac(user["userAccountControl"]))
out({"users": flat, "count": len(flat)})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,229 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
name = I["computer-name"]
dn = computer_dn(conn, name, I.get("base-dn") or base_dn_arg())
if not conn.modify_dn(dn, f"CN={name}", new_superior=I["full-superior-dn"]):
fail("Failed to move computer: " + json.dumps(conn.result, default=str))
out({"moved": name, "to": I["full-superior-dn"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,236 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
sam = I["username"]
never = str(I["value"]).lower() == "true"
sb = base_dn_arg()
flat = search_paging(conn, f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))",
sb, attributes=["userAccountControl"], size_limit=1, page_size=1)
if not flat:
fail(f"User {sam} not found")
uac = first_uac(flat[0].get("userAccountControl"))
uac = (uac | (1 << 16)) if never else (uac & ~(1 << 16))
dn = user_dn(conn, sam, sb)
modify(conn, dn, {"userAccountControl": [(MODIFY_REPLACE, [uac])]})
out({"username": sam, "password_never_expire": never})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,231 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
name = I["user-name"]
dn = user_dn(conn, name, I.get("base-dn") or base_dn_arg())
cn = dn.split(",OU=", 1)[0].split(",DC=", 1)[0].replace("\\", "")
dn_clean = dn.replace("\\", "")
if not conn.modify_dn(dn_clean, cn, new_superior=I["full-superior-dn"]):
fail("Failed to move user: " + json.dumps(conn.result, default=str))
out({"moved": name, "to": I["full-superior-dn"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,237 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
sb = base_dn_arg()
if I.get("username") and I.get("computer-name"):
fail("Provide either username or computer-name, not both")
if I.get("username"):
member_dn = user_dn(conn, I["username"], sb)
elif I.get("computer-name"):
member_dn = computer_dn(conn, I["computer-name"], sb)
else:
fail("Provide username or computer-name")
grp = group_dn(conn, I["group-cn"], sb)
if not microsoft.removeMembersFromGroups.ad_remove_members_from_groups(conn, [member_dn], [grp], True):
fail("Failed to remove member from group " + I["group-cn"])
out({"removed": member_dn, "group": I["group-cn"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,239 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
search_filter = I["filter"]
for a, b in {"\(": "\28", "\)": "\29", "\*": "\2a", "\/": "\2f", "\\\\": "\5c"}.items():
search_filter = search_filter.replace(a, b)
search_base = base_dn_arg()
attributes = I.get("attributes")
if attributes:
attributes = ALL_ATTRIBUTES if attributes == "ALL" else [a.strip() for a in attributes.split(",")]
size_limit = int(I.get("size-limit") or 50)
time_limit = int(I.get("time-limit") or 0)
page_size = int(I.get("page-size") or c["page_size"])
if I.get("page-size"):
size_limit = page_size
flat = search_paging(conn, search_filter, search_base, attributes=attributes,
page_size=page_size, size_limit=size_limit, time_limit=time_limit)
out({"results": flat, "count": len(flat)})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,230 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
if c["secure"] not in ("SSL", "TLS"):
fail("Setting a password requires a secure connection (SSL or TLS).")
dn = user_dn(conn, I["username"], I.get("base-dn") or base_dn_arg())
if not conn.extend.microsoft.modify_password(dn, I["password"]):
fail("Failed to set password: " + json.dumps(conn.result, default=str))
out({"password_set": I["username"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,236 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
server = _server(c)
username = I["username"]
try:
if c["ntlm"]:
domain_user = username if "\\" in username else c["host"] + "\\" + username
test = Connection(server, domain_user, password=I["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
test = Connection(server, user=username, password=I["password"], auto_bind=_auto_bind(c["secure"]))
test.unbind()
except LDAPException:
fail(f"Credential test for {username} failed")
out({"valid": username})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,228 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
dn = user_dn(conn, I["username"], I.get("base-dn") or base_dn_arg())
if not microsoft.unlockAccount.ad_unlock_account(conn, dn):
fail("Failed to unlock " + I["username"])
out({"unlocked": I["username"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,226 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
modify(conn, I["contact-dn"], {I["attribute-name"]: [(MODIFY_REPLACE, [I["attribute-value"]])]})
out({"updated": I["contact-dn"], "attribute": I["attribute-name"], "value": I["attribute-value"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,227 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
dn = group_dn(conn, I["groupname"], I.get("basedn") or base_dn_arg())
modify(conn, dn, {I["attributename"]: [(MODIFY_REPLACE, [I["attributevalue"]])]})
out({"updated": dn, "attribute": I["attributename"], "value": I["attributevalue"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,227 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
dn = user_dn(conn, I["username"], I.get("base-dn") or base_dn_arg())
modify(conn, dn, {I["attribute-name"]: [(MODIFY_REPLACE, [I["attribute-value"]])]})
out({"updated": dn, "attribute": I["attribute-name"], "value": I["attribute-value"]})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
@@ -0,0 +1,229 @@
import json, os, sys
S = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
I = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def out(value):
print(json.dumps(value, default=str))
def fail(message, **extra):
payload = {"error": message}
payload.update(extra)
print(json.dumps(payload, default=str))
sys.exit(1)
try:
import ssl
from ldap3 import (
Server, Connection, Tls, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES,
MODIFY_REPLACE, AUTO_BIND_NO_TLS, AUTO_BIND_TLS_BEFORE_BIND,
)
from ldap3.core.exceptions import LDAPException
from ldap3.extend import microsoft
from ldap3.utils.conv import escape_filter_chars
except ImportError as _e:
print(json.dumps({
"error": "The 'ldap3' Python library is required for the Active Directory integration. "
"Install it on the execution host (engine): pip install ldap3",
"detail": str(_e),
}))
sys.exit(1)
SSL_VERSIONS = {
"None": None,
"TLS": ssl.PROTOCOL_TLS,
"TLSv1": ssl.PROTOCOL_TLSv1,
"TLSv1_1": ssl.PROTOCOL_TLSv1_1,
"TLSv1_2": ssl.PROTOCOL_TLSv1_2,
"TLS_CLIENT": ssl.PROTOCOL_TLS_CLIENT,
}
CIPHERS = (
"@SECLEVEL=1:ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:ECDH+AESGCM:"
"DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
)
CONN = None
def _bool(v):
if isinstance(v, bool):
return v
return str(v).lower() in ("1", "true", "yes")
def _list(v):
if isinstance(v, list):
return v
return [x.strip() for x in str(v).split(",") if x.strip()]
def _cfg():
return {
"host": S.get("server_ip") or S.get("host") or "",
"port": int(S["port"]) if S.get("port") else None,
"user": S.get("username") or "",
"password": S.get("password") or "",
"base_dn": S.get("base_dn") or "",
"secure": S.get("secure_connection") or "SSL",
"ssl_version": S.get("ssl_version") or "None",
"ntlm": _bool(S.get("ntlm")),
"unsecure": _bool(S.get("unsecure")),
"page_size": int(S["page_size"]) if S.get("page_size") else 500,
}
def _tls(unsecure, ssl_version):
version = SSL_VERSIONS.get(ssl_version)
if unsecure:
return Tls(validate=ssl.CERT_NONE, ca_certs_file=None, ciphers=CIPHERS, version=version)
return Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("SSL_CERT_FILE"), version=version)
def _server(c):
secure = c["secure"]
if secure in ("SSL", "TLS"):
return Server(host=c["host"], port=c["port"], use_ssl=True, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
if secure == "Start TLS":
return Server(host=c["host"], port=c["port"], use_ssl=False, tls=_tls(c["unsecure"], c["ssl_version"]), connect_timeout=120)
return Server(host=c["host"], port=c["port"], connect_timeout=120)
def _auto_bind(secure):
return AUTO_BIND_TLS_BEFORE_BIND if secure == "Start TLS" else AUTO_BIND_NO_TLS
def connect():
global CONN
c = _cfg()
server = _server(c)
if c["ntlm"]:
domain_user = c["user"] if "\\" in c["user"] else c["host"] + "\\" + c["user"]
CONN = Connection(server, domain_user, password=c["password"], authentication=NTLM, auto_bind=_auto_bind(c["secure"]))
else:
CONN = Connection(server, user=c["user"], password=c["password"], auto_bind=_auto_bind(c["secure"]))
return CONN, c
def base_dn_arg():
return I.get("base-dn") or I.get("base_dn") or _cfg()["base_dn"]
def search_paging(conn, search_filter, search_base, attributes=None, page_size=100, size_limit=0, time_limit=0):
entries = []
cookie = None
total = 0
left = size_limit
while True:
ps = page_size
if 0 < left < page_size:
ps = left
conn.search(search_base, search_filter, search_scope=SUBTREE, attributes=attributes,
paged_size=ps, paged_cookie=cookie, time_limit=time_limit)
left -= len(conn.entries)
total += len(conn.entries)
try:
cookie = conn.result["controls"]["1.2.840.113556.1.4.319"]["value"]["cookie"]
except (KeyError, TypeError):
cookie = None
entries.extend(conn.entries)
if (size_limit and size_limit <= total) or not cookie:
break
flat = []
for entry in entries:
obj = json.loads(entry.entry_to_json())
flat_entry = {"dn": obj["dn"]}
for attr in obj.get("attributes", {}):
flat_entry[attr] = obj["attributes"][attr]
flat.append(flat_entry)
return flat
def user_dn(conn, sam, search_base):
if "\\" in sam:
sam = sam.split("\\", 1)[1]
flat = search_paging(conn, f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find user '{sam}'")
return flat[0]["dn"]
def computer_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=user)(objectCategory=computer)(name={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find computer '{name}'")
return flat[0]["dn"]
def group_dn(conn, name, search_base):
flat = search_paging(conn, f"(&(objectClass=group)(cn={escape_filter_chars(name)}))",
search_base, page_size=1, size_limit=1)
if not flat:
raise Exception(f"Could not find group '{name}'")
return flat[0]["dn"]
def modify(conn, dn, modification):
if not conn.modify(dn, modification):
raise Exception(f"Failed to modify {dn}: {json.dumps(conn.result, default=str)}")
def first_uac(values):
if isinstance(values, list):
return values[0] if values else 0
return values or 0
def uac_fields(value):
value = int(value)
flags = {
"SCRIPT": 0x1, "ACCOUNTDISABLE": 0x2, "HOMEDIR_REQUIRED": 0x8, "LOCKOUT": 0x10,
"PASSWD_NOTREQD": 0x20, "PASSWD_CANT_CHANGE": 0x40, "ENCRYPTED_TEXT_PWD_ALLOWED": 0x80,
"TEMP_DUPLICATE_ACCOUNT": 0x100, "NORMAL_ACCOUNT": 0x200, "INTERDOMAIN_TRUST_ACCOUNT": 0x800,
"WORKSTATION_TRUST_ACCOUNT": 0x1000, "SERVER_TRUST_ACCOUNT": 0x2000, "DONT_EXPIRE_PASSWORD": 0x10000,
"MNS_LOGON_ACCOUNT": 0x20000, "SMARTCARD_REQUIRED": 0x40000, "TRUSTED_FOR_DELEGATION": 0x80000,
"NOT_DELEGATED": 0x100000, "USE_DES_KEY_ONLY": 0x200000, "DONT_REQ_PREAUTH": 0x400000,
"PASSWORD_EXPIRED": 0x800000, "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, "PARTIAL_SECRETS_ACCOUNT": 0x4000000,
}
return {name: bool(value & mask) for name, mask in flags.items()}
def current_uac(conn, search_base):
sam = I.get("username") or I.get("sAMAccountName")
query = "(&(objectClass=User)(objectCategory=person))"
if sam:
query = f"(&(objectClass=User)(objectCategory=person)(sAMAccountName={escape_filter_chars(sam)}))"
flat = search_paging(conn, query, search_base, attributes=["userAccountControl"], size_limit=1, page_size=1)
if flat and flat[0].get("userAccountControl"):
return first_uac(flat[0]["userAccountControl"])
return 512
def run():
conn, c = connect()
if c["base_dn"]:
found = conn.search(search_base=c["base_dn"], search_filter="(objectClass=*)",
search_scope=BASE, size_limit=1, attributes=["1.1"])
if not found:
fail("Connected, but failed to verify base DN: " + json.dumps(conn.result, default=str))
out({"ok": True})
try:
run()
except SystemExit:
raise
except LDAPException as e:
fail("LDAP error: " + str(e))
except Exception as e:
fail(str(e))
finally:
try:
if CONN is not None:
CONN.unbind()
except Exception:
pass
+101
View File
@@ -0,0 +1,101 @@
id: alienvault_otx
name: AlienVault OTX
version: 1.0.0
description: "AlienVault OTX (Open Threat Exchange, API v1) — reputation and threat context for IPs, domains, URLs and file hashes, pulse details and search, and passive DNS / related-URL pivots. API-key authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: IP/domain/URL/file reputation, pulse details and search, passive DNS and related URLs."
category: enrichment
config_schema:
properties:
api_key:
type: string
description: "AlienVault OTX API key (from your OTX account settings)"
x-soar-sensitive: true
required:
- api_key
commands:
- id: ip_reputation
name: alienvault-otx-ip
description: "Threat context for an IP address (IPv4 or IPv6)."
risk: read
inputs_schema:
properties:
ip: { type: string, description: "IP address" }
required: [ip]
outputs_schema: { properties: {} }
- id: domain_reputation
name: alienvault-otx-domain
description: "Threat context for a domain."
risk: read
inputs_schema:
properties:
domain: { type: string, description: "Domain name" }
required: [domain]
outputs_schema: { properties: {} }
- id: url_reputation
name: alienvault-otx-url
description: "Threat context for a URL."
risk: read
inputs_schema:
properties:
url: { type: string, description: "URL" }
required: [url]
outputs_schema: { properties: {} }
- id: file_reputation
name: alienvault-otx-file
description: "Threat context for a file hash (MD5, SHA1 or SHA256)."
risk: read
inputs_schema:
properties:
file: { type: string, description: "File hash" }
required: [file]
outputs_schema: { properties: {} }
- id: get_pulse
name: alienvault-otx-get-pulse
description: "Get the details of a pulse (threat report) by ID."
risk: read
inputs_schema:
properties:
pulse_id: { type: string, description: "Pulse ID" }
required: [pulse_id]
outputs_schema: { properties: {} }
- id: search_pulses
name: alienvault-otx-search-pulses
description: "Search pulses by keyword."
risk: read
inputs_schema:
properties:
query: { type: string, description: "Search string" }
limit: { type: number, description: "Maximum pulses (default 20)" }
required: [query]
outputs_schema: { properties: {} }
- id: passive_dns
name: alienvault-otx-passive-dns
description: "Passive DNS records for an IP or domain indicator."
risk: read
inputs_schema:
properties:
indicator: { type: string, description: "IP or domain" }
indicator_type: { type: string, description: "IPv4, IPv6 or domain (default auto-detected)" }
required: [indicator]
outputs_schema: { properties: {} }
- id: related_urls
name: alienvault-otx-related-urls
description: "URLs associated with an IP or domain indicator."
risk: read
inputs_schema:
properties:
indicator: { type: string, description: "IP or domain" }
indicator_type: { type: string, description: "IPv4, IPv6 or domain (default auto-detected)" }
required: [indicator]
outputs_schema: { properties: {} }
- id: test_connection
name: alienvault-otx-test-connection
description: "Verify the API key (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,41 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://otx.alienvault.com/api/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
headers = {"Accept": "application/json", "X-OTX-API-KEY": str(_cfg().get("api_key") or "")}
req = urllib.request.Request(url, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
q = lambda v: urllib.parse.quote(str(v), safe="")
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
domain = inputs.get("domain")
if not domain:
raise Exception("domain is required")
res = request("GET", "/indicators/domain/%s/general" % q(domain))
print(json.dumps(res))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,41 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://otx.alienvault.com/api/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
headers = {"Accept": "application/json", "X-OTX-API-KEY": str(_cfg().get("api_key") or "")}
req = urllib.request.Request(url, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
q = lambda v: urllib.parse.quote(str(v), safe="")
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
file_hash = inputs.get("file")
if not file_hash:
raise Exception("file is required")
res = request("GET", "/indicators/file/%s/general" % q(file_hash))
print(json.dumps(res))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,41 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://otx.alienvault.com/api/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
headers = {"Accept": "application/json", "X-OTX-API-KEY": str(_cfg().get("api_key") or "")}
req = urllib.request.Request(url, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
q = lambda v: urllib.parse.quote(str(v), safe="")
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
pulse_id = inputs.get("pulse_id")
if not pulse_id:
raise Exception("pulse_id is required")
res = request("GET", "/pulses/%s" % q(pulse_id))
print(json.dumps(res))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,43 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
import re
API = "https://otx.alienvault.com/api/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
headers = {"Accept": "application/json", "X-OTX-API-KEY": str(_cfg().get("api_key") or "")}
req = urllib.request.Request(url, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
q = lambda v: urllib.parse.quote(str(v), safe="")
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
ip = inputs.get("ip")
if not ip:
raise Exception("ip is required")
section = "IPv6" if ":" in ip else "IPv4"
res = request("GET", "/indicators/%s/%s/general" % (section, q(ip)))
print(json.dumps(res))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,44 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
import re
API = "https://otx.alienvault.com/api/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
headers = {"Accept": "application/json", "X-OTX-API-KEY": str(_cfg().get("api_key") or "")}
req = urllib.request.Request(url, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
q = lambda v: urllib.parse.quote(str(v), safe="")
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
indicator = inputs.get("indicator")
if not indicator:
raise Exception("indicator is required")
indicator_type = inputs.get("indicator_type")
itype = indicator_type or ("IPv6" if ":" in indicator else ("IPv4" if re.match(r"^\d+\.\d+\.\d+\.\d+$", indicator) else "domain"))
res = request("GET", "/indicators/%s/%s/passive_dns" % (itype, q(indicator)))
print(json.dumps(res))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,44 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
import re
API = "https://otx.alienvault.com/api/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
headers = {"Accept": "application/json", "X-OTX-API-KEY": str(_cfg().get("api_key") or "")}
req = urllib.request.Request(url, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
q = lambda v: urllib.parse.quote(str(v), safe="")
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
indicator = inputs.get("indicator")
if not indicator:
raise Exception("indicator is required")
indicator_type = inputs.get("indicator_type")
itype = indicator_type or ("IPv6" if ":" in indicator else ("IPv4" if re.match(r"^\d+\.\d+\.\d+\.\d+$", indicator) else "domain"))
res = request("GET", "/indicators/%s/%s/url_list" % (itype, q(indicator)), {"limit": 100})
print(json.dumps(res))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,42 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://otx.alienvault.com/api/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
headers = {"Accept": "application/json", "X-OTX-API-KEY": str(_cfg().get("api_key") or "")}
req = urllib.request.Request(url, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
q = lambda v: urllib.parse.quote(str(v), safe="")
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
query = inputs.get("query")
if not query:
raise Exception("query is required")
limit = inputs.get("limit")
res = request("GET", "/search/pulses", {"q": query, "limit": limit or 20})
print(json.dumps(res))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,39 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://otx.alienvault.com/api/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
headers = {"Accept": "application/json", "X-OTX-API-KEY": str(_cfg().get("api_key") or "")}
req = urllib.request.Request(url, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
q = lambda v: urllib.parse.quote(str(v), safe="")
def main():
res = request("GET", "/user/me")
if "username" not in res:
raise Exception("unexpected response")
print(json.dumps({"ok": True, "user": res.get("username")}))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,41 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://otx.alienvault.com/api/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def request(method, path, params=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
headers = {"Accept": "application/json", "X-OTX-API-KEY": str(_cfg().get("api_key") or "")}
req = urllib.request.Request(url, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
q = lambda v: urllib.parse.quote(str(v), safe="")
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
url = inputs.get("url")
if not url:
raise Exception("url is required")
res = request("GET", "/indicators/url/%s/general" % q(url))
print(json.dumps(res))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,112 @@
id: anomali_threatstream
name: Anomali ThreatStream
version: 1.0.0
description: "Anomali ThreatStream (API v2/v1) — threat intelligence: reputation lookups for IPs, domains, file hashes and URLs, indicator search, passive DNS, threat-model listing, and indicator import (with or without approval). API-key authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: ip/domain/file/url reputation, indicator search, passive DNS, threat models, import indicator."
category: threat_intel
# Per-instance configuration. Auth header 'Authorization: apikey <username>:<api_key>'.
config_schema:
properties:
url:
type: string
description: "ThreatStream API URL"
default: "https://api.threatstream.com"
username:
type: string
description: "ThreatStream username"
api_key:
type: string
description: "ThreatStream API key"
x-soar-sensitive: true
required:
- username
- api_key
commands:
- id: ip_reputation
name: anomali-ip-reputation
description: "Look up threat intelligence for an IP address."
risk: read
inputs_schema:
properties:
ip: { type: string, description: "IP address" }
required: [ip]
outputs_schema: { properties: {} }
- id: domain_reputation
name: anomali-domain-reputation
description: "Look up threat intelligence for a domain."
risk: read
inputs_schema:
properties:
domain: { type: string, description: "Domain name" }
required: [domain]
outputs_schema: { properties: {} }
- id: file_reputation
name: anomali-file-reputation
description: "Look up threat intelligence for a file hash."
risk: read
inputs_schema:
properties:
file_hash: { type: string, description: "MD5/SHA1/SHA256 hash" }
required: [file_hash]
outputs_schema: { properties: {} }
- id: url_reputation
name: anomali-url-reputation
description: "Look up threat intelligence for a URL."
risk: read
inputs_schema:
properties:
url: { type: string, description: "URL" }
required: [url]
outputs_schema: { properties: {} }
- id: get_indicators
name: anomali-get-indicators
description: "Search indicators with a free-text query."
risk: read
inputs_schema:
properties:
query: { type: string, description: "ThreatStream search query (q=)" }
limit: { type: number, description: "Max indicators (default 20)" }
required: []
outputs_schema: { properties: {} }
- id: passive_dns
name: anomali-passive-dns
description: "Get passive DNS records for an IP or domain."
risk: read
inputs_schema:
properties:
value: { type: string, description: "IP or domain" }
type: { type: string, description: "ip or domain (default ip)" }
required: [value]
outputs_schema: { properties: {} }
- id: get_threat_models
name: anomali-get-threat-models
description: "List threat models (actors, campaigns, incidents, ...)."
risk: read
inputs_schema:
properties:
query: { type: string, description: "Optional name search" }
limit: { type: number, description: "Max models (default 20)" }
required: []
outputs_schema: { properties: {} }
- id: import_indicator
name: anomali-import-indicator
description: "Import an observable as an indicator (optionally requiring approval)."
inputs_schema:
properties:
value: { type: string, description: "Observable value (IP, domain, hash, URL)" }
itype: { type: string, description: "Indicator type (e.g. mal_ip, mal_domain, apt_md5)" }
confidence: { type: number, description: "Confidence 0-100 (default 50)" }
approve: { type: boolean, description: "Import without approval (default false = requires approval)" }
required: [value, itype]
outputs_schema: { properties: {} }
- id: test_connection
name: anomali-test-connection
description: "Verify connectivity and the API key (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,54 @@
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("url") or "https://api.threatstream.com")).rstrip("/")
def _auth(cfg):
return "apikey " + str(cfg.get("username", "")) + ":" + str(cfg.get("api_key", ""))
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):
domain = inputs.get("domain")
if not domain:
raise Exception("domain is required")
return request("GET", "/api/v2/intelligence/", cfg, params={"value": domain, "type": "domain", "limit": 50})
_run(main)
@@ -0,0 +1,54 @@
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("url") or "https://api.threatstream.com")).rstrip("/")
def _auth(cfg):
return "apikey " + str(cfg.get("username", "")) + ":" + str(cfg.get("api_key", ""))
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):
file_hash = inputs.get("file_hash")
if not file_hash:
raise Exception("file_hash is required")
return request("GET", "/api/v2/intelligence/", cfg, params={"value": file_hash, "type": "md5", "limit": 50})
_run(main)
@@ -0,0 +1,53 @@
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("url") or "https://api.threatstream.com")).rstrip("/")
def _auth(cfg):
return "apikey " + str(cfg.get("username", "")) + ":" + str(cfg.get("api_key", ""))
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):
query = inputs.get("query")
limit = inputs.get("limit")
return request("GET", "/api/v2/intelligence/", cfg, params={"q": query, "limit": int(limit or 20)})
_run(main)
@@ -0,0 +1,53 @@
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("url") or "https://api.threatstream.com")).rstrip("/")
def _auth(cfg):
return "apikey " + str(cfg.get("username", "")) + ":" + str(cfg.get("api_key", ""))
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):
query = inputs.get("query")
limit = inputs.get("limit")
return request("GET", "/api/v1/threat_model_search/", cfg, params={"name": query, "limit": int(limit or 20)})
_run(main)
@@ -0,0 +1,63 @@
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("url") or "https://api.threatstream.com")).rstrip("/")
def _auth(cfg):
return "apikey " + str(cfg.get("username", "")) + ":" + str(cfg.get("api_key", ""))
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):
value = inputs.get("value")
if not value:
raise Exception("value is required")
itype = inputs.get("itype")
if not itype:
raise Exception("itype is required")
confidence = inputs.get("confidence")
approve = inputs.get("approve")
body = {"objects": [{"value": value, "itype": itype, "confidence": int(confidence or 50)}]}
params = {}
if approve:
params["approve"] = "true"
return request("POST", "/api/v2/intelligence/", cfg, body=body, params=params or None)
_run(main)
@@ -0,0 +1,54 @@
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("url") or "https://api.threatstream.com")).rstrip("/")
def _auth(cfg):
return "apikey " + str(cfg.get("username", "")) + ":" + str(cfg.get("api_key", ""))
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):
ip = inputs.get("ip")
if not ip:
raise Exception("ip is required")
return request("GET", "/api/v2/intelligence/", cfg, params={"value": ip, "type": "ip", "limit": 50})
_run(main)
@@ -0,0 +1,57 @@
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("url") or "https://api.threatstream.com")).rstrip("/")
def _auth(cfg):
return "apikey " + str(cfg.get("username", "")) + ":" + str(cfg.get("api_key", ""))
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):
q = lambda v: urllib.parse.quote(str(v), safe="")
value = inputs.get("value")
if not value:
raise Exception("value is required")
itype = inputs.get("type") or "ip"
path = "/api/v1/pdns/" + q(itype) + "/" + q(value) + "/"
return request("GET", path, cfg)
_run(main)
@@ -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 _base(cfg):
return (str(cfg.get("url") or "https://api.threatstream.com")).rstrip("/")
def _auth(cfg):
return "apikey " + str(cfg.get("username", "")) + ":" + str(cfg.get("api_key", ""))
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", "/api/v2/intelligence/", cfg, params={"limit": 1})
return {"ok": True}
_run(main)
@@ -0,0 +1,54 @@
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("url") or "https://api.threatstream.com")).rstrip("/")
def _auth(cfg):
return "apikey " + str(cfg.get("username", "")) + ":" + str(cfg.get("api_key", ""))
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):
url = inputs.get("url")
if not url:
raise Exception("url is required")
return request("GET", "/api/v2/intelligence/", cfg, params={"value": url, "type": "url", "limit": 50})
_run(main)
+83
View File
@@ -0,0 +1,83 @@
id: anyrun
name: ANY.RUN
version: 1.0.0
description: "ANY.RUN (API v1) — interactive malware sandbox: detonate files and URLs on Windows/Linux, poll the analysis report and verdict, list analysis history, read user limits and delete tasks. API-key authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: file/URL detonation, report and verdict retrieval, analysis history, user limits and task deletion."
category: enrichment
# The API key is sent as 'Authorization: API-Key <key>' on every request.
config_schema:
properties:
api_key:
type: string
description: "ANY.RUN API key"
x-soar-sensitive: true
required:
- api_key
commands:
- id: detonate_file
name: anyrun-detonate-file
description: "Detonate a file (base64) in the ANY.RUN sandbox. Returns a task_id; poll with anyrun-get-report."
inputs_schema:
properties:
file_name: { type: string, description: "File name" }
content_base64: { type: string, description: "File content, base64-encoded" }
os: { type: string, description: "Sandbox OS: windows or linux (default windows)" }
env_bitness: { type: number, description: "Windows bitness: 32 or 64 (default 64)" }
required: [file_name, content_base64]
outputs_schema: { properties: {} }
- id: detonate_url
name: anyrun-detonate-url
description: "Detonate a URL in the ANY.RUN sandbox. Returns a task_id."
inputs_schema:
properties:
url: { type: string, description: "URL to detonate" }
os: { type: string, description: "Sandbox OS: windows or linux (default windows)" }
env_bitness: { type: number, description: "Windows bitness: 32 or 64 (default 64)" }
required: [url]
outputs_schema: { properties: {} }
- id: get_report
name: anyrun-get-report
description: "Get the full analysis report for a task (includes the verdict once the analysis completes)."
risk: read
inputs_schema:
properties:
task_id: { type: string, description: "Task ID (from a detonate command)" }
required: [task_id]
outputs_schema: { properties: {} }
- id: get_history
name: anyrun-get-history
description: "List the analysis history for the account."
risk: read
inputs_schema:
properties:
limit: { type: number, description: "Maximum records (default 25)" }
skip: { type: number, description: "Records to skip (pagination)" }
required: []
outputs_schema: { properties: {} }
- id: get_user_limits
name: anyrun-get-user-limits
description: "Read the account's API usage limits."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
- id: delete_task
name: anyrun-delete-task
description: "Delete an analysis task by ID."
inputs_schema:
properties:
task_id: { type: string, description: "Task ID" }
required: [task_id]
outputs_schema: { properties: {} }
- id: test_connection
name: anyrun-test-connection
description: "Verify the API key (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,55 @@
import base64, json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://api.any.run/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _headers(extra=None):
h = {"Accept": "application/json", "Authorization": "API-Key " + str(_cfg().get("api_key") or "")}
if extra:
h.update(extra)
return h
def request(method, path, params=None, form=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
data = None
headers = _headers()
if form is not None:
data = urllib.parse.urlencode({k: str(v) for k, v in form.items() if v not in (None, "")}).encode("utf-8")
headers["Content-Type"] = "application/x-www-form-urlencoded"
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 {}
q = lambda v: urllib.parse.quote(str(v), safe="")
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
task_id = inputs.get("task_id")
if not task_id:
raise Exception("task_id is required")
result = request("DELETE", "/analysis/" + q(task_id))
if not result:
result = {"ok": True, "task_id": task_id}
print(json.dumps(result))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,81 @@
import base64, json, os, sys, urllib.parse, urllib.request, urllib.error
import uuid
API = "https://api.any.run/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _headers(extra=None):
h = {"Accept": "application/json", "Authorization": "API-Key " + str(_cfg().get("api_key") or "")}
if extra:
h.update(extra)
return h
def request(method, path, params=None, form=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
data = None
headers = _headers()
if form is not None:
data = urllib.parse.urlencode({k: str(v) for k, v in form.items() if v not in (None, "")}).encode("utf-8")
headers["Content-Type"] = "application/x-www-form-urlencoded"
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 multipart(fields, file_field, file_name, file_bytes):
boundary = "----riposte" + uuid.uuid4().hex
parts = []
for name, value in fields.items():
parts.append(("--" + boundary + "\r\n"
+ 'Content-Disposition: form-data; name="' + name + '"\r\n\r\n'
+ str(value) + "\r\n").encode("utf-8"))
parts.append(("--" + boundary + "\r\n"
+ 'Content-Disposition: form-data; name="' + file_field + '"; filename="' + file_name + '"\r\n'
+ "Content-Type: application/octet-stream\r\n\r\n").encode("utf-8"))
parts.append(file_bytes)
parts.append(("\r\n--" + boundary + "--\r\n").encode("utf-8"))
return boundary, b"".join(parts)
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
file_name = inputs.get("file_name")
if not file_name:
raise Exception("file_name is required")
content_base64 = inputs.get("content_base64")
if not content_base64:
raise Exception("content_base64 is required")
env_os = inputs.get("os") or "windows"
env_bitness = inputs.get("env_bitness") or 64
fields = {"obj_type": "file", "env_os": env_os, "env_bitness": str(env_bitness)}
boundary, body = multipart(fields, "file", file_name, base64.b64decode(content_base64))
req = urllib.request.Request(
API + "/analysis",
data=body,
headers=_headers({"Content-Type": "multipart/form-data; boundary=" + boundary}),
method="POST",
)
with urllib.request.urlopen(req, timeout=120) as r:
raw = r.read()
result = json.loads(raw) if raw else {}
print(json.dumps(result))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,52 @@
import base64, json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://api.any.run/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _headers(extra=None):
h = {"Accept": "application/json", "Authorization": "API-Key " + str(_cfg().get("api_key") or "")}
if extra:
h.update(extra)
return h
def request(method, path, params=None, form=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
data = None
headers = _headers()
if form is not None:
data = urllib.parse.urlencode({k: str(v) for k, v in form.items() if v not in (None, "")}).encode("utf-8")
headers["Content-Type"] = "application/x-www-form-urlencoded"
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 main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
url = inputs.get("url")
if not url:
raise Exception("url is required")
env_os = inputs.get("os") or "windows"
env_bitness = inputs.get("env_bitness") or 64
result = request("POST", "/analysis", form={"obj_type": "url", "obj_url": url, "env_os": env_os, "env_bitness": env_bitness})
print(json.dumps(result))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,49 @@
import base64, json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://api.any.run/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _headers(extra=None):
h = {"Accept": "application/json", "Authorization": "API-Key " + str(_cfg().get("api_key") or "")}
if extra:
h.update(extra)
return h
def request(method, path, params=None, form=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
data = None
headers = _headers()
if form is not None:
data = urllib.parse.urlencode({k: str(v) for k, v in form.items() if v not in (None, "")}).encode("utf-8")
headers["Content-Type"] = "application/x-www-form-urlencoded"
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 main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
limit = inputs.get("limit") or 25
skip = inputs.get("skip")
result = request("GET", "/analysis", params={"limit": limit, "skip": skip})
print(json.dumps(result))
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)
+53
View File
@@ -0,0 +1,53 @@
import base64, json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://api.any.run/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _headers(extra=None):
h = {"Accept": "application/json", "Authorization": "API-Key " + str(_cfg().get("api_key") or "")}
if extra:
h.update(extra)
return h
def request(method, path, params=None, form=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
data = None
headers = _headers()
if form is not None:
data = urllib.parse.urlencode({k: str(v) for k, v in form.items() if v not in (None, "")}).encode("utf-8")
headers["Content-Type"] = "application/x-www-form-urlencoded"
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 {}
q = lambda v: urllib.parse.quote(str(v), safe="")
def main():
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
task_id = inputs.get("task_id")
if not task_id:
raise Exception("task_id is required")
result = request("GET", "/analysis/" + q(task_id))
print(json.dumps(result))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,45 @@
import base64, json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://api.any.run/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _headers(extra=None):
h = {"Accept": "application/json", "Authorization": "API-Key " + str(_cfg().get("api_key") or "")}
if extra:
h.update(extra)
return h
def request(method, path, params=None, form=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
data = None
headers = _headers()
if form is not None:
data = urllib.parse.urlencode({k: str(v) for k, v in form.items() if v not in (None, "")}).encode("utf-8")
headers["Content-Type"] = "application/x-www-form-urlencoded"
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 main():
result = request("GET", "/user/limits")
print(json.dumps(result))
try:
main()
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
@@ -0,0 +1,47 @@
import base64, json, os, sys, urllib.parse, urllib.request, urllib.error
API = "https://api.any.run/v1"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _headers(extra=None):
h = {"Accept": "application/json", "Authorization": "API-Key " + str(_cfg().get("api_key") or "")}
if extra:
h.update(extra)
return h
def request(method, path, params=None, form=None):
url = API + path
p = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
if p:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(p)
data = None
headers = _headers()
if form is not None:
data = urllib.parse.urlencode({k: str(v) for k, v in form.items() if v not in (None, "")}).encode("utf-8")
headers["Content-Type"] = "application/x-www-form-urlencoded"
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 main():
result = request("GET", "/user/limits")
if not isinstance(result, dict):
raise Exception("unexpected response")
print(json.dumps({"ok": True}))
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)
+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)
+126
View File
@@ -0,0 +1,126 @@
id: aws
name: AWS
version: 1.0.0
description: "Amazon Web Services (EC2, IAM, STS) — cloud containment: describe instances and security groups, authorize/revoke security-group ingress rules, change an instance's security groups (isolate), stop instances, and deactivate a compromised IAM access key. AWS Signature V4 authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: EC2 describe instances/security-groups, authorize/revoke ingress, modify instance security groups, stop instances; IAM list/update access keys; STS caller identity."
category: cloud
# Per-instance configuration. Requests are signed with AWS Signature V4.
# Use an IAM user/role access key with EC2 + IAM permissions. session_token is
# only needed for temporary (STS) credentials.
config_schema:
properties:
access_key_id:
type: string
description: "AWS access key ID"
secret_access_key:
type: string
description: "AWS secret access key"
x-soar-sensitive: true
region:
type: string
description: "Default AWS region (e.g. eu-west-1)"
default: "us-east-1"
session_token:
type: string
description: "Optional STS session token (for temporary credentials)"
x-soar-sensitive: true
required:
- access_key_id
- secret_access_key
commands:
- id: describe_instances
name: aws-describe-instances
description: "Describe EC2 instances (optionally a single instance by ID)."
risk: read
inputs_schema:
properties:
instance_id: { type: string, description: "Optional instance ID to fetch a single instance" }
region: { type: string, description: "Region override" }
required: []
outputs_schema: { properties: {} }
- id: describe_security_groups
name: aws-describe-security-groups
description: "Describe EC2 security groups (optionally a single group by ID)."
risk: read
inputs_schema:
properties:
group_id: { type: string, description: "Optional security group ID" }
region: { type: string, description: "Region override" }
required: []
outputs_schema: { properties: {} }
- id: authorize_security_group_ingress
name: aws-authorize-security-group-ingress
description: "Add an inbound rule to a security group."
inputs_schema:
properties:
group_id: { type: string, description: "Security group ID" }
protocol: { type: string, description: "IP protocol (tcp, udp, icmp, or -1 for all)" }
from_port: { type: number, description: "Start port" }
to_port: { type: number, description: "End port" }
cidr: { type: string, description: "Source CIDR (e.g. 203.0.113.0/24)" }
region: { type: string, description: "Region override" }
required: [group_id, protocol, cidr]
outputs_schema: { properties: {} }
- id: revoke_security_group_ingress
name: aws-revoke-security-group-ingress
description: "Remove an inbound rule from a security group (containment)."
inputs_schema:
properties:
group_id: { type: string, description: "Security group ID" }
protocol: { type: string, description: "IP protocol (tcp, udp, icmp, or -1 for all)" }
from_port: { type: number, description: "Start port" }
to_port: { type: number, description: "End port" }
cidr: { type: string, description: "Source CIDR to revoke" }
region: { type: string, description: "Region override" }
required: [group_id, protocol, cidr]
outputs_schema: { properties: {} }
- id: modify_instance_security_groups
name: aws-modify-instance-security-groups
description: "Replace the security groups attached to an instance (e.g. move it to an isolation group)."
inputs_schema:
properties:
instance_id: { type: string, description: "Instance ID" }
group_ids: { type: string, description: "Comma-separated security group IDs to set" }
region: { type: string, description: "Region override" }
required: [instance_id, group_ids]
outputs_schema: { properties: {} }
- id: stop_instances
name: aws-stop-instances
description: "Stop one or more EC2 instances."
inputs_schema:
properties:
instance_ids: { type: string, description: "Comma-separated instance IDs" }
force: { type: boolean, description: "Force stop (default false)" }
region: { type: string, description: "Region override" }
required: [instance_ids]
outputs_schema: { properties: {} }
- id: list_access_keys
name: aws-list-access-keys
description: "List a user's IAM access keys."
risk: read
inputs_schema:
properties:
user_name: { type: string, description: "IAM user name (omit to use the calling user)" }
required: []
outputs_schema: { properties: {} }
- id: update_access_key
name: aws-update-access-key
description: "Activate or deactivate an IAM access key (deactivate to contain a compromised key)."
inputs_schema:
properties:
access_key_id: { type: string, description: "The access key ID to update" }
status: { type: string, description: "Active or Inactive" }
user_name: { type: string, description: "IAM user name (omit to use the calling user)" }
required: [access_key_id, status]
outputs_schema: { properties: {} }
- id: test_connection
name: aws-test-connection
description: "Verify credentials via STS GetCallerIdentity (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,151 @@
import json, os, sys, hmac, hashlib, datetime
import urllib.parse, urllib.request, urllib.error
import xml.etree.ElementTree as ET
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _sign_key(key, date_stamp, region, service):
def _h(k, m):
return hmac.new(k, m.encode("utf-8"), hashlib.sha256).digest()
k_date = _h(("AWS4" + key).encode("utf-8"), date_stamp)
k_region = _h(k_date, region)
k_service = _h(k_region, service)
return _h(k_service, "aws4_request")
def _strip_ns(tag):
return tag.split("}", 1)[1] if "}" in tag else tag
def _xml_to_dict(elem):
d = {}
children = list(elem)
if not children:
return (elem.text or "").strip()
for c in children:
tag = _strip_ns(c.tag)
val = _xml_to_dict(c)
if tag in d:
if not isinstance(d[tag], list):
d[tag] = [d[tag]]
d[tag].append(val)
else:
d[tag] = val
return d
def aws_query(service, host, region, action, version, params, cfg):
# params: dict of extra query params for this Action
body_params = {"Action": action, "Version": version}
body_params.update({k: str(v) for k, v in params.items() if v is not None})
body = urllib.parse.urlencode(sorted(body_params.items()))
access_key = str(cfg.get("access_key_id", ""))
secret_key = str(cfg.get("secret_access_key", ""))
session_token = cfg.get("session_token") or ""
now = datetime.datetime.utcnow()
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
method = "POST"
canonical_uri = "/"
canonical_querystring = ""
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
canonical_headers = "content-type:application/x-www-form-urlencoded; charset=utf-8\n" \
"host:" + host + "\n" \
"x-amz-date:" + amz_date + "\n"
signed_headers = "content-type;host;x-amz-date"
if session_token:
canonical_headers += "x-amz-security-token:" + session_token + "\n"
signed_headers = "content-type;host;x-amz-date;x-amz-security-token"
canonical_request = "\n".join([method, canonical_uri, canonical_querystring,
canonical_headers, signed_headers, payload_hash])
algorithm = "AWS4-HMAC-SHA256"
credential_scope = date_stamp + "/" + region + "/" + service + "/aws4_request"
string_to_sign = "\n".join([algorithm, amz_date, credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()])
signing_key = _sign_key(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (algorithm + " Credential=" + access_key + "/" + credential_scope +
", SignedHeaders=" + signed_headers + ", Signature=" + signature)
headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"X-Amz-Date": amz_date,
"Authorization": authorization,
"Accept": "application/json",
}
if session_token:
headers["X-Amz-Security-Token"] = session_token
req = urllib.request.Request("https://" + host + "/", data=body.encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
root = ET.fromstring(raw)
return {_strip_ns(root.tag): _xml_to_dict(root)}
def _region(cfg, inputs):
return (inputs.get("region") if inputs else None) or cfg.get("region") or "us-east-1"
def ec2(action, params, cfg, inputs):
region = _region(cfg, inputs)
return aws_query("ec2", "ec2." + region + ".amazonaws.com", region, action, "2016-11-15", params, cfg)
def iam(action, params, cfg):
return aws_query("iam", "iam.amazonaws.com", "us-east-1", action, "2010-05-08", params, cfg)
def sts(action, params, cfg):
return aws_query("sts", "sts.amazonaws.com", "us-east-1", action, "2011-06-15", params, cfg)
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):
group_id = inputs.get("group_id")
if not group_id:
raise Exception("group_id is required")
protocol = inputs.get("protocol")
if not protocol:
raise Exception("protocol is required")
cidr = inputs.get("cidr")
if not cidr:
raise Exception("cidr is required")
params = {
"GroupId": group_id,
"IpPermissions.1.IpProtocol": protocol,
"IpPermissions.1.IpRanges.1.CidrIp": cidr,
}
from_port = inputs.get("from_port")
if from_port is not None and str(from_port).strip() != "":
params["IpPermissions.1.FromPort"] = int(from_port)
to_port = inputs.get("to_port")
if to_port is not None and str(to_port).strip() != "":
params["IpPermissions.1.ToPort"] = int(to_port)
return ec2("AuthorizeSecurityGroupIngress", params, cfg, inputs)
_run(main)
@@ -0,0 +1,131 @@
import json, os, sys, hmac, hashlib, datetime
import urllib.parse, urllib.request, urllib.error
import xml.etree.ElementTree as ET
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _sign_key(key, date_stamp, region, service):
def _h(k, m):
return hmac.new(k, m.encode("utf-8"), hashlib.sha256).digest()
k_date = _h(("AWS4" + key).encode("utf-8"), date_stamp)
k_region = _h(k_date, region)
k_service = _h(k_region, service)
return _h(k_service, "aws4_request")
def _strip_ns(tag):
return tag.split("}", 1)[1] if "}" in tag else tag
def _xml_to_dict(elem):
d = {}
children = list(elem)
if not children:
return (elem.text or "").strip()
for c in children:
tag = _strip_ns(c.tag)
val = _xml_to_dict(c)
if tag in d:
if not isinstance(d[tag], list):
d[tag] = [d[tag]]
d[tag].append(val)
else:
d[tag] = val
return d
def aws_query(service, host, region, action, version, params, cfg):
# params: dict of extra query params for this Action
body_params = {"Action": action, "Version": version}
body_params.update({k: str(v) for k, v in params.items() if v is not None})
body = urllib.parse.urlencode(sorted(body_params.items()))
access_key = str(cfg.get("access_key_id", ""))
secret_key = str(cfg.get("secret_access_key", ""))
session_token = cfg.get("session_token") or ""
now = datetime.datetime.utcnow()
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
method = "POST"
canonical_uri = "/"
canonical_querystring = ""
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
canonical_headers = "content-type:application/x-www-form-urlencoded; charset=utf-8\n" \
"host:" + host + "\n" \
"x-amz-date:" + amz_date + "\n"
signed_headers = "content-type;host;x-amz-date"
if session_token:
canonical_headers += "x-amz-security-token:" + session_token + "\n"
signed_headers = "content-type;host;x-amz-date;x-amz-security-token"
canonical_request = "\n".join([method, canonical_uri, canonical_querystring,
canonical_headers, signed_headers, payload_hash])
algorithm = "AWS4-HMAC-SHA256"
credential_scope = date_stamp + "/" + region + "/" + service + "/aws4_request"
string_to_sign = "\n".join([algorithm, amz_date, credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()])
signing_key = _sign_key(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (algorithm + " Credential=" + access_key + "/" + credential_scope +
", SignedHeaders=" + signed_headers + ", Signature=" + signature)
headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"X-Amz-Date": amz_date,
"Authorization": authorization,
"Accept": "application/json",
}
if session_token:
headers["X-Amz-Security-Token"] = session_token
req = urllib.request.Request("https://" + host + "/", data=body.encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
root = ET.fromstring(raw)
return {_strip_ns(root.tag): _xml_to_dict(root)}
def _region(cfg, inputs):
return (inputs.get("region") if inputs else None) or cfg.get("region") or "us-east-1"
def ec2(action, params, cfg, inputs):
region = _region(cfg, inputs)
return aws_query("ec2", "ec2." + region + ".amazonaws.com", region, action, "2016-11-15", params, cfg)
def iam(action, params, cfg):
return aws_query("iam", "iam.amazonaws.com", "us-east-1", action, "2010-05-08", params, cfg)
def sts(action, params, cfg):
return aws_query("sts", "sts.amazonaws.com", "us-east-1", action, "2011-06-15", params, cfg)
def _run(fn):
try:
print(json.dumps(fn(_cfg(), _inputs())))
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
def main(cfg, inputs):
instance_id = inputs.get("instance_id")
params = {"InstanceId.1": instance_id} if instance_id else {}
return ec2("DescribeInstances", params, cfg, inputs)
_run(main)
@@ -0,0 +1,131 @@
import json, os, sys, hmac, hashlib, datetime
import urllib.parse, urllib.request, urllib.error
import xml.etree.ElementTree as ET
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _sign_key(key, date_stamp, region, service):
def _h(k, m):
return hmac.new(k, m.encode("utf-8"), hashlib.sha256).digest()
k_date = _h(("AWS4" + key).encode("utf-8"), date_stamp)
k_region = _h(k_date, region)
k_service = _h(k_region, service)
return _h(k_service, "aws4_request")
def _strip_ns(tag):
return tag.split("}", 1)[1] if "}" in tag else tag
def _xml_to_dict(elem):
d = {}
children = list(elem)
if not children:
return (elem.text or "").strip()
for c in children:
tag = _strip_ns(c.tag)
val = _xml_to_dict(c)
if tag in d:
if not isinstance(d[tag], list):
d[tag] = [d[tag]]
d[tag].append(val)
else:
d[tag] = val
return d
def aws_query(service, host, region, action, version, params, cfg):
# params: dict of extra query params for this Action
body_params = {"Action": action, "Version": version}
body_params.update({k: str(v) for k, v in params.items() if v is not None})
body = urllib.parse.urlencode(sorted(body_params.items()))
access_key = str(cfg.get("access_key_id", ""))
secret_key = str(cfg.get("secret_access_key", ""))
session_token = cfg.get("session_token") or ""
now = datetime.datetime.utcnow()
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
method = "POST"
canonical_uri = "/"
canonical_querystring = ""
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
canonical_headers = "content-type:application/x-www-form-urlencoded; charset=utf-8\n" \
"host:" + host + "\n" \
"x-amz-date:" + amz_date + "\n"
signed_headers = "content-type;host;x-amz-date"
if session_token:
canonical_headers += "x-amz-security-token:" + session_token + "\n"
signed_headers = "content-type;host;x-amz-date;x-amz-security-token"
canonical_request = "\n".join([method, canonical_uri, canonical_querystring,
canonical_headers, signed_headers, payload_hash])
algorithm = "AWS4-HMAC-SHA256"
credential_scope = date_stamp + "/" + region + "/" + service + "/aws4_request"
string_to_sign = "\n".join([algorithm, amz_date, credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()])
signing_key = _sign_key(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (algorithm + " Credential=" + access_key + "/" + credential_scope +
", SignedHeaders=" + signed_headers + ", Signature=" + signature)
headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"X-Amz-Date": amz_date,
"Authorization": authorization,
"Accept": "application/json",
}
if session_token:
headers["X-Amz-Security-Token"] = session_token
req = urllib.request.Request("https://" + host + "/", data=body.encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
root = ET.fromstring(raw)
return {_strip_ns(root.tag): _xml_to_dict(root)}
def _region(cfg, inputs):
return (inputs.get("region") if inputs else None) or cfg.get("region") or "us-east-1"
def ec2(action, params, cfg, inputs):
region = _region(cfg, inputs)
return aws_query("ec2", "ec2." + region + ".amazonaws.com", region, action, "2016-11-15", params, cfg)
def iam(action, params, cfg):
return aws_query("iam", "iam.amazonaws.com", "us-east-1", action, "2010-05-08", params, cfg)
def sts(action, params, cfg):
return aws_query("sts", "sts.amazonaws.com", "us-east-1", action, "2011-06-15", params, cfg)
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):
group_id = inputs.get("group_id")
params = {"GroupId.1": group_id} if group_id else {}
return ec2("DescribeSecurityGroups", params, cfg, inputs)
_run(main)
@@ -0,0 +1,131 @@
import json, os, sys, hmac, hashlib, datetime
import urllib.parse, urllib.request, urllib.error
import xml.etree.ElementTree as ET
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _sign_key(key, date_stamp, region, service):
def _h(k, m):
return hmac.new(k, m.encode("utf-8"), hashlib.sha256).digest()
k_date = _h(("AWS4" + key).encode("utf-8"), date_stamp)
k_region = _h(k_date, region)
k_service = _h(k_region, service)
return _h(k_service, "aws4_request")
def _strip_ns(tag):
return tag.split("}", 1)[1] if "}" in tag else tag
def _xml_to_dict(elem):
d = {}
children = list(elem)
if not children:
return (elem.text or "").strip()
for c in children:
tag = _strip_ns(c.tag)
val = _xml_to_dict(c)
if tag in d:
if not isinstance(d[tag], list):
d[tag] = [d[tag]]
d[tag].append(val)
else:
d[tag] = val
return d
def aws_query(service, host, region, action, version, params, cfg):
# params: dict of extra query params for this Action
body_params = {"Action": action, "Version": version}
body_params.update({k: str(v) for k, v in params.items() if v is not None})
body = urllib.parse.urlencode(sorted(body_params.items()))
access_key = str(cfg.get("access_key_id", ""))
secret_key = str(cfg.get("secret_access_key", ""))
session_token = cfg.get("session_token") or ""
now = datetime.datetime.utcnow()
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
method = "POST"
canonical_uri = "/"
canonical_querystring = ""
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
canonical_headers = "content-type:application/x-www-form-urlencoded; charset=utf-8\n" \
"host:" + host + "\n" \
"x-amz-date:" + amz_date + "\n"
signed_headers = "content-type;host;x-amz-date"
if session_token:
canonical_headers += "x-amz-security-token:" + session_token + "\n"
signed_headers = "content-type;host;x-amz-date;x-amz-security-token"
canonical_request = "\n".join([method, canonical_uri, canonical_querystring,
canonical_headers, signed_headers, payload_hash])
algorithm = "AWS4-HMAC-SHA256"
credential_scope = date_stamp + "/" + region + "/" + service + "/aws4_request"
string_to_sign = "\n".join([algorithm, amz_date, credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()])
signing_key = _sign_key(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (algorithm + " Credential=" + access_key + "/" + credential_scope +
", SignedHeaders=" + signed_headers + ", Signature=" + signature)
headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"X-Amz-Date": amz_date,
"Authorization": authorization,
"Accept": "application/json",
}
if session_token:
headers["X-Amz-Security-Token"] = session_token
req = urllib.request.Request("https://" + host + "/", data=body.encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
root = ET.fromstring(raw)
return {_strip_ns(root.tag): _xml_to_dict(root)}
def _region(cfg, inputs):
return (inputs.get("region") if inputs else None) or cfg.get("region") or "us-east-1"
def ec2(action, params, cfg, inputs):
region = _region(cfg, inputs)
return aws_query("ec2", "ec2." + region + ".amazonaws.com", region, action, "2016-11-15", params, cfg)
def iam(action, params, cfg):
return aws_query("iam", "iam.amazonaws.com", "us-east-1", action, "2010-05-08", params, cfg)
def sts(action, params, cfg):
return aws_query("sts", "sts.amazonaws.com", "us-east-1", action, "2011-06-15", params, cfg)
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):
user_name = inputs.get("user_name")
params = {"UserName": user_name} if user_name else {}
return iam("ListAccessKeys", params, cfg)
_run(main)
@@ -0,0 +1,143 @@
import json, os, sys, hmac, hashlib, datetime
import urllib.parse, urllib.request, urllib.error
import xml.etree.ElementTree as ET
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _sign_key(key, date_stamp, region, service):
def _h(k, m):
return hmac.new(k, m.encode("utf-8"), hashlib.sha256).digest()
k_date = _h(("AWS4" + key).encode("utf-8"), date_stamp)
k_region = _h(k_date, region)
k_service = _h(k_region, service)
return _h(k_service, "aws4_request")
def _strip_ns(tag):
return tag.split("}", 1)[1] if "}" in tag else tag
def _xml_to_dict(elem):
d = {}
children = list(elem)
if not children:
return (elem.text or "").strip()
for c in children:
tag = _strip_ns(c.tag)
val = _xml_to_dict(c)
if tag in d:
if not isinstance(d[tag], list):
d[tag] = [d[tag]]
d[tag].append(val)
else:
d[tag] = val
return d
def aws_query(service, host, region, action, version, params, cfg):
# params: dict of extra query params for this Action
body_params = {"Action": action, "Version": version}
body_params.update({k: str(v) for k, v in params.items() if v is not None})
body = urllib.parse.urlencode(sorted(body_params.items()))
access_key = str(cfg.get("access_key_id", ""))
secret_key = str(cfg.get("secret_access_key", ""))
session_token = cfg.get("session_token") or ""
now = datetime.datetime.utcnow()
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
method = "POST"
canonical_uri = "/"
canonical_querystring = ""
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
canonical_headers = "content-type:application/x-www-form-urlencoded; charset=utf-8\n" \
"host:" + host + "\n" \
"x-amz-date:" + amz_date + "\n"
signed_headers = "content-type;host;x-amz-date"
if session_token:
canonical_headers += "x-amz-security-token:" + session_token + "\n"
signed_headers = "content-type;host;x-amz-date;x-amz-security-token"
canonical_request = "\n".join([method, canonical_uri, canonical_querystring,
canonical_headers, signed_headers, payload_hash])
algorithm = "AWS4-HMAC-SHA256"
credential_scope = date_stamp + "/" + region + "/" + service + "/aws4_request"
string_to_sign = "\n".join([algorithm, amz_date, credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()])
signing_key = _sign_key(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (algorithm + " Credential=" + access_key + "/" + credential_scope +
", SignedHeaders=" + signed_headers + ", Signature=" + signature)
headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"X-Amz-Date": amz_date,
"Authorization": authorization,
"Accept": "application/json",
}
if session_token:
headers["X-Amz-Security-Token"] = session_token
req = urllib.request.Request("https://" + host + "/", data=body.encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
root = ET.fromstring(raw)
return {_strip_ns(root.tag): _xml_to_dict(root)}
def _region(cfg, inputs):
return (inputs.get("region") if inputs else None) or cfg.get("region") or "us-east-1"
def ec2(action, params, cfg, inputs):
region = _region(cfg, inputs)
return aws_query("ec2", "ec2." + region + ".amazonaws.com", region, action, "2016-11-15", params, cfg)
def iam(action, params, cfg):
return aws_query("iam", "iam.amazonaws.com", "us-east-1", action, "2010-05-08", params, cfg)
def sts(action, params, cfg):
return aws_query("sts", "sts.amazonaws.com", "us-east-1", action, "2011-06-15", params, cfg)
def _run(fn):
try:
print(json.dumps(fn(_cfg(), _inputs())))
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
def main(cfg, inputs):
instance_id = inputs.get("instance_id")
if not instance_id:
raise Exception("instance_id is required")
group_ids_raw = inputs.get("group_ids")
if not group_ids_raw or not str(group_ids_raw).strip():
raise Exception("group_ids is required")
group_ids = [s.strip() for s in str(group_ids_raw).split(",") if s.strip()]
if not group_ids:
raise Exception("group_ids is required")
params = {"InstanceId": instance_id}
for i, gid in enumerate(group_ids, start=1):
params["GroupId.%d" % i] = gid
return ec2("ModifyInstanceAttribute", params, cfg, inputs)
_run(main)
@@ -0,0 +1,151 @@
import json, os, sys, hmac, hashlib, datetime
import urllib.parse, urllib.request, urllib.error
import xml.etree.ElementTree as ET
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _sign_key(key, date_stamp, region, service):
def _h(k, m):
return hmac.new(k, m.encode("utf-8"), hashlib.sha256).digest()
k_date = _h(("AWS4" + key).encode("utf-8"), date_stamp)
k_region = _h(k_date, region)
k_service = _h(k_region, service)
return _h(k_service, "aws4_request")
def _strip_ns(tag):
return tag.split("}", 1)[1] if "}" in tag else tag
def _xml_to_dict(elem):
d = {}
children = list(elem)
if not children:
return (elem.text or "").strip()
for c in children:
tag = _strip_ns(c.tag)
val = _xml_to_dict(c)
if tag in d:
if not isinstance(d[tag], list):
d[tag] = [d[tag]]
d[tag].append(val)
else:
d[tag] = val
return d
def aws_query(service, host, region, action, version, params, cfg):
# params: dict of extra query params for this Action
body_params = {"Action": action, "Version": version}
body_params.update({k: str(v) for k, v in params.items() if v is not None})
body = urllib.parse.urlencode(sorted(body_params.items()))
access_key = str(cfg.get("access_key_id", ""))
secret_key = str(cfg.get("secret_access_key", ""))
session_token = cfg.get("session_token") or ""
now = datetime.datetime.utcnow()
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
method = "POST"
canonical_uri = "/"
canonical_querystring = ""
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
canonical_headers = "content-type:application/x-www-form-urlencoded; charset=utf-8\n" \
"host:" + host + "\n" \
"x-amz-date:" + amz_date + "\n"
signed_headers = "content-type;host;x-amz-date"
if session_token:
canonical_headers += "x-amz-security-token:" + session_token + "\n"
signed_headers = "content-type;host;x-amz-date;x-amz-security-token"
canonical_request = "\n".join([method, canonical_uri, canonical_querystring,
canonical_headers, signed_headers, payload_hash])
algorithm = "AWS4-HMAC-SHA256"
credential_scope = date_stamp + "/" + region + "/" + service + "/aws4_request"
string_to_sign = "\n".join([algorithm, amz_date, credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()])
signing_key = _sign_key(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (algorithm + " Credential=" + access_key + "/" + credential_scope +
", SignedHeaders=" + signed_headers + ", Signature=" + signature)
headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"X-Amz-Date": amz_date,
"Authorization": authorization,
"Accept": "application/json",
}
if session_token:
headers["X-Amz-Security-Token"] = session_token
req = urllib.request.Request("https://" + host + "/", data=body.encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
root = ET.fromstring(raw)
return {_strip_ns(root.tag): _xml_to_dict(root)}
def _region(cfg, inputs):
return (inputs.get("region") if inputs else None) or cfg.get("region") or "us-east-1"
def ec2(action, params, cfg, inputs):
region = _region(cfg, inputs)
return aws_query("ec2", "ec2." + region + ".amazonaws.com", region, action, "2016-11-15", params, cfg)
def iam(action, params, cfg):
return aws_query("iam", "iam.amazonaws.com", "us-east-1", action, "2010-05-08", params, cfg)
def sts(action, params, cfg):
return aws_query("sts", "sts.amazonaws.com", "us-east-1", action, "2011-06-15", params, cfg)
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):
group_id = inputs.get("group_id")
if not group_id:
raise Exception("group_id is required")
protocol = inputs.get("protocol")
if not protocol:
raise Exception("protocol is required")
cidr = inputs.get("cidr")
if not cidr:
raise Exception("cidr is required")
params = {
"GroupId": group_id,
"IpPermissions.1.IpProtocol": protocol,
"IpPermissions.1.IpRanges.1.CidrIp": cidr,
}
from_port = inputs.get("from_port")
if from_port is not None and str(from_port).strip() != "":
params["IpPermissions.1.FromPort"] = int(from_port)
to_port = inputs.get("to_port")
if to_port is not None and str(to_port).strip() != "":
params["IpPermissions.1.ToPort"] = int(to_port)
return ec2("RevokeSecurityGroupIngress", params, cfg, inputs)
_run(main)
+148
View File
@@ -0,0 +1,148 @@
import json, os, sys, hmac, hashlib, datetime
import urllib.parse, urllib.request, urllib.error
import xml.etree.ElementTree as ET
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _sign_key(key, date_stamp, region, service):
def _h(k, m):
return hmac.new(k, m.encode("utf-8"), hashlib.sha256).digest()
k_date = _h(("AWS4" + key).encode("utf-8"), date_stamp)
k_region = _h(k_date, region)
k_service = _h(k_region, service)
return _h(k_service, "aws4_request")
def _strip_ns(tag):
return tag.split("}", 1)[1] if "}" in tag else tag
def _xml_to_dict(elem):
d = {}
children = list(elem)
if not children:
return (elem.text or "").strip()
for c in children:
tag = _strip_ns(c.tag)
val = _xml_to_dict(c)
if tag in d:
if not isinstance(d[tag], list):
d[tag] = [d[tag]]
d[tag].append(val)
else:
d[tag] = val
return d
def aws_query(service, host, region, action, version, params, cfg):
# params: dict of extra query params for this Action
body_params = {"Action": action, "Version": version}
body_params.update({k: str(v) for k, v in params.items() if v is not None})
body = urllib.parse.urlencode(sorted(body_params.items()))
access_key = str(cfg.get("access_key_id", ""))
secret_key = str(cfg.get("secret_access_key", ""))
session_token = cfg.get("session_token") or ""
now = datetime.datetime.utcnow()
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
method = "POST"
canonical_uri = "/"
canonical_querystring = ""
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
canonical_headers = "content-type:application/x-www-form-urlencoded; charset=utf-8\n" \
"host:" + host + "\n" \
"x-amz-date:" + amz_date + "\n"
signed_headers = "content-type;host;x-amz-date"
if session_token:
canonical_headers += "x-amz-security-token:" + session_token + "\n"
signed_headers = "content-type;host;x-amz-date;x-amz-security-token"
canonical_request = "\n".join([method, canonical_uri, canonical_querystring,
canonical_headers, signed_headers, payload_hash])
algorithm = "AWS4-HMAC-SHA256"
credential_scope = date_stamp + "/" + region + "/" + service + "/aws4_request"
string_to_sign = "\n".join([algorithm, amz_date, credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()])
signing_key = _sign_key(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (algorithm + " Credential=" + access_key + "/" + credential_scope +
", SignedHeaders=" + signed_headers + ", Signature=" + signature)
headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"X-Amz-Date": amz_date,
"Authorization": authorization,
"Accept": "application/json",
}
if session_token:
headers["X-Amz-Security-Token"] = session_token
req = urllib.request.Request("https://" + host + "/", data=body.encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
root = ET.fromstring(raw)
return {_strip_ns(root.tag): _xml_to_dict(root)}
def _region(cfg, inputs):
return (inputs.get("region") if inputs else None) or cfg.get("region") or "us-east-1"
def ec2(action, params, cfg, inputs):
region = _region(cfg, inputs)
return aws_query("ec2", "ec2." + region + ".amazonaws.com", region, action, "2016-11-15", params, cfg)
def iam(action, params, cfg):
return aws_query("iam", "iam.amazonaws.com", "us-east-1", action, "2010-05-08", params, cfg)
def sts(action, params, cfg):
return aws_query("sts", "sts.amazonaws.com", "us-east-1", action, "2011-06-15", params, cfg)
def _run(fn):
try:
print(json.dumps(fn(_cfg(), _inputs())))
except urllib.error.HTTPError as e:
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
sys.exit(1)
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
def main(cfg, inputs):
instance_ids_raw = inputs.get("instance_ids")
if not instance_ids_raw or not str(instance_ids_raw).strip():
raise Exception("instance_ids is required")
instance_ids = [s.strip() for s in str(instance_ids_raw).split(",") if s.strip()]
if not instance_ids:
raise Exception("instance_ids is required")
force = inputs.get("force", False)
if isinstance(force, str):
force = force.strip().lower() in ("true", "1", "yes")
else:
force = bool(force)
params = {}
for i, iid in enumerate(instance_ids, start=1):
params["InstanceId.%d" % i] = iid
if force:
params["Force"] = "true"
return ec2("StopInstances", params, cfg, inputs)
_run(main)
+130
View File
@@ -0,0 +1,130 @@
import json, os, sys, hmac, hashlib, datetime
import urllib.parse, urllib.request, urllib.error
import xml.etree.ElementTree as ET
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _sign_key(key, date_stamp, region, service):
def _h(k, m):
return hmac.new(k, m.encode("utf-8"), hashlib.sha256).digest()
k_date = _h(("AWS4" + key).encode("utf-8"), date_stamp)
k_region = _h(k_date, region)
k_service = _h(k_region, service)
return _h(k_service, "aws4_request")
def _strip_ns(tag):
return tag.split("}", 1)[1] if "}" in tag else tag
def _xml_to_dict(elem):
d = {}
children = list(elem)
if not children:
return (elem.text or "").strip()
for c in children:
tag = _strip_ns(c.tag)
val = _xml_to_dict(c)
if tag in d:
if not isinstance(d[tag], list):
d[tag] = [d[tag]]
d[tag].append(val)
else:
d[tag] = val
return d
def aws_query(service, host, region, action, version, params, cfg):
# params: dict of extra query params for this Action
body_params = {"Action": action, "Version": version}
body_params.update({k: str(v) for k, v in params.items() if v is not None})
body = urllib.parse.urlencode(sorted(body_params.items()))
access_key = str(cfg.get("access_key_id", ""))
secret_key = str(cfg.get("secret_access_key", ""))
session_token = cfg.get("session_token") or ""
now = datetime.datetime.utcnow()
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
method = "POST"
canonical_uri = "/"
canonical_querystring = ""
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
canonical_headers = "content-type:application/x-www-form-urlencoded; charset=utf-8\n" \
"host:" + host + "\n" \
"x-amz-date:" + amz_date + "\n"
signed_headers = "content-type;host;x-amz-date"
if session_token:
canonical_headers += "x-amz-security-token:" + session_token + "\n"
signed_headers = "content-type;host;x-amz-date;x-amz-security-token"
canonical_request = "\n".join([method, canonical_uri, canonical_querystring,
canonical_headers, signed_headers, payload_hash])
algorithm = "AWS4-HMAC-SHA256"
credential_scope = date_stamp + "/" + region + "/" + service + "/aws4_request"
string_to_sign = "\n".join([algorithm, amz_date, credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()])
signing_key = _sign_key(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (algorithm + " Credential=" + access_key + "/" + credential_scope +
", SignedHeaders=" + signed_headers + ", Signature=" + signature)
headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"X-Amz-Date": amz_date,
"Authorization": authorization,
"Accept": "application/json",
}
if session_token:
headers["X-Amz-Security-Token"] = session_token
req = urllib.request.Request("https://" + host + "/", data=body.encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
root = ET.fromstring(raw)
return {_strip_ns(root.tag): _xml_to_dict(root)}
def _region(cfg, inputs):
return (inputs.get("region") if inputs else None) or cfg.get("region") or "us-east-1"
def ec2(action, params, cfg, inputs):
region = _region(cfg, inputs)
return aws_query("ec2", "ec2." + region + ".amazonaws.com", region, action, "2016-11-15", params, cfg)
def iam(action, params, cfg):
return aws_query("iam", "iam.amazonaws.com", "us-east-1", action, "2010-05-08", params, cfg)
def sts(action, params, cfg):
return aws_query("sts", "sts.amazonaws.com", "us-east-1", action, "2011-06-15", params, cfg)
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):
identity = sts("GetCallerIdentity", {}, cfg)
return {"ok": True, "identity": identity}
_run(main)
@@ -0,0 +1,143 @@
import json, os, sys, hmac, hashlib, datetime
import urllib.parse, urllib.request, urllib.error
import xml.etree.ElementTree as ET
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _sign_key(key, date_stamp, region, service):
def _h(k, m):
return hmac.new(k, m.encode("utf-8"), hashlib.sha256).digest()
k_date = _h(("AWS4" + key).encode("utf-8"), date_stamp)
k_region = _h(k_date, region)
k_service = _h(k_region, service)
return _h(k_service, "aws4_request")
def _strip_ns(tag):
return tag.split("}", 1)[1] if "}" in tag else tag
def _xml_to_dict(elem):
d = {}
children = list(elem)
if not children:
return (elem.text or "").strip()
for c in children:
tag = _strip_ns(c.tag)
val = _xml_to_dict(c)
if tag in d:
if not isinstance(d[tag], list):
d[tag] = [d[tag]]
d[tag].append(val)
else:
d[tag] = val
return d
def aws_query(service, host, region, action, version, params, cfg):
# params: dict of extra query params for this Action
body_params = {"Action": action, "Version": version}
body_params.update({k: str(v) for k, v in params.items() if v is not None})
body = urllib.parse.urlencode(sorted(body_params.items()))
access_key = str(cfg.get("access_key_id", ""))
secret_key = str(cfg.get("secret_access_key", ""))
session_token = cfg.get("session_token") or ""
now = datetime.datetime.utcnow()
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
method = "POST"
canonical_uri = "/"
canonical_querystring = ""
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
canonical_headers = "content-type:application/x-www-form-urlencoded; charset=utf-8\n" \
"host:" + host + "\n" \
"x-amz-date:" + amz_date + "\n"
signed_headers = "content-type;host;x-amz-date"
if session_token:
canonical_headers += "x-amz-security-token:" + session_token + "\n"
signed_headers = "content-type;host;x-amz-date;x-amz-security-token"
canonical_request = "\n".join([method, canonical_uri, canonical_querystring,
canonical_headers, signed_headers, payload_hash])
algorithm = "AWS4-HMAC-SHA256"
credential_scope = date_stamp + "/" + region + "/" + service + "/aws4_request"
string_to_sign = "\n".join([algorithm, amz_date, credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()])
signing_key = _sign_key(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (algorithm + " Credential=" + access_key + "/" + credential_scope +
", SignedHeaders=" + signed_headers + ", Signature=" + signature)
headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"X-Amz-Date": amz_date,
"Authorization": authorization,
"Accept": "application/json",
}
if session_token:
headers["X-Amz-Security-Token"] = session_token
req = urllib.request.Request("https://" + host + "/", data=body.encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
root = ET.fromstring(raw)
return {_strip_ns(root.tag): _xml_to_dict(root)}
def _region(cfg, inputs):
return (inputs.get("region") if inputs else None) or cfg.get("region") or "us-east-1"
def ec2(action, params, cfg, inputs):
region = _region(cfg, inputs)
return aws_query("ec2", "ec2." + region + ".amazonaws.com", region, action, "2016-11-15", params, cfg)
def iam(action, params, cfg):
return aws_query("iam", "iam.amazonaws.com", "us-east-1", action, "2010-05-08", params, cfg)
def sts(action, params, cfg):
return aws_query("sts", "sts.amazonaws.com", "us-east-1", action, "2011-06-15", params, cfg)
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):
access_key_id = inputs.get("access_key_id")
if not access_key_id:
raise Exception("access_key_id is required")
status = inputs.get("status")
if not status:
raise Exception("status is required")
if status not in ("Active", "Inactive"):
raise Exception("status must be Active or Inactive")
params = {"AccessKeyId": access_key_id, "Status": status}
user_name = inputs.get("user_name")
if user_name and str(user_name).strip():
params["UserName"] = user_name
return iam("UpdateAccessKey", params, cfg)
_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)
+120
View File
@@ -0,0 +1,120 @@
id: azure_security
name: Microsoft Azure
version: 1.0.0
description: "Microsoft Azure (Resource Manager: Defender for Cloud + Network) — cloud containment: list and read Defender for Cloud security alerts and update their state, read the secure score, list/read network security groups (NSGs), and add or delete NSG security rules (deny inbound to isolate). Azure AD OAuth2 client-credentials authentication; stdlib-only, no extra Python dependencies."
changelog: "1.0.0 — Initial release: list/get security alerts, update alert state, secure score, list/get NSGs, create/delete NSG security rules."
category: cloud
# Per-instance configuration. Uses application (client-credentials) OAuth2 with
# a service principal that has Reader + Security Admin + Network Contributor on
# the subscription.
config_schema:
properties:
tenant_id:
type: string
description: "Azure AD tenant ID"
client_id:
type: string
description: "Service principal (client) ID"
client_secret:
type: string
description: "Service principal client secret"
x-soar-sensitive: true
subscription_id:
type: string
description: "Azure subscription ID"
required:
- tenant_id
- client_id
- client_secret
- subscription_id
commands:
- id: list_alerts
name: azure-list-alerts
description: "List Microsoft Defender for Cloud security alerts in the subscription."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
- id: get_alert
name: azure-get-alert
description: "Get a single security alert by its full ARM resource ID."
risk: read
inputs_schema:
properties:
alert_id: { type: string, description: "Full ARM resource ID of the alert (from azure-list-alerts)" }
required: [alert_id]
outputs_schema: { properties: {} }
- id: update_alert_state
name: azure-update-alert-state
description: "Change a security alert's state (dismiss, resolve, activate, or inProgress)."
inputs_schema:
properties:
alert_id: { type: string, description: "Full ARM resource ID of the alert" }
state: { type: string, description: "dismiss | resolve | activate | inProgress" }
required: [alert_id, state]
outputs_schema: { properties: {} }
- id: get_secure_score
name: azure-get-secure-score
description: "Get the subscription's Defender for Cloud secure score."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
- id: list_nsgs
name: azure-list-nsgs
description: "List network security groups in the subscription."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
- id: get_nsg
name: azure-get-nsg
description: "Get a single network security group."
risk: read
inputs_schema:
properties:
resource_group: { type: string, description: "Resource group name" }
nsg_name: { type: string, description: "NSG name" }
required: [resource_group, nsg_name]
outputs_schema: { properties: {} }
- id: create_nsg_rule
name: azure-create-nsg-rule
description: "Create or update an NSG security rule (e.g. a Deny inbound rule to isolate a resource)."
inputs_schema:
properties:
resource_group: { type: string, description: "Resource group name" }
nsg_name: { type: string, description: "NSG name" }
rule_name: { type: string, description: "Security rule name" }
priority: { type: number, description: "Rule priority (100-4096)" }
direction: { type: string, description: "Inbound or Outbound (default Inbound)" }
access: { type: string, description: "Allow or Deny (default Deny)" }
protocol: { type: string, description: "Tcp, Udp, or * (default *)" }
source: { type: string, description: "Source address prefix (CIDR or *, default *)" }
destination: { type: string, description: "Destination address prefix (default *)" }
destination_port: { type: string, description: "Destination port range (default *)" }
required: [resource_group, nsg_name, rule_name, priority]
outputs_schema: { properties: {} }
- id: delete_nsg_rule
name: azure-delete-nsg-rule
description: "Delete an NSG security rule."
inputs_schema:
properties:
resource_group: { type: string, description: "Resource group name" }
nsg_name: { type: string, description: "NSG name" }
rule_name: { type: string, description: "Security rule name" }
required: [resource_group, nsg_name, rule_name]
outputs_schema: { properties: {} }
- id: test_connection
name: azure-test-connection
description: "Verify connectivity and the service-principal credentials (used by the Test button)."
risk: read
inputs_schema:
properties: {}
required: []
outputs_schema: { properties: {} }
@@ -0,0 +1,103 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
ARM = "https://management.azure.com"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _token(cfg):
url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id", "")) + "/oauth2/v2.0/token"
form = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": str(cfg.get("client_id", "")),
"client_secret": str(cfg.get("client_secret", "")),
"scope": "https://management.azure.com/.default",
}).encode("utf-8")
req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
if not tok.get("access_token"):
raise Exception("Token request failed: " + json.dumps(tok))
return tok["access_token"]
def arm(method, path, token, api_version, body=None, params=None, full_url=None):
url = (full_url if full_url else ARM + path)
qp = {"api-version": api_version}
if params:
qp.update({k: v for k, v in params.items() if v not in (None, "")})
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(qp)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + 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)
sub = lambda cfg: str(cfg.get("subscription_id", ""))
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, token, inputs):
resource_group = inputs.get("resource_group")
if not resource_group:
raise Exception("resource_group is required")
nsg_name = inputs.get("nsg_name")
if not nsg_name:
raise Exception("nsg_name is required")
rule_name = inputs.get("rule_name")
if not rule_name:
raise Exception("rule_name is required")
priority = inputs.get("priority")
if priority in (None, ""):
raise Exception("priority is required")
direction = inputs.get("direction")
access = inputs.get("access")
protocol = inputs.get("protocol")
source = inputs.get("source")
destination = inputs.get("destination")
destination_port = inputs.get("destination_port")
path = ("/subscriptions/" + sub(cfg) + "/resourceGroups/" + q(resource_group) +
"/providers/Microsoft.Network/networkSecurityGroups/" + q(nsg_name) +
"/securityRules/" + q(rule_name))
body = {
"properties": {
"priority": int(priority),
"direction": direction or "Inbound",
"access": access or "Deny",
"protocol": protocol or "*",
"sourceAddressPrefix": source or "*",
"destinationAddressPrefix": destination or "*",
"sourcePortRange": "*",
"destinationPortRange": destination_port or "*",
}
}
return arm("PUT", path, token, "2023-09-01", body=body)
_run(main)
@@ -0,0 +1,84 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
ARM = "https://management.azure.com"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _token(cfg):
url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id", "")) + "/oauth2/v2.0/token"
form = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": str(cfg.get("client_id", "")),
"client_secret": str(cfg.get("client_secret", "")),
"scope": "https://management.azure.com/.default",
}).encode("utf-8")
req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
if not tok.get("access_token"):
raise Exception("Token request failed: " + json.dumps(tok))
return tok["access_token"]
def arm(method, path, token, api_version, body=None, params=None, full_url=None):
url = (full_url if full_url else ARM + path)
qp = {"api-version": api_version}
if params:
qp.update({k: v for k, v in params.items() if v not in (None, "")})
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(qp)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + 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)
sub = lambda cfg: str(cfg.get("subscription_id", ""))
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, token, inputs):
resource_group = inputs.get("resource_group")
if not resource_group:
raise Exception("resource_group is required")
nsg_name = inputs.get("nsg_name")
if not nsg_name:
raise Exception("nsg_name is required")
rule_name = inputs.get("rule_name")
if not rule_name:
raise Exception("rule_name is required")
path = ("/subscriptions/" + sub(cfg) + "/resourceGroups/" + q(resource_group) +
"/providers/Microsoft.Network/networkSecurityGroups/" + q(nsg_name) +
"/securityRules/" + q(rule_name))
result = arm("DELETE", path, token, "2023-09-01")
if not result:
return {"ok": True, "deleted": rule_name}
return result
_run(main)
@@ -0,0 +1,67 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
ARM = "https://management.azure.com"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _token(cfg):
url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id", "")) + "/oauth2/v2.0/token"
form = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": str(cfg.get("client_id", "")),
"client_secret": str(cfg.get("client_secret", "")),
"scope": "https://management.azure.com/.default",
}).encode("utf-8")
req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
if not tok.get("access_token"):
raise Exception("Token request failed: " + json.dumps(tok))
return tok["access_token"]
def arm(method, path, token, api_version, body=None, params=None, full_url=None):
url = (full_url if full_url else ARM + path)
qp = {"api-version": api_version}
if params:
qp.update({k: v for k, v in params.items() if v not in (None, "")})
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(qp)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + 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):
alert_id = inputs.get("alert_id")
if not alert_id:
raise Exception("alert_id is required")
return arm("GET", None, token, "2022-01-01", full_url=ARM + str(alert_id))
_run(main)
@@ -0,0 +1,76 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
ARM = "https://management.azure.com"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _token(cfg):
url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id", "")) + "/oauth2/v2.0/token"
form = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": str(cfg.get("client_id", "")),
"client_secret": str(cfg.get("client_secret", "")),
"scope": "https://management.azure.com/.default",
}).encode("utf-8")
req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
if not tok.get("access_token"):
raise Exception("Token request failed: " + json.dumps(tok))
return tok["access_token"]
def arm(method, path, token, api_version, body=None, params=None, full_url=None):
url = (full_url if full_url else ARM + path)
qp = {"api-version": api_version}
if params:
qp.update({k: v for k, v in params.items() if v not in (None, "")})
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(qp)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + 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)
sub = lambda cfg: str(cfg.get("subscription_id", ""))
q = lambda v: urllib.parse.quote(str(v), safe="")
def main(cfg, token, inputs):
resource_group = inputs.get("resource_group")
if not resource_group:
raise Exception("resource_group is required")
nsg_name = inputs.get("nsg_name")
if not nsg_name:
raise Exception("nsg_name is required")
path = ("/subscriptions/" + sub(cfg) + "/resourceGroups/" + q(resource_group) +
"/providers/Microsoft.Network/networkSecurityGroups/" + q(nsg_name))
return arm("GET", path, token, "2023-09-01")
_run(main)
@@ -0,0 +1,68 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
ARM = "https://management.azure.com"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _token(cfg):
url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id", "")) + "/oauth2/v2.0/token"
form = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": str(cfg.get("client_id", "")),
"client_secret": str(cfg.get("client_secret", "")),
"scope": "https://management.azure.com/.default",
}).encode("utf-8")
req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
if not tok.get("access_token"):
raise Exception("Token request failed: " + json.dumps(tok))
return tok["access_token"]
def arm(method, path, token, api_version, body=None, params=None, full_url=None):
url = (full_url if full_url else ARM + path)
qp = {"api-version": api_version}
if params:
qp.update({k: v for k, v in params.items() if v not in (None, "")})
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(qp)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + 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)
sub = lambda cfg: str(cfg.get("subscription_id", ""))
def main(cfg, token, inputs):
path = "/subscriptions/" + sub(cfg) + "/providers/Microsoft.Security/secureScores/ascScore"
return arm("GET", path, token, "2020-01-01")
_run(main)
@@ -0,0 +1,68 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
ARM = "https://management.azure.com"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _token(cfg):
url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id", "")) + "/oauth2/v2.0/token"
form = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": str(cfg.get("client_id", "")),
"client_secret": str(cfg.get("client_secret", "")),
"scope": "https://management.azure.com/.default",
}).encode("utf-8")
req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
if not tok.get("access_token"):
raise Exception("Token request failed: " + json.dumps(tok))
return tok["access_token"]
def arm(method, path, token, api_version, body=None, params=None, full_url=None):
url = (full_url if full_url else ARM + path)
qp = {"api-version": api_version}
if params:
qp.update({k: v for k, v in params.items() if v not in (None, "")})
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(qp)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + 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)
sub = lambda cfg: str(cfg.get("subscription_id", ""))
def main(cfg, token, inputs):
path = "/subscriptions/" + sub(cfg) + "/providers/Microsoft.Security/alerts"
return arm("GET", path, token, "2022-01-01")
_run(main)
@@ -0,0 +1,68 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
ARM = "https://management.azure.com"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _token(cfg):
url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id", "")) + "/oauth2/v2.0/token"
form = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": str(cfg.get("client_id", "")),
"client_secret": str(cfg.get("client_secret", "")),
"scope": "https://management.azure.com/.default",
}).encode("utf-8")
req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
if not tok.get("access_token"):
raise Exception("Token request failed: " + json.dumps(tok))
return tok["access_token"]
def arm(method, path, token, api_version, body=None, params=None, full_url=None):
url = (full_url if full_url else ARM + path)
qp = {"api-version": api_version}
if params:
qp.update({k: v for k, v in params.items() if v not in (None, "")})
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(qp)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + 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)
sub = lambda cfg: str(cfg.get("subscription_id", ""))
def main(cfg, token, inputs):
path = "/subscriptions/" + sub(cfg) + "/providers/Microsoft.Network/networkSecurityGroups"
return arm("GET", path, token, "2023-09-01")
_run(main)
@@ -0,0 +1,69 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
ARM = "https://management.azure.com"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _token(cfg):
url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id", "")) + "/oauth2/v2.0/token"
form = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": str(cfg.get("client_id", "")),
"client_secret": str(cfg.get("client_secret", "")),
"scope": "https://management.azure.com/.default",
}).encode("utf-8")
req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
if not tok.get("access_token"):
raise Exception("Token request failed: " + json.dumps(tok))
return tok["access_token"]
def arm(method, path, token, api_version, body=None, params=None, full_url=None):
url = (full_url if full_url else ARM + path)
qp = {"api-version": api_version}
if params:
qp.update({k: v for k, v in params.items() if v not in (None, "")})
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(qp)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + 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)
sub = lambda cfg: str(cfg.get("subscription_id", ""))
def main(cfg, token, inputs):
path = "/subscriptions/" + sub(cfg)
arm("GET", path, token, "2022-12-01")
return {"ok": True}
_run(main)
@@ -0,0 +1,78 @@
import json, os, sys, urllib.parse, urllib.request, urllib.error
ARM = "https://management.azure.com"
def _cfg():
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
def _inputs():
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
def _token(cfg):
url = "https://login.microsoftonline.com/" + str(cfg.get("tenant_id", "")) + "/oauth2/v2.0/token"
form = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": str(cfg.get("client_id", "")),
"client_secret": str(cfg.get("client_secret", "")),
"scope": "https://management.azure.com/.default",
}).encode("utf-8")
req = urllib.request.Request(url, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
with urllib.request.urlopen(req, timeout=60) as r:
tok = json.loads(r.read())
if not tok.get("access_token"):
raise Exception("Token request failed: " + json.dumps(tok))
return tok["access_token"]
def arm(method, path, token, api_version, body=None, params=None, full_url=None):
url = (full_url if full_url else ARM + path)
qp = {"api-version": api_version}
if params:
qp.update({k: v for k, v in params.items() if v not in (None, "")})
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(qp)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Authorization": "Bearer " + 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)
VALID_STATES = ("dismiss", "resolve", "activate", "inProgress")
def main(cfg, token, inputs):
alert_id = inputs.get("alert_id")
if not alert_id:
raise Exception("alert_id is required")
state = inputs.get("state")
if not state:
raise Exception("state is required")
if state not in VALID_STATES:
raise Exception("state must be one of: " + ", ".join(VALID_STATES))
result = arm("POST", None, token, "2022-01-01", body=None, full_url=ARM + str(alert_id) + "/" + state)
if not result:
return {"ok": True, "state": state}
return result
_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)

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