From 07fe4b50a77c9d1025d848e40bec84ecbb2c01ca Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Fri, 26 Jun 2026 17:22:22 +0200 Subject: [PATCH] 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 --- integrations/active-directory/manifest.yaml | 348 ++++++++++++++++++ .../scripts/ad_add_to_group.py | 240 ++++++++++++ .../scripts/ad_create_contact.py | 239 ++++++++++++ .../scripts/ad_create_group.py | 235 ++++++++++++ .../scripts/ad_create_user.py | 246 +++++++++++++ .../scripts/ad_delete_group.py | 227 ++++++++++++ .../scripts/ad_delete_user.py | 228 ++++++++++++ .../scripts/ad_disable_account.py | 229 ++++++++++++ .../scripts/ad_enable_account.py | 232 ++++++++++++ .../scripts/ad_expire_password.py | 227 ++++++++++++ .../scripts/ad_get_computer.py | 243 ++++++++++++ .../scripts/ad_get_group_members.py | 248 +++++++++++++ .../active-directory/scripts/ad_get_user.py | 254 +++++++++++++ .../scripts/ad_modify_computer_ou.py | 229 ++++++++++++ .../ad_modify_password_never_expire.py | 236 ++++++++++++ .../scripts/ad_modify_user_ou.py | 231 ++++++++++++ .../scripts/ad_remove_from_group.py | 237 ++++++++++++ .../active-directory/scripts/ad_search.py | 239 ++++++++++++ .../scripts/ad_set_new_password.py | 230 ++++++++++++ .../scripts/ad_test_credentials.py | 236 ++++++++++++ .../scripts/ad_unlock_account.py | 228 ++++++++++++ .../scripts/ad_update_contact.py | 226 ++++++++++++ .../scripts/ad_update_group.py | 227 ++++++++++++ .../scripts/ad_update_user.py | 227 ++++++++++++ .../scripts/test_connection.py | 229 ++++++++++++ 25 files changed, 5971 insertions(+) create mode 100644 integrations/active-directory/manifest.yaml create mode 100644 integrations/active-directory/scripts/ad_add_to_group.py create mode 100644 integrations/active-directory/scripts/ad_create_contact.py create mode 100644 integrations/active-directory/scripts/ad_create_group.py create mode 100644 integrations/active-directory/scripts/ad_create_user.py create mode 100644 integrations/active-directory/scripts/ad_delete_group.py create mode 100644 integrations/active-directory/scripts/ad_delete_user.py create mode 100644 integrations/active-directory/scripts/ad_disable_account.py create mode 100644 integrations/active-directory/scripts/ad_enable_account.py create mode 100644 integrations/active-directory/scripts/ad_expire_password.py create mode 100644 integrations/active-directory/scripts/ad_get_computer.py create mode 100644 integrations/active-directory/scripts/ad_get_group_members.py create mode 100644 integrations/active-directory/scripts/ad_get_user.py create mode 100644 integrations/active-directory/scripts/ad_modify_computer_ou.py create mode 100644 integrations/active-directory/scripts/ad_modify_password_never_expire.py create mode 100644 integrations/active-directory/scripts/ad_modify_user_ou.py create mode 100644 integrations/active-directory/scripts/ad_remove_from_group.py create mode 100644 integrations/active-directory/scripts/ad_search.py create mode 100644 integrations/active-directory/scripts/ad_set_new_password.py create mode 100644 integrations/active-directory/scripts/ad_test_credentials.py create mode 100644 integrations/active-directory/scripts/ad_unlock_account.py create mode 100644 integrations/active-directory/scripts/ad_update_contact.py create mode 100644 integrations/active-directory/scripts/ad_update_group.py create mode 100644 integrations/active-directory/scripts/ad_update_user.py create mode 100644 integrations/active-directory/scripts/test_connection.py diff --git a/integrations/active-directory/manifest.yaml b/integrations/active-directory/manifest.yaml new file mode 100644 index 0000000..839d0ff --- /dev/null +++ b/integrations/active-directory/manifest.yaml @@ -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: {} } diff --git a/integrations/active-directory/scripts/ad_add_to_group.py b/integrations/active-directory/scripts/ad_add_to_group.py new file mode 100644 index 0000000..3617169 --- /dev/null +++ b/integrations/active-directory/scripts/ad_add_to_group.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_create_contact.py b/integrations/active-directory/scripts/ad_create_contact.py new file mode 100644 index 0000000..8418c8c --- /dev/null +++ b/integrations/active-directory/scripts/ad_create_contact.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_create_group.py b/integrations/active-directory/scripts/ad_create_group.py new file mode 100644 index 0000000..43a78f1 --- /dev/null +++ b/integrations/active-directory/scripts/ad_create_group.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_create_user.py b/integrations/active-directory/scripts/ad_create_user.py new file mode 100644 index 0000000..6523942 --- /dev/null +++ b/integrations/active-directory/scripts/ad_create_user.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_delete_group.py b/integrations/active-directory/scripts/ad_delete_group.py new file mode 100644 index 0000000..c880863 --- /dev/null +++ b/integrations/active-directory/scripts/ad_delete_group.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_delete_user.py b/integrations/active-directory/scripts/ad_delete_user.py new file mode 100644 index 0000000..e265063 --- /dev/null +++ b/integrations/active-directory/scripts/ad_delete_user.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_disable_account.py b/integrations/active-directory/scripts/ad_disable_account.py new file mode 100644 index 0000000..22fbcdd --- /dev/null +++ b/integrations/active-directory/scripts/ad_disable_account.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_enable_account.py b/integrations/active-directory/scripts/ad_enable_account.py new file mode 100644 index 0000000..86bbb2b --- /dev/null +++ b/integrations/active-directory/scripts/ad_enable_account.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_expire_password.py b/integrations/active-directory/scripts/ad_expire_password.py new file mode 100644 index 0000000..3ce81eb --- /dev/null +++ b/integrations/active-directory/scripts/ad_expire_password.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_get_computer.py b/integrations/active-directory/scripts/ad_get_computer.py new file mode 100644 index 0000000..8157506 --- /dev/null +++ b/integrations/active-directory/scripts/ad_get_computer.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_get_group_members.py b/integrations/active-directory/scripts/ad_get_group_members.py new file mode 100644 index 0000000..173ba03 --- /dev/null +++ b/integrations/active-directory/scripts/ad_get_group_members.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_get_user.py b/integrations/active-directory/scripts/ad_get_user.py new file mode 100644 index 0000000..f887085 --- /dev/null +++ b/integrations/active-directory/scripts/ad_get_user.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_modify_computer_ou.py b/integrations/active-directory/scripts/ad_modify_computer_ou.py new file mode 100644 index 0000000..f7bda77 --- /dev/null +++ b/integrations/active-directory/scripts/ad_modify_computer_ou.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_modify_password_never_expire.py b/integrations/active-directory/scripts/ad_modify_password_never_expire.py new file mode 100644 index 0000000..d3cf3cf --- /dev/null +++ b/integrations/active-directory/scripts/ad_modify_password_never_expire.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_modify_user_ou.py b/integrations/active-directory/scripts/ad_modify_user_ou.py new file mode 100644 index 0000000..2c6a6fa --- /dev/null +++ b/integrations/active-directory/scripts/ad_modify_user_ou.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_remove_from_group.py b/integrations/active-directory/scripts/ad_remove_from_group.py new file mode 100644 index 0000000..8f62487 --- /dev/null +++ b/integrations/active-directory/scripts/ad_remove_from_group.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_search.py b/integrations/active-directory/scripts/ad_search.py new file mode 100644 index 0000000..86b49dd --- /dev/null +++ b/integrations/active-directory/scripts/ad_search.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_set_new_password.py b/integrations/active-directory/scripts/ad_set_new_password.py new file mode 100644 index 0000000..70563f7 --- /dev/null +++ b/integrations/active-directory/scripts/ad_set_new_password.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_test_credentials.py b/integrations/active-directory/scripts/ad_test_credentials.py new file mode 100644 index 0000000..d98d312 --- /dev/null +++ b/integrations/active-directory/scripts/ad_test_credentials.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_unlock_account.py b/integrations/active-directory/scripts/ad_unlock_account.py new file mode 100644 index 0000000..1d0ce82 --- /dev/null +++ b/integrations/active-directory/scripts/ad_unlock_account.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_update_contact.py b/integrations/active-directory/scripts/ad_update_contact.py new file mode 100644 index 0000000..f0a1955 --- /dev/null +++ b/integrations/active-directory/scripts/ad_update_contact.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_update_group.py b/integrations/active-directory/scripts/ad_update_group.py new file mode 100644 index 0000000..e9d5585 --- /dev/null +++ b/integrations/active-directory/scripts/ad_update_group.py @@ -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 diff --git a/integrations/active-directory/scripts/ad_update_user.py b/integrations/active-directory/scripts/ad_update_user.py new file mode 100644 index 0000000..f62a45c --- /dev/null +++ b/integrations/active-directory/scripts/ad_update_user.py @@ -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 diff --git a/integrations/active-directory/scripts/test_connection.py b/integrations/active-directory/scripts/test_connection.py new file mode 100644 index 0000000..599f7f0 --- /dev/null +++ b/integrations/active-directory/scripts/test_connection.py @@ -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