diff --git a/changelog/fragments/1764010000-translate-ldap-guid-inference.yaml b/changelog/fragments/1764010000-translate-ldap-guid-inference.yaml new file mode 100644 index 000000000000..10aabe317052 --- /dev/null +++ b/changelog/fragments/1764010000-translate-ldap-guid-inference.yaml @@ -0,0 +1,45 @@ +# REQUIRED +# Kind can be one of: +# - breaking-change: a change to previously-documented behavior +# - deprecation: functionality that is being removed in a later release +# - bug-fix: fixes a problem in a previous version +# - enhancement: extends functionality but does not break or fix existing behavior +# - feature: new functionality +# - known-issue: problems that we are aware of in a given version +# - security: impacts on the security of a product or a user’s deployment. +# - upgrade: important information for someone upgrading from a prior version +# - other: does not fit into any of the other categories +kind: enhancement + +# REQUIRED for all kinds +# Change summary; a 80ish characters long description of the change. +summary: Add GUID translation, base DN inference, and SSPI authentication to LDAP processor. + +# REQUIRED for breaking-change, deprecation, known-issue +# Long description; in case the summary is not enough to describe the change +# this field accommodate a description without length limits. +# description: + +# REQUIRED for breaking-change, deprecation, known-issue +# impact: + +# REQUIRED for breaking-change, deprecation, known-issue +# action: + +# REQUIRED for all kinds +# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc. +component: all + +# AUTOMATED +# OPTIONAL to manually add other PR URLs +# PR URL: A link the PR that added the changeset. +# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added. +# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number. +# Please provide it if you are adding a fragment for a different PR. +pr: https://github.com/elastic/beats/pull/47827 + +# AUTOMATED +# OPTIONAL to manually add other issue URLs +# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of). +# If not present is automatically filled by the tooling with the issue linked to the PR number. +# issue: https://github.com/owner/repo/1234 diff --git a/docs/reference/auditbeat/processor-translate-guid.md b/docs/reference/auditbeat/processor-translate-guid.md index d5a4514f4e86..420749ed35cf 100644 --- a/docs/reference/auditbeat/processor-translate-guid.md +++ b/docs/reference/auditbeat/processor-translate-guid.md @@ -6,27 +6,27 @@ applies_to: stack: ga --- -# Translate GUID [processor-translate-guid] +# Translate LDAP Attribute [processor-translate-ldap-attribute] +The `translate_ldap_attribute` processor translates LDAP attributes into friendlier values. The typical use case is converting an Active Directory Global Unique Identifier (GUID) into a human-readable name (for example the object's `cn`). -The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. It is typically used to translate AD Global Unique Identifiers (GUID) into their common names. +Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object's name and these values sometimes appear in logs. -Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object’s name and these values sometimes appear in logs. - -If the search attribute is invalid (malformed) or does not map to any object on the domain then this will result in the processor returning an error unless `ignore_failure` is set. +If the search attribute is invalid (malformed) or does not map to any object on the domain the processor returns an error unless `ignore_failure` is set. The result of this operation is an array of values, given that a single attribute can hold multiple values. -Note: the search attribute is expected to map to a single object. If it doesn’t, no error will be returned, but only results of the first entry will be added to the event. +The search attribute is expected to map to a single object. If multiple entries match, only the first entry's mapped attribute values are returned. ```yaml processors: - translate_ldap_attribute: field: winlog.event_data.ObjectGuid - ldap_address: "ldap://" - ldap_base_dn: "dc=example,dc=com" ignore_missing: true ignore_failure: true + # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints + # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted + # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used ``` The `translate_ldap_attribute` processor has the following configuration settings: @@ -35,18 +35,40 @@ The `translate_ldap_attribute` processor has the following configuration setting | --- | --- | --- | --- | | `field` | yes | | Source field containing a GUID. | | `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. | -| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` | -| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` | -| `ldap_bind_user` | no | | LDAP user. | -| `ldap_bind_password` | no | | LDAP password. | +| `ldap_domain` | no | | {applies_to}`stack: ga 9.2.4` DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). | +| `ldap_address` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. | +| `ldap_base_dn` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. | +| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (useful for servers that expect a bind DN even for anonymous operations). | +| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service or user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms immediately use an unauthenticated bind. | | `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. | | `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. | | `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. | -| `ldap_ssl`\* | no | 30 | LDAP TLS/SSL connection settings. | +| `ldap_ssl` | no | {applies_to}`stack: ga 9.2.4` no default
{applies_to}`stack: ga 9.0.0` `30` | LDAP TLS/SSL connection settings. Refer to [SSL](/reference/auditbeat/configuration-ssl.md). | +| `ad_guid_translation` | no | `auto` | {applies_to}`stack: ga 9.2.4` Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. | | `ignore_missing` | no | false | Ignore errors when the source field is missing. | | `ignore_failure` | no | false | Ignore all errors produced by the processor. | -\* Also see [SSL](/reference/auditbeat/configuration-ssl.md) for a full description of the `ldap_ssl` options. +## Authentication flow + +Beats attempts LDAP authentication in the following order: + +1. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied. +2. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System, domain-joined services, and gMSA accounts. Other platforms do not yet implement automatic authentication. +3. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously. + +Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates. + +## Server auto-discovery + +When `ldap_address` is omitted Beats resolves controllers dynamically: + +1. **Domain discovery.** Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, the TCP/IP and Kerberos registry keys, and the machine's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped. +2. **DNS SRV queries.** When a domain is known Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs. +3. **Windows LOGONSERVER fallback.** If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid. + +Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed. + +When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly. If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing: @@ -78,4 +100,3 @@ processors: key_field: winlog.event_data.ObjectGuid value_field: winlog.common_name ``` - diff --git a/docs/reference/filebeat/processor-translate-guid.md b/docs/reference/filebeat/processor-translate-guid.md index 89af9d4c963b..ebd7cf6165b3 100644 --- a/docs/reference/filebeat/processor-translate-guid.md +++ b/docs/reference/filebeat/processor-translate-guid.md @@ -6,27 +6,27 @@ applies_to: stack: ga --- -# Translate GUID [processor-translate-guid] +# Translate LDAP Attribute [processor-translate-ldap-attribute] +The `translate_ldap_attribute` processor translates LDAP attributes into friendlier values. The typical use case is converting an Active Directory Global Unique Identifier (GUID) into a human-readable name (for example the object's `cn`). -The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. It is typically used to translate AD Global Unique Identifiers (GUID) into their common names. +Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object's name and these values sometimes appear in logs. -Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object’s name and these values sometimes appear in logs. - -If the search attribute is invalid (malformed) or does not map to any object on the domain then this will result in the processor returning an error unless `ignore_failure` is set. +If the search attribute is invalid (malformed) or does not map to any object on the domain the processor returns an error unless `ignore_failure` is set. The result of this operation is an array of values, given that a single attribute can hold multiple values. -Note: the search attribute is expected to map to a single object. If it doesn’t, no error will be returned, but only results of the first entry will be added to the event. +The search attribute is expected to map to a single object. If multiple entries match, only the first entry's mapped attribute values are returned. ```yaml processors: - translate_ldap_attribute: field: winlog.event_data.ObjectGuid - ldap_address: "ldap://" - ldap_base_dn: "dc=example,dc=com" ignore_missing: true ignore_failure: true + # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints + # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted + # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used ``` The `translate_ldap_attribute` processor has the following configuration settings: @@ -35,18 +35,40 @@ The `translate_ldap_attribute` processor has the following configuration setting | --- | --- | --- | --- | | `field` | yes | | Source field containing a GUID. | | `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. | -| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` | -| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` | -| `ldap_bind_user` | no | | LDAP user. | -| `ldap_bind_password` | no | | LDAP password. | +| `ldap_domain` | no | | {applies_to}`stack: ga 9.2.4` DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). | +| `ldap_address` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. | +| `ldap_base_dn` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. | +| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (useful for servers that expect a bind DN even for anonymous operations). | +| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service or user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms immediately use an unauthenticated bind. | | `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. | | `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. | | `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. | -| `ldap_ssl`\* | no | 30 | LDAP TLS/SSL connection settings. | +| `ldap_ssl` | no | {applies_to}`stack: ga 9.2.4` no default
{applies_to}`stack: ga 9.0.0` `30` | LDAP TLS/SSL connection settings. Refer to [SSL](/reference/filebeat/configuration-ssl.md). | +| `ad_guid_translation` | no | `auto` | {applies_to}`stack: ga 9.2.4` Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. | | `ignore_missing` | no | false | Ignore errors when the source field is missing. | | `ignore_failure` | no | false | Ignore all errors produced by the processor. | -\* Also see [SSL](/reference/filebeat/configuration-ssl.md) for a full description of the `ldap_ssl` options. +## Authentication flow + +Beats attempts LDAP authentication in the following order: + +1. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied. +2. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System, domain-joined services, and gMSA accounts. Other platforms do not yet implement automatic authentication. +3. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously. + +Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates. + +## Server auto-discovery + +When `ldap_address` is omitted Beats resolves controllers dynamically: + +1. **Domain discovery.** Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, the TCP/IP and Kerberos registry keys, and the machine's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped. +2. **DNS SRV queries.** When a domain is known Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs. +3. **Windows LOGONSERVER fallback.** If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid. + +Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed. + +When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly. If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing: @@ -78,4 +100,3 @@ processors: key_field: winlog.event_data.ObjectGuid value_field: winlog.common_name ``` - diff --git a/docs/reference/heartbeat/processor-translate-guid.md b/docs/reference/heartbeat/processor-translate-guid.md index 5984145c89f3..50d9527b719c 100644 --- a/docs/reference/heartbeat/processor-translate-guid.md +++ b/docs/reference/heartbeat/processor-translate-guid.md @@ -6,27 +6,27 @@ applies_to: stack: ga --- -# Translate GUID [processor-translate-guid] +# Translate LDAP Attribute [processor-translate-ldap-attribute] +The `translate_ldap_attribute` processor translates LDAP attributes into friendlier values. The typical use case is converting an Active Directory Global Unique Identifier (GUID) into a human-readable name (for example the object's `cn`). -The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. It is typically used to translate AD Global Unique Identifiers (GUID) into their common names. +Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object's name and these values sometimes appear in logs. -Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object’s name and these values sometimes appear in logs. - -If the search attribute is invalid (malformed) or does not map to any object on the domain then this will result in the processor returning an error unless `ignore_failure` is set. +If the search attribute is invalid (malformed) or does not map to any object on the domain the processor returns an error unless `ignore_failure` is set. The result of this operation is an array of values, given that a single attribute can hold multiple values. -Note: the search attribute is expected to map to a single object. If it doesn’t, no error will be returned, but only results of the first entry will be added to the event. +The search attribute is expected to map to a single object. If multiple entries match, only the first entry's mapped attribute values are returned. ```yaml processors: - translate_ldap_attribute: field: winlog.event_data.ObjectGuid - ldap_address: "ldap://" - ldap_base_dn: "dc=example,dc=com" ignore_missing: true ignore_failure: true + # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints + # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted + # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used ``` The `translate_ldap_attribute` processor has the following configuration settings: @@ -35,18 +35,40 @@ The `translate_ldap_attribute` processor has the following configuration setting | --- | --- | --- | --- | | `field` | yes | | Source field containing a GUID. | | `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. | -| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` | -| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` | -| `ldap_bind_user` | no | | LDAP user. | -| `ldap_bind_password` | no | | LDAP password. | +| `ldap_domain` | no | | {applies_to}`stack: ga 9.2.4` DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). | +| `ldap_address` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. | +| `ldap_base_dn` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. | +| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (useful for servers that expect a bind DN even for anonymous operations). | +| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service or user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms immediately use an unauthenticated bind. | | `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. | | `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. | | `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. | -| `ldap_ssl`\* | no | 30 | LDAP TLS/SSL connection settings. | +| `ldap_ssl` | no | {applies_to}`stack: ga 9.2.4` no default
{applies_to}`stack: ga 9.0.0` `30` | LDAP TLS/SSL connection settings. Refer to [SSL](/reference/heartbeat/configuration-ssl.md). | +| `ad_guid_translation` | no | `auto` | {applies_to}`stack: ga 9.2.4` Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. | | `ignore_missing` | no | false | Ignore errors when the source field is missing. | | `ignore_failure` | no | false | Ignore all errors produced by the processor. | -\* Also see [SSL](/reference/heartbeat/configuration-ssl.md) for a full description of the `ldap_ssl` options. +## Authentication flow + +Beats attempts LDAP authentication in the following order: + +1. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied. +2. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System, domain-joined services, and gMSA accounts. Other platforms do not yet implement automatic authentication. +3. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously. + +Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates. + +## Server auto-discovery + +When `ldap_address` is omitted Beats resolves controllers dynamically: + +1. **Domain discovery.** Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, the TCP/IP and Kerberos registry keys, and the machine's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped. +2. **DNS SRV queries.** When a domain is known Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs. +3. **Windows LOGONSERVER fallback.** If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid. + +Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed. + +When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly. If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing: @@ -78,4 +100,3 @@ processors: key_field: winlog.event_data.ObjectGuid value_field: winlog.common_name ``` - diff --git a/docs/reference/metricbeat/processor-translate-guid.md b/docs/reference/metricbeat/processor-translate-guid.md index 6eef946fe5d2..63e0427f84a0 100644 --- a/docs/reference/metricbeat/processor-translate-guid.md +++ b/docs/reference/metricbeat/processor-translate-guid.md @@ -6,27 +6,27 @@ applies_to: stack: ga --- -# Translate GUID [processor-translate-guid] +# Translate LDAP Attribute [processor-translate-ldap-attribute] +The `translate_ldap_attribute` processor translates LDAP attributes into friendlier values. The typical use case is converting an Active Directory Global Unique Identifier (GUID) into a human-readable name (for example the object's `cn`). -The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. It is typically used to translate AD Global Unique Identifiers (GUID) into their common names. +Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object's name and these values sometimes appear in logs. -Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object’s name and these values sometimes appear in logs. - -If the search attribute is invalid (malformed) or does not map to any object on the domain then this will result in the processor returning an error unless `ignore_failure` is set. +If the search attribute is invalid (malformed) or does not map to any object on the domain the processor returns an error unless `ignore_failure` is set. The result of this operation is an array of values, given that a single attribute can hold multiple values. -Note: the search attribute is expected to map to a single object. If it doesn’t, no error will be returned, but only results of the first entry will be added to the event. +The search attribute is expected to map to a single object. If multiple entries match, only the first entry's mapped attribute values are returned. ```yaml processors: - translate_ldap_attribute: field: winlog.event_data.ObjectGuid - ldap_address: "ldap://" - ldap_base_dn: "dc=example,dc=com" ignore_missing: true ignore_failure: true + # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints + # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted + # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used ``` The `translate_ldap_attribute` processor has the following configuration settings: @@ -35,18 +35,40 @@ The `translate_ldap_attribute` processor has the following configuration setting | --- | --- | --- | --- | | `field` | yes | | Source field containing a GUID. | | `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. | -| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` | -| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` | -| `ldap_bind_user` | no | | LDAP user. | -| `ldap_bind_password` | no | | LDAP password. | +| `ldap_domain` | no | | {applies_to}`stack: ga 9.2.4` DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). | +| `ldap_address` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. | +| `ldap_base_dn` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. | +| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (useful for servers that expect a bind DN even for anonymous operations). | +| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service or user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms immediately use an unauthenticated bind. | | `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. | | `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. | | `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. | -| `ldap_ssl`\* | no | 30 | LDAP TLS/SSL connection settings. | +| `ldap_ssl` | no | {applies_to}`stack: ga 9.2.4` no default
{applies_to}`stack: ga 9.0.0` `30` | LDAP TLS/SSL connection settings. Refer to [SSL](/reference/metricbeat/configuration-ssl.md). | +| `ad_guid_translation` | no | `auto` | {applies_to}`stack: ga 9.2.4` Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. | | `ignore_missing` | no | false | Ignore errors when the source field is missing. | | `ignore_failure` | no | false | Ignore all errors produced by the processor. | -\* Also see [SSL](/reference/metricbeat/configuration-ssl.md) for a full description of the `ldap_ssl` options. +## Authentication flow + +Beats attempts LDAP authentication in the following order: + +1. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied. +2. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System, domain-joined services, and gMSA accounts. Other platforms do not yet implement automatic authentication. +3. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously. + +Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates. + +## Server auto-discovery + +When `ldap_address` is omitted Beats resolves controllers dynamically: + +1. **Domain discovery.** Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, the TCP/IP and Kerberos registry keys, and the machine's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped. +2. **DNS SRV queries.** When a domain is known Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs. +3. **Windows LOGONSERVER fallback.** If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid. + +Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed. + +When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly. If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing: @@ -78,4 +100,3 @@ processors: key_field: winlog.event_data.ObjectGuid value_field: winlog.common_name ``` - diff --git a/docs/reference/packetbeat/processor-translate-guid.md b/docs/reference/packetbeat/processor-translate-guid.md index 6ea74e97af5b..92e7e39ce8fd 100644 --- a/docs/reference/packetbeat/processor-translate-guid.md +++ b/docs/reference/packetbeat/processor-translate-guid.md @@ -6,27 +6,27 @@ applies_to: stack: ga --- -# Translate GUID [processor-translate-guid] +# Translate LDAP Attribute [processor-translate-ldap-attribute] +The `translate_ldap_attribute` processor translates LDAP attributes into friendlier values. The typical use case is converting an Active Directory Global Unique Identifier (GUID) into a human-readable name (for example the object's `cn`). -The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. It is typically used to translate AD Global Unique Identifiers (GUID) into their common names. +Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object's name and these values sometimes appear in logs. -Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object’s name and these values sometimes appear in logs. - -If the search attribute is invalid (malformed) or does not map to any object on the domain then this will result in the processor returning an error unless `ignore_failure` is set. +If the search attribute is invalid (malformed) or does not map to any object on the domain the processor returns an error unless `ignore_failure` is set. The result of this operation is an array of values, given that a single attribute can hold multiple values. -Note: the search attribute is expected to map to a single object. If it doesn’t, no error will be returned, but only results of the first entry will be added to the event. +The search attribute is expected to map to a single object. If multiple entries match, only the first entry's mapped attribute values are returned. ```yaml processors: - translate_ldap_attribute: field: winlog.event_data.ObjectGuid - ldap_address: "ldap://" - ldap_base_dn: "dc=example,dc=com" ignore_missing: true ignore_failure: true + # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints + # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted + # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used ``` The `translate_ldap_attribute` processor has the following configuration settings: @@ -35,18 +35,40 @@ The `translate_ldap_attribute` processor has the following configuration setting | --- | --- | --- | --- | | `field` | yes | | Source field containing a GUID. | | `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. | -| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` | -| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` | -| `ldap_bind_user` | no | | LDAP user. | -| `ldap_bind_password` | no | | LDAP password. | +| `ldap_domain` | no | | {applies_to}`stack: ga 9.2.4` DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). | +| `ldap_address` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. | +| `ldap_base_dn` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. | +| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (useful for servers that expect a bind DN even for anonymous operations). | +| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service or user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms immediately use an unauthenticated bind. | | `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. | | `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. | | `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. | -| `ldap_ssl`\* | no | 30 | LDAP TLS/SSL connection settings. | +| `ldap_ssl` | no | {applies_to}`stack: ga 9.2.4` no default
{applies_to}`stack: ga 9.0.0` `30` | LDAP TLS/SSL connection settings. Refer to [SSL](/reference/packetbeat/configuration-ssl.md). | +| `ad_guid_translation` | no | `auto` | {applies_to}`stack: ga 9.2.4` Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. | | `ignore_missing` | no | false | Ignore errors when the source field is missing. | | `ignore_failure` | no | false | Ignore all errors produced by the processor. | -\* Also see [SSL](/reference/packetbeat/configuration-ssl.md) for a full description of the `ldap_ssl` options. +## Authentication flow + +Beats attempts LDAP authentication in the following order: + +1. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied. +2. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System, domain-joined services, and gMSA accounts. Other platforms do not yet implement automatic authentication. +3. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously. + +Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates. + +## Server auto-discovery + +When `ldap_address` is omitted Beats resolves controllers dynamically: + +1. **Domain discovery.** Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, the TCP/IP and Kerberos registry keys, and the machine's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped. +2. **DNS SRV queries.** When a domain is known Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs. +3. **Windows LOGONSERVER fallback.** If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid. + +Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed. + +When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly. If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing: @@ -78,4 +100,3 @@ processors: key_field: winlog.event_data.ObjectGuid value_field: winlog.common_name ``` - diff --git a/docs/reference/winlogbeat/processor-translate-guid.md b/docs/reference/winlogbeat/processor-translate-guid.md index fe7c1fc483eb..261939ca7668 100644 --- a/docs/reference/winlogbeat/processor-translate-guid.md +++ b/docs/reference/winlogbeat/processor-translate-guid.md @@ -6,27 +6,27 @@ applies_to: stack: ga --- -# Translate GUID [processor-translate-guid] +# Translate LDAP Attribute [processor-translate-ldap-attribute] +The `translate_ldap_attribute` processor translates LDAP attributes into friendlier values. The typical use case is converting an Active Directory Global Unique Identifier (GUID) into a human-readable name (for example the object's `cn`). -The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. It is typically used to translate AD Global Unique Identifiers (GUID) into their common names. +Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object's name and these values sometimes appear in logs. -Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID’s rather than the object’s name and these values sometimes appear in logs. - -If the search attribute is invalid (malformed) or does not map to any object on the domain then this will result in the processor returning an error unless `ignore_failure` is set. +If the search attribute is invalid (malformed) or does not map to any object on the domain the processor returns an error unless `ignore_failure` is set. The result of this operation is an array of values, given that a single attribute can hold multiple values. -Note: the search attribute is expected to map to a single object. If it doesn’t, no error will be returned, but only results of the first entry will be added to the event. +The search attribute is expected to map to a single object. If multiple entries match, only the first entry's mapped attribute values are returned. ```yaml processors: - translate_ldap_attribute: field: winlog.event_data.ObjectGuid - ldap_address: "ldap://" - ldap_base_dn: "dc=example,dc=com" ignore_missing: true ignore_failure: true + # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints + # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted + # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used ``` The `translate_ldap_attribute` processor has the following configuration settings: @@ -35,18 +35,40 @@ The `translate_ldap_attribute` processor has the following configuration setting | --- | --- | --- | --- | | `field` | yes | | Source field containing a GUID. | | `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. | -| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` | -| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` | -| `ldap_bind_user` | no | | LDAP user. | -| `ldap_bind_password` | no | | LDAP password. | +| `ldap_domain` | no | | {applies_to}`stack: ga 9.2.4` DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). | +| `ldap_address` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. | +| `ldap_base_dn` | {applies_to}`stack: ga 9.2.4` no
{applies_to}`stack: ga 9.0.0` yes | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. | +| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (useful for servers that expect a bind DN even for anonymous operations). | +| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service or user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms immediately use an unauthenticated bind. | | `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. | | `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. | | `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. | -| `ldap_ssl`\* | no | 30 | LDAP TLS/SSL connection settings. | +| `ldap_ssl` | no | {applies_to}`stack: ga 9.2.4` no default
{applies_to}`stack: ga 9.0.0` `30` | LDAP TLS/SSL connection settings. Refer to [SSL](/reference/winlogbeat/configuration-ssl.md). | +| `ad_guid_translation` | no | `auto` | {applies_to}`stack: ga 9.2.4` Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. | | `ignore_missing` | no | false | Ignore errors when the source field is missing. | | `ignore_failure` | no | false | Ignore all errors produced by the processor. | -\* Also see [SSL](/reference/winlogbeat/configuration-ssl.md) for a full description of the `ldap_ssl` options. +## Authentication flow + +Beats attempts LDAP authentication in the following order: + +1. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied. +2. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System, domain-joined services, and gMSA accounts. Other platforms do not yet implement automatic authentication. +3. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously. + +Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates. + +## Server auto-discovery + +When `ldap_address` is omitted Beats resolves controllers dynamically: + +1. **Domain discovery.** Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, the TCP/IP and Kerberos registry keys, and the machine's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped. +2. **DNS SRV queries.** When a domain is known Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs. +3. **Windows LOGONSERVER fallback.** If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid. + +Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed. + +When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly. If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing: @@ -78,4 +100,3 @@ processors: key_field: winlog.event_data.ObjectGuid value_field: winlog.common_name ``` - diff --git a/go.mod b/go.mod index e76588d21136..f8d97241f4c8 100644 --- a/go.mod +++ b/go.mod @@ -286,6 +286,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect github.com/VictoriaMetrics/easyproto v0.1.4 // indirect + github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apache/arrow/go/v15 v15.0.2 // indirect diff --git a/libbeat/processors/translate_ldap_attribute/config.go b/libbeat/processors/translate_ldap_attribute/config.go index 4a669f582de2..ce59e5b4da02 100644 --- a/libbeat/processors/translate_ldap_attribute/config.go +++ b/libbeat/processors/translate_ldap_attribute/config.go @@ -20,14 +20,24 @@ package translate_ldap_attribute import ( + "fmt" + "strings" + "github.com/elastic/elastic-agent-libs/transport/tlscommon" ) +const ( + guidTranslationAuto = "auto" + guidTranslationAlways = "always" + guidTranslationNever = "never" +) + type config struct { Field string `config:"field" validate:"required"` TargetField string `config:"target_field"` - LDAPAddress string `config:"ldap_address" validate:"required"` - LDAPBaseDN string `config:"ldap_base_dn" validate:"required"` + LDAPDomain string `config:"ldap_domain"` + LDAPAddress string `config:"ldap_address"` + LDAPBaseDN string `config:"ldap_base_dn"` LDAPBindUser string `config:"ldap_bind_user"` LDAPBindPassword string `config:"ldap_bind_password"` LDAPSearchAttribute string `config:"ldap_search_attribute" validate:"required"` @@ -35,6 +45,16 @@ type config struct { LDAPSearchTimeLimit int `config:"ldap_search_time_limit"` LDAPTLS *tlscommon.Config `config:"ldap_ssl"` + // ADGUIDTranslation controls when GUID values get converted to the binary form + // expected by Active Directory. We no longer rely on server detection; the + // auto mode simply checks whether the configured search attribute is named + // objectGUID (case-insensitive). + // Supported values: + // "auto" (default): Convert when LDAP search attribute equals objectGUID + // "always": Always apply GUID conversion regardless of attribute name + // "never" : Never apply GUID conversion + ADGUIDTranslation string `config:"ad_guid_translation"` + IgnoreMissing bool `config:"ignore_missing"` IgnoreFailure bool `config:"ignore_failure"` } @@ -43,5 +63,21 @@ func defaultConfig() config { return config{ LDAPSearchAttribute: "objectGUID", LDAPMappedAttribute: "cn", - LDAPSearchTimeLimit: 30} + LDAPSearchTimeLimit: 30, + ADGUIDTranslation: guidTranslationAuto, + } +} + +func (c *config) validate() error { + switch strings.ToLower(strings.TrimSpace(c.ADGUIDTranslation)) { + case "", guidTranslationAuto: + c.ADGUIDTranslation = guidTranslationAuto + case guidTranslationAlways: + c.ADGUIDTranslation = guidTranslationAlways + case guidTranslationNever: + c.ADGUIDTranslation = guidTranslationNever + default: + return fmt.Errorf("invalid ad_guid_translation value %q (expected auto|always|never)", c.ADGUIDTranslation) + } + return nil } diff --git a/libbeat/processors/translate_ldap_attribute/config_test.go b/libbeat/processors/translate_ldap_attribute/config_test.go new file mode 100644 index 000000000000..40be593ed042 --- /dev/null +++ b/libbeat/processors/translate_ldap_attribute/config_test.go @@ -0,0 +1,57 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//go:build !requirefips + +package translate_ldap_attribute + +import "testing" + +func TestConfigValidate(t *testing.T) { + tests := []struct { + name string + value string + expect string + expectError bool + }{ + {name: "empty defaults to auto", value: "", expect: guidTranslationAuto}, + {name: "explicit auto", value: "auto", expect: guidTranslationAuto}, + {name: "explicit always", value: "always", expect: guidTranslationAlways}, + {name: "case insensitive", value: " NEVER ", expect: guidTranslationNever}, + {name: "invalid", value: "sometimes", expectError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := defaultConfig() + cfg.ADGUIDTranslation = tt.value + err := cfg.validate() + if tt.expectError { + if err == nil { + t.Fatalf("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.ADGUIDTranslation != tt.expect { + t.Fatalf("expected %q, got %q", tt.expect, cfg.ADGUIDTranslation) + } + }) + } +} diff --git a/libbeat/processors/translate_ldap_attribute/discovery.go b/libbeat/processors/translate_ldap_attribute/discovery.go new file mode 100644 index 000000000000..0244101b9086 --- /dev/null +++ b/libbeat/processors/translate_ldap_attribute/discovery.go @@ -0,0 +1,180 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//go:build !requirefips + +package translate_ldap_attribute + +import ( + "cmp" + "errors" + "fmt" + "net" + "os" + "runtime" + "slices" + "strings" + + "github.com/elastic/elastic-agent-libs/logp" +) + +var ( + // errNoLDAPServerFound is returned when no LDAP server can be discovered + errNoLDAPServerFound = errors.New("no LDAP server found via DNS SRV or system configuration") +) + +// discoverLDAPAddress attempts to auto-discover the LDAP server address. +// It returns a list of candidate addresses sorted by preference (LDAPS over LDAP, SRV over LOGONSERVER). +// The caller should attempt to connect to each address in order until one succeeds. +func discoverLDAPAddress(configDomain string, log *logp.Logger) ([]string, error) { + log.Debug("attempting LDAP server auto-discovery") + + domain := discoverDomain(configDomain, log) + + var candidates []string + + if domain != "" { + // 1. Primary: DNS SRV Lookup (LDAPS, then LDAP) + candidates = append(candidates, lookupSRVServers(domain, true, log)...) + candidates = append(candidates, lookupSRVServers(domain, false, log)...) + } + + if len(candidates) > 0 { + return candidates, nil + } + + // 2. Fallback: LOGONSERVER environment variable, + // typically only available on Windows interactive sessions + log.Debug("attempting discovery via LOGONSERVER environment variable") + candidates = append(candidates, findLogonServer(domain, true, log)...) + candidates = append(candidates, findLogonServer(domain, false, log)...) + + if len(candidates) == 0 { + log.Warnw("no LDAP servers discovered", "dns_srv_attempted", true, "logonserver_attempted", runtime.GOOS == "windows") + return nil, errNoLDAPServerFound + } + + log.Infow("LDAP server auto-discovery completed", "total_candidates", len(candidates), "candidates", candidates) + return candidates, nil +} + +func discoverDomain(configDomain string, log *logp.Logger) string { + if configDomain != "" { + return normalizeDomain(configDomain) + } + d, err := discoverDomainInPlatform() + if err != nil { + log.Warnw("failed to discover domain in platform", "error", err) + return "" + } + log.Infow("discovered domain in platform", "domain", d) + return normalizeDomain(d) +} + +func normalizeDomain(domain string) string { + return strings.ToLower(strings.TrimSpace(domain)) +} + +func getDomainHostname() (string, error) { + h, err := os.Hostname() + if err != nil { + return "", err + } + parts := strings.Split(h, ".") + if len(parts) > 1 { + return strings.Join(parts[1:], "."), nil + } + return "", fmt.Errorf("not FQDN") +} + +func lookupSRVServers(domain string, useTLS bool, log *logp.Logger) []string { + service := "ldap" + scheme := "ldap" + if useTLS { + service = "ldaps" + scheme = "ldaps" + } + + log.Infow("executing DNS SRV lookup", "domain", domain, "service", service) + _, records, err := net.LookupSRV(service, "tcp", domain) + if err != nil || len(records) == 0 { + log.Debugw("DNS SRV lookup failed", "domain", domain, "error", err) + return nil + } + log.Infow("DNS SRV lookup succeeded", "domain", domain, "record_count", len(records)) + + // Even if the DNS server *usually* sorts them, we enforce it here + // to ensure we don't accidentally hit a DR site first. + slices.SortFunc(records, func(a, b *net.SRV) int { + // 1. Lower Priority is better (RFC 2782) + if c := cmp.Compare(a.Priority, b.Priority); c != 0 { + return c + } + // 2. Higher Weight is better (RFC 2782) + return cmp.Compare(b.Weight, a.Weight) + }) + + var addresses []string + for _, addr := range records { + target := strings.TrimSuffix(addr.Target, ".") + addresses = append(addresses, fmt.Sprintf("%s://%s:%d", scheme, target, addr.Port)) + } + log.Infow("discovered servers via DNS SRV", "scheme", scheme, "domain", domain, "count", len(addresses), "addresses", addresses) + return addresses +} + +// findLogonServer attempts to construct a valid FQDN from the LOGONSERVER env var. +// It requires the previously discovered domain to ensure TLS validation works. +func findLogonServer(domain string, useTLS bool, log *logp.Logger) []string { + logonServer := os.Getenv("LOGONSERVER") + if logonServer == "" { + log.Debug("LOGONSERVER environment variable not set") + return nil + } + + // 1. Sanitize: Remove leading backslashes (Windows format: \\SERVERNAME) + serverName := strings.TrimPrefix(logonServer, `\\`) + if serverName == "" { + return nil + } + + scheme := "ldap" + port := 389 + if useTLS { + scheme = "ldaps" + port = 636 + } + + var addresses []string + + // 2. Option A: The FQDN (Best for TLS) + // If we have a domain, and the serverName isn't already fully qualified, join them. + if domain != "" && !strings.Contains(serverName, ".") { + fqdn := fmt.Sprintf("%s.%s", serverName, domain) + log.Debugw("constructed FQDN from LOGONSERVER", "original", serverName, "fqdn", fqdn) + // Return FQDN first - this has the highest chance of passing TLS checks + addresses = append(addresses, fmt.Sprintf("%s://%s:%d", scheme, fqdn, port)) + } + + // 3. Option B: The NetBIOS Name (Fallback) + // We add this just in case the FQDN construction was wrong, + // though this will likely fail TLS validation unless InsecureSkipVerify is used. + addresses = append(addresses, fmt.Sprintf("%s://%s:%d", scheme, serverName, port)) + + log.Infow("discovered server via LOGONSERVER", "addresses", addresses) + return addresses +} diff --git a/libbeat/processors/translate_ldap_attribute/discovery_other.go b/libbeat/processors/translate_ldap_attribute/discovery_other.go new file mode 100644 index 000000000000..045d29953d99 --- /dev/null +++ b/libbeat/processors/translate_ldap_attribute/discovery_other.go @@ -0,0 +1,75 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//go:build !windows && !requirefips + +package translate_ldap_attribute + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// discoverDomainInPlatform Chain: Resolv.conf -> Hostname -> Krb5.conf +func discoverDomainInPlatform() (string, error) { + if d, err := getDomainResolv(); err == nil && d != "" { + return d, nil + } + if d, err := getDomainKrbConf(); err == nil && d != "" { + return d, nil + } + if d, err := getDomainHostname(); err == nil && d != "" { + return d, nil + } + return "", fmt.Errorf("domain discovery failed") +} + +func getDomainResolv() (string, error) { + f, err := os.Open("/etc/resolv.conf") + if err != nil { + return "", err + } + defer f.Close() + s := bufio.NewScanner(f) + for s.Scan() { + fields := strings.Fields(s.Text()) + if len(fields) > 1 && (fields[0] == "search" || fields[0] == "domain") { + return fields[1], nil + } + } + return "", nil +} + +func getDomainKrbConf() (string, error) { + f, err := os.Open("/etc/krb5.conf") + if err != nil { + return "", err + } + defer f.Close() + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if strings.HasPrefix(line, "default_realm") { + if _, rhs, ok := strings.Cut(line, "="); ok { + return strings.ToLower(strings.TrimSpace(rhs)), nil + } + } + } + return "", nil +} diff --git a/libbeat/processors/translate_ldap_attribute/discovery_windows.go b/libbeat/processors/translate_ldap_attribute/discovery_windows.go new file mode 100644 index 000000000000..1a0b24978996 --- /dev/null +++ b/libbeat/processors/translate_ldap_attribute/discovery_windows.go @@ -0,0 +1,87 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//go:build windows && !requirefips + +package translate_ldap_attribute + +import ( + "fmt" + "os" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// discoverDomainInPlatform Chain: Env -> API -> Reg(TCP) -> Reg(Krb) -> Hostname +func discoverDomainInPlatform() (string, error) { + if d := os.Getenv("USERDNSDOMAIN"); d != "" { + return d, nil + } + if d, err := getDomainAPI(); err == nil && d != "" { + return d, nil + } + if d, err := getDomainReg(); err == nil && d != "" { + return d, nil + } + if d, err := getDomainKrb(); err == nil && d != "" { + return d, nil + } + if d, err := getDomainHostname(); err == nil && d != "" { + return d, nil + } + return "", fmt.Errorf("domain discovery failed") +} + +func getDomainAPI() (string, error) { + const ComputerNameDnsDomain = 2 + k32 := windows.NewLazySystemDLL("kernel32.dll") + proc := k32.NewProc("GetComputerNameExW") + var n uint32 + proc.Call(uintptr(ComputerNameDnsDomain), 0, uintptr(unsafe.Pointer(&n))) + if n == 0 { + return "", fmt.Errorf("size 0") + } + b := make([]uint16, n) + r, _, _ := proc.Call(uintptr(ComputerNameDnsDomain), uintptr(unsafe.Pointer(&b[0])), uintptr(unsafe.Pointer(&n))) + if r == 0 { + return "", fmt.Errorf("failed") + } + return syscall.UTF16ToString(b), nil +} + +func getDomainReg() (string, error) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters`, registry.QUERY_VALUE) + if err != nil { + return "", err + } + defer k.Close() + val, _, err := k.GetStringValue("Domain") + return val, err +} + +func getDomainKrb() (string, error) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Control\Lsa\Kerberos\Parameters`, registry.QUERY_VALUE) + if err != nil { + return "", err + } + defer k.Close() + val, _, err := k.GetStringValue("DefaultRealm") + return val, err +} diff --git a/libbeat/processors/translate_ldap_attribute/docs/translate_ldap_attribute.asciidoc b/libbeat/processors/translate_ldap_attribute/docs/translate_ldap_attribute.asciidoc index aff1125f43a2..bc8b98a1ba47 100644 --- a/libbeat/processors/translate_ldap_attribute/docs/translate_ldap_attribute.asciidoc +++ b/libbeat/processors/translate_ldap_attribute/docs/translate_ldap_attribute.asciidoc @@ -1,38 +1,37 @@ -[[processor-translate-guid]] -=== Translate GUID +[[processor-translate-ldap-attribute]] +=== Translate LDAP Attribute ++++ translate_ldap_attribute ++++ -The `translate_ldap_attribute` processor translates an LDAP attributes between eachother. -It is typically used to translate AD Global Unique Identifiers (GUID) -into their common names. +The `translate_ldap_attribute` processor translates LDAP attributes into friendlier +values. The typical use case is converting an Active Directory Global Unique +Identifier (GUID) into a human-readable name (for example the object's `cn`). Every object on an Active Directory or an LDAP server is issued a GUID. Internal processes refer to their GUID's rather than the object's name and these values sometimes appear in logs. -If the search attribute is invalid (malformed) or does not map to any object on the domain -then this will result in the processor returning an error unless `ignore_failure` -is set. +If the search attribute is invalid (malformed) or does not map to any object on the +domain the processor returns an error unless `ignore_failure` is set. The result of this operation is an array of values, given that a single attribute can hold multiple values. -Note: the search attribute is expected to map to a single object. If it doesn't, -no error will be returned, but only results of the first entry will be added -to the event. +The search attribute is expected to map to a single object. If multiple +entries match, only the first entry's mapped attribute values are returned. [source,yaml] ---- processors: - translate_ldap_attribute: field: winlog.event_data.ObjectGuid - ldap_address: "ldap://" - ldap_base_dn: "dc=example,dc=com" ignore_missing: true ignore_failure: true + # ldap_domain: "example.com" # Optional - override the OS-discovered domain used for SRV/LOGONSERVER hints + # ldap_address: "ldap://ds.example.com:389" # Optional - Beats discovers controllers when omitted + # ldap_base_dn: "dc=example,dc=com" # Optional - otherwise rootDSE and hostname inference are used ---- The `translate_ldap_attribute` processor has the following configuration settings: @@ -43,20 +42,44 @@ The `translate_ldap_attribute` processor has the following configuration setting | Name | Required | Default | Description | `field` | yes | | Source field containing a GUID. | `target_field` | no | | Target field for the mapped attribute value. If not set it will be replaced in place. -| `ldap_address` | yes | | LDAP server address. eg: `ldap://ds.example.com:389` -| `ldap_base_dn` | yes | | LDAP base DN. eg: `dc=example,dc=com` -| `ldap_bind_user` | no | | LDAP user. -| `ldap_bind_password` | no | | LDAP password. +| `ldap_domain` | no | | DNS domain name (for example, `example.com`) used for DNS SRV discovery and to construct FQDNs from `LOGONSERVER`. When omitted Beats inspects OS metadata to infer the domain (Windows: `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP + Kerberos registry keys, hostname; Linux/macOS: `/etc/resolv.conf`, `/etc/krb5.conf`, hostname). +| `ldap_address` | no | | LDAP server address (for example, `ldap://ds.example.com:389`). When omitted Beats auto-discovers controllers by querying `_ldaps._tcp.` first, `_ldap._tcp.` second, and finally the Windows `LOGONSERVER` variable if available. Candidates are tried in order until one succeeds. +| `ldap_base_dn` | no | | LDAP base DN (for example, `dc=example,dc=com`). When omitted Beats queries the server's rootDSE for `defaultNamingContext`/`namingContexts`. If the controller does not expose those attributes, client initialization fails and you must configure the value manually. +| `ldap_bind_user` | no | | LDAP DN/UPN for simple bind. When provided with `ldap_bind_password` Beats performs a standard bind. When set without a password Beats issues an unauthenticated bind using this identity (helpful when servers expect a bind DN even for anonymous operations). +| `ldap_bind_password` | no | | LDAP password for simple bind. When both the username and password are omitted Beats attempts automatic authentication: on Windows it first tries SSPI with the Beat's service/user identity using the SPN `ldap/` and falls back to an unauthenticated bind if that fails. Non-Windows platforms currently fall back to an unauthenticated bind immediately. | `ldap_search_attribute` | yes | `objectGUID` | LDAP attribute to search by. | `ldap_mapped_attribute` | yes | `cn` | LDAP attribute to map to. | `ldap_search_time_limit` | no | 30 | LDAP search time limit in seconds. -| `ldap_ssl`* | no | 30 | LDAP TLS/SSL connection settings. +| `ldap_ssl` | no | | LDAP TLS/SSL connection settings, see <>. +| `ad_guid_translation` | no | `auto` | Controls GUID binary conversion for Active Directory attributes. `auto` (default) converts when the LDAP search attribute equals `objectGUID` (case-insensitive). Use `always` to force conversion or `never` to disable it. | `ignore_missing` | no | false | Ignore errors when the source field is missing. | `ignore_failure` | no | false | Ignore all errors produced by the processor. |====== * Also see <> for a full description of the `ldap_ssl` options. +==== Authentication flow + +Beats attempts LDAP authentication in the following order: + +. Simple bind using `ldap_bind_user` and `ldap_bind_password` when both are supplied. +. Automatic bind when both values are empty. On Windows Beats creates an SSPI (Kerberos/NTLM) client for the SPN `ldap/`, which works for Local System services and gMSA accounts. Other platforms do not yet implement automatic authentication. +. If automatic authentication is unavailable or fails, Beats issues an unauthenticated bind. When `ldap_bind_user` is set without a password that identity is used; otherwise Beats binds anonymously. + +Always prefer specifying `ldap_address` as an FQDN (for example `ldap://dc1.example.com:389`) so the SPN built for SSPI matches the controller's service principal and TLS certificates. + +==== Server auto-discovery + +When `ldap_address` is omitted Beats resolves controllers dynamically: + +. *Domain discovery.* Beats determines the DNS domain from `ldap_domain` (if set) or OS metadata. Windows checks `USERDNSDOMAIN`, `GetComputerNameEx`, TCP/IP registry keys, Kerberos defaults, and the host's FQDN. Linux/macOS read `/etc/resolv.conf`, `/etc/krb5.conf`, and the hostname suffix. If no domain is available SRV lookups are skipped. +. *DNS SRV queries.* With a domain available Beats queries `_ldaps._tcp.` first and `_ldap._tcp.` second using the system resolver. Results are sorted by priority/weight per RFC 2782 and converted to `ldaps://host:port` or `ldap://host:port` URLs. +. *Windows `LOGONSERVER` fallback.* If SRV queries return no controllers or no domain was discovered, Beats reads the `LOGONSERVER` environment variable. When a domain is known the NetBIOS name is combined with it to build an FQDN so TLS validation and SSPI SPNs remain valid. + +Each candidate address is attempted in order (LDAPS before LDAP) until a connection and bind succeed. + +When `ldap_base_dn` is empty the client queries the controller's rootDSE for `defaultNamingContext` or the first non-system `namingContexts` entry. If neither is present Beats cannot continue and you must provide `ldap_base_dn` explicitly. + If the searches are slow or you expect a high amount of different key attributes to be found, consider using a cache processor to speed processing: diff --git a/libbeat/processors/translate_ldap_attribute/guid.go b/libbeat/processors/translate_ldap_attribute/guid.go new file mode 100644 index 000000000000..aa5642971ca5 --- /dev/null +++ b/libbeat/processors/translate_ldap_attribute/guid.go @@ -0,0 +1,127 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//go:build !requirefips + +package translate_ldap_attribute + +import ( + "encoding/hex" + "errors" + "fmt" + "strings" +) + +var ( + // errInvalidGUIDFormat is returned when the GUID format is invalid + errInvalidGUIDFormat = errors.New("invalid GUID format") +) + +// guidToBytes converts a GUID string in various formats to the binary format +// expected by Microsoft Active Directory. +// +// IMPORTANT: This conversion is ONLY for Microsoft Active Directory's objectGUID. +// Do NOT use for other LDAP implementations: +// - 389 Directory Server: Uses nsUniqueId (different format) +// - OpenLDAP and Other LDAP: Typically use RFC 4122 standard UUIDs +// +// Supported input formats: +// - {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} +// - xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +// - xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +// +// The function handles the byte-order conversion required for Microsoft GUIDs: +// The first three components (Data1, Data2, Data3) are little-endian, +// while the remaining bytes are in network byte order. +// +// Example: +// +// Input: "{7fb125ee-ceaf-48ff-8385-32c516ab10ed}" +// Output: []byte{0xee, 0x25, 0xb1, 0x7f, 0xaf, 0xce, 0xff, 0x48, 0x83, 0x85, 0x32, 0xc5, 0x16, 0xab, 0x10, 0xed} +func guidToBytes(guid string) ([]byte, error) { + // Remove curly braces if present + guid = strings.Trim(guid, "{}") + + // Remove hyphens + guid = strings.ReplaceAll(guid, "-", "") + + // Validate length + if len(guid) != 32 { + return nil, fmt.Errorf("%w: expected 32 hex characters, got %d", errInvalidGUIDFormat, len(guid)) + } + + // Decode hex string + bytes, err := hex.DecodeString(guid) + if err != nil { + return nil, fmt.Errorf("%w: %v", errInvalidGUIDFormat, err) + } + + // Microsoft GUID format requires byte swapping for the first three components + // GUID structure: {Data1-Data2-Data3-Data4[8]} + // Data1: 4 bytes (little-endian) + // Data2: 2 bytes (little-endian) + // Data3: 2 bytes (little-endian) + // Data4: 8 bytes (big-endian/network order) + + // Swap Data1 (first 4 bytes) + bytes[0], bytes[1], bytes[2], bytes[3] = bytes[3], bytes[2], bytes[1], bytes[0] + + // Swap Data2 (next 2 bytes) + bytes[4], bytes[5] = bytes[5], bytes[4] + + // Swap Data3 (next 2 bytes) + bytes[6], bytes[7] = bytes[7], bytes[6] + + // Data4 remains in network byte order (no swap needed) + + return bytes, nil +} + +// escapeBinaryForLDAP escapes binary data for use in LDAP filters. +// Each byte is represented as \XX where XX is the hexadecimal value. +func escapeBinaryForLDAP(data []byte) string { + var sb strings.Builder + for _, b := range data { + fmt.Fprintf(&sb, "\\%02x", b) + } + return sb.String() +} + +// guidBytesToString converts the binary representation used by Active Directory +// back to the canonical GUID string format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). +func guidBytesToString(data []byte) (string, error) { + if len(data) != 16 { + return "", fmt.Errorf("%w: expected 16 bytes, got %d", errInvalidGUIDFormat, len(data)) + } + + bytes := make([]byte, 16) + copy(bytes, data) + + // Reverse the byte swaps applied in guidToBytes (operation is symmetrical). + bytes[0], bytes[1], bytes[2], bytes[3] = bytes[3], bytes[2], bytes[1], bytes[0] + bytes[4], bytes[5] = bytes[5], bytes[4] + bytes[6], bytes[7] = bytes[7], bytes[6] + + hexStr := hex.EncodeToString(bytes) + return fmt.Sprintf("%s-%s-%s-%s-%s", + hexStr[0:8], + hexStr[8:12], + hexStr[12:16], + hexStr[16:20], + hexStr[20:32], + ), nil +} diff --git a/libbeat/processors/translate_ldap_attribute/guid_test.go b/libbeat/processors/translate_ldap_attribute/guid_test.go new file mode 100644 index 000000000000..d2e112d6d6b7 --- /dev/null +++ b/libbeat/processors/translate_ldap_attribute/guid_test.go @@ -0,0 +1,160 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//go:build !requirefips + +package translate_ldap_attribute + +import ( + "encoding/hex" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGUIDToBytes(t *testing.T) { + tests := []struct { + name string + input string + expected []byte + expectError bool + }{ + { + name: "GUID with curly braces and hyphens", + input: "{7fb125ee-ceaf-48ff-8385-32c516ab10ed}", + // Expected byte order after Microsoft GUID conversion: + // Original hex: 7fb125ee-ceaf-48ff-8385-32c516ab10ed + // After swap: ee25b17f-afce-ff48-8385-32c516ab10ed + expected: []byte{0xee, 0x25, 0xb1, 0x7f, 0xaf, 0xce, 0xff, 0x48, 0x83, 0x85, 0x32, 0xc5, 0x16, 0xab, 0x10, 0xed}, + expectError: false, + }, + { + name: "GUID with hyphens", + input: "7fb125ee-ceaf-48ff-8385-32c516ab10ed", + expected: []byte{0xee, 0x25, 0xb1, 0x7f, 0xaf, 0xce, 0xff, 0x48, 0x83, 0x85, 0x32, 0xc5, 0x16, 0xab, 0x10, 0xed}, + expectError: false, + }, + { + name: "GUID without hyphens", + input: "7fb125eeceaf48ff838532c516ab10ed", + expected: []byte{0xee, 0x25, 0xb1, 0x7f, 0xaf, 0xce, 0xff, 0x48, 0x83, 0x85, 0x32, 0xc5, 0x16, 0xab, 0x10, 0xed}, + expectError: false, + }, + { + name: "Another valid GUID", + input: "{a1b2c3d4-e5f6-0718-9293-a4b5c6d7e8f9}", + expected: []byte{0xd4, 0xc3, 0xb2, 0xa1, 0xf6, 0xe5, 0x18, 0x07, 0x92, 0x93, 0xa4, 0xb5, 0xc6, 0xd7, 0xe8, 0xf9}, + expectError: false, + }, + { + name: "Empty string", + input: "", + expected: nil, + expectError: true, + }, + { + name: "Invalid length", + input: "7fb125ee-ceaf-48ff-8385", + expected: nil, + expectError: true, + }, + { + name: "Invalid hex characters", + input: "7fb125ee-ceaf-48ff-8385-32c516ab10xz", + expected: nil, + expectError: true, + }, + { + name: "Too long", + input: "7fb125ee-ceaf-48ff-8385-32c516ab10ed-extra", + expected: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := guidToBytes(tt.input) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result, "Expected: %s, Got: %s", + hex.EncodeToString(tt.expected), + hex.EncodeToString(result)) + } + }) + } +} + +func TestEscapeBinaryForLDAP(t *testing.T) { + tests := []struct { + name string + input []byte + expected string + }{ + { + name: "Simple binary data", + input: []byte{0x7f, 0xb1, 0x25, 0xee}, + expected: "\\7f\\b1\\25\\ee", + }, + { + name: "GUID binary", + input: []byte{0xee, 0x25, 0xb1, 0x7f, 0xaf, 0xce, 0xff, 0x48, 0x83, 0x85, 0x32, 0xc5, 0x16, 0xab, 0x10, 0xed}, + expected: "\\ee\\25\\b1\\7f\\af\\ce\\ff\\48\\83\\85\\32\\c5\\16\\ab\\10\\ed", + }, + { + name: "Empty byte array", + input: []byte{}, + expected: "", + }, + { + name: "Single byte", + input: []byte{0x00}, + expected: "\\00", + }, + { + name: "High value bytes", + input: []byte{0xff, 0xfe, 0xfd}, + expected: "\\ff\\fe\\fd", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := escapeBinaryForLDAP(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGUIDBytesToString(t *testing.T) { + original := "{7fb125ee-ceaf-48ff-8385-32c516ab10ed}" + bytes, err := guidToBytes(original) + require.NoError(t, err) + + guid, err := guidBytesToString(bytes) + require.NoError(t, err) + assert.Equal(t, strings.Trim(strings.ToLower(original), "{}"), guid) + + _, err = guidBytesToString([]byte{0x00}) + assert.Error(t, err) +} diff --git a/libbeat/processors/translate_ldap_attribute/ldap.go b/libbeat/processors/translate_ldap_attribute/ldap.go index 8eb2c8814f5a..6e772aab6641 100644 --- a/libbeat/processors/translate_ldap_attribute/ldap.go +++ b/libbeat/processors/translate_ldap_attribute/ldap.go @@ -22,6 +22,8 @@ package translate_ldap_attribute import ( "crypto/tls" "fmt" + "net/url" + "strings" "sync" "github.com/go-ldap/ldap/v3" @@ -50,7 +52,9 @@ type ldapConfig struct { tlsConfig *tls.Config } -// newLDAPClient initializes a new ldapClient with a single connection +// newLDAPClient initializes a new ldapClient with a single connection. +// If baseDN is empty, it will attempt to discover it via rootDSE or domain inference. +// It also detects whether the server is Active Directory. func newLDAPClient(config *ldapConfig, log *logp.Logger) (*ldapClient, error) { client := &ldapClient{ldapConfig: config, log: log} @@ -61,10 +65,21 @@ func newLDAPClient(config *ldapConfig, log *logp.Logger) (*ldapClient, error) { } client.conn = conn + if client.baseDN == "" { + // Discover base DN if not provided + baseDN, err := client.getBaseDN() + if err != nil { + client.close() + return nil, fmt.Errorf("failed to discover base DN: %w", err) + } + client.baseDN = baseDN + } + return client, nil } -// dial establishes a new connection to the LDAP server +// dial establishes a new connection to the LDAP server. +// It handles the upgrade to StartTLS if the scheme is ldap:// and a TLS config is present. func (client *ldapClient) dial() (*ldap.Conn, error) { client.log.Debugw("ldap client connecting") @@ -73,51 +88,165 @@ func (client *ldapClient) dial() (*ldap.Conn, error) { if client.tlsConfig != nil { opts = append(opts, ldap.DialWithTLSConfig(client.tlsConfig)) } + + // ldap.DialURL handles parsing ldap:// vs ldaps:// conn, err := ldap.DialURL(client.address, opts...) if err != nil { return nil, fmt.Errorf("failed to dial LDAP server: %w", err) } - if client.password != "" { - client.log.Debugw("ldap client bind") - err = conn.Bind(client.username, client.password) - } else { - client.log.Debugw("ldap client unauthenticated bind") - err = conn.UnauthenticatedBind(client.username) + // Explicitly handle StartTLS upgrade. + // DialURL connects to 389 for "ldap://" but does not upgrade automatically. + // We must do this before binding credentials. + if strings.HasPrefix(client.address, "ldap://") && client.tlsConfig != nil { + if err := conn.StartTLS(client.tlsConfig); err != nil { + conn.Close() + return nil, fmt.Errorf("failed to upgrade connection to StartTLS: %w", err) + } + client.log.Debug("connection upgraded to StartTLS") } - if err != nil { + switch { + case client.password != "": + client.log.Debugw("ldap client bind with provided credentials") + if err = conn.Bind(client.username, client.password); err == nil { + return conn, nil + } else { + client.log.Debugw("ldap client bind with provided credentials failed", "error", err) + } + case client.username == "" && client.password == "": + client.log.Debugw("trying automatic ldap client bind") + if err = client.bindAuto(conn); err == nil { + return conn, nil + } else { + client.log.Debugw("automatic ldap client bind failed", "error", err) + } + } + + client.log.Debugw("trying ldap client unauthenticated bind") + if err = conn.UnauthenticatedBind(client.username); err != nil { conn.Close() return nil, fmt.Errorf("failed to bind to LDAP server: %w", err) } - return conn, nil } -// reconnect checks the connection's health and reconnects if necessary -func (client *ldapClient) connection() (*ldap.Conn, error) { +// bindAuto attempts to authenticate using the best available platform-specific method. +// On Windows: SSPI (Current User) +// On Linux: Kerberos Cache (Current User) +func (client *ldapClient) bindAuto(conn *ldap.Conn) error { + // Parse hostname for SPN (Service Principal Name) + // SPN Format: ldap/server.example.com + parsedURL, err := url.Parse(client.address) + if err != nil { + return fmt.Errorf("failed to parse LDAP address: %w", err) + } + // Canonicalize the SPN (Active Directory expects this format) + spn := fmt.Sprintf("ldap/%s", strings.ToLower(parsedURL.Hostname())) + return client.bindPlatformSpecific(conn, spn) +} + +// getBaseDN discovers base DN (if needed) and detects server type +func (client *ldapClient) getBaseDN() (string, error) { + client.log.Debug("querying rootDSE for server metadata") + + // Query rootDSE with relevant attributes + searchRequest := ldap.NewSearchRequest( + "", // Empty base DN = rootDSE + ldap.ScopeBaseObject, + ldap.NeverDerefAliases, + 0, // No size limit + client.searchTimeLimit, + false, + "(objectClass=*)", // Match everything + []string{ + "defaultNamingContext", + "namingContexts", + }, + nil, + ) + + var result *ldap.SearchResult + err := client.withLockedConnection(func(conn *ldap.Conn) error { + var searchErr error + result, searchErr = conn.Search(searchRequest) + return searchErr + }) + if err != nil { + return "", fmt.Errorf("rootDSE query failed: %w", err) + } + + if len(result.Entries) == 0 { + return "", fmt.Errorf("no entries returned from rootDSE") + } + + entry := result.Entries[0] + + // 1. Prefer defaultNamingContext (Active Directory standard) + if values := entry.GetAttributeValues("defaultNamingContext"); len(values) > 0 { + client.log.Infow("discovered base DN via defaultNamingContext", "base_dn", values[0]) + return values[0], nil + } + + // 2. Fallback to namingContexts (OpenLDAP / Standard) + // We must filter out system contexts like cn=config, cn=schema, etc. + if values := entry.GetAttributeValues("namingContexts"); len(values) > 0 { + for _, v := range values { + lowerV := strings.ToLower(v) + // Skip common system contexts + if strings.HasPrefix(lowerV, "cn=config") || + strings.HasPrefix(lowerV, "cn=schema") || + strings.HasPrefix(lowerV, "cn=monitor") || + strings.HasPrefix(lowerV, "cn=subschema") { + continue + } + + // We return the first "reasonable" context we find. + // Usually, this will be the main data partition (e.g., dc=example,dc=com) + client.log.Infow("discovered base DN via namingContexts", "base_dn", v) + return v, nil + } + + // If we iterated everything and only found system contexts (unlikely for a user DB), + // we default to the first one but log a warning. + client.log.Warnw("only system contexts found in namingContexts, defaulting to first value", "base_dn", values[0]) + return values[0], nil + } + + return "", fmt.Errorf("base DN not found in rootDSE") +} + +// withLockedConnection runs fn while holding the client mutex and ensuring the +// underlying LDAP connection is healthy before invoking the callback. +func (client *ldapClient) withLockedConnection(fn func(*ldap.Conn) error) error { client.mu.Lock() defer client.mu.Unlock() - // Check if the connection is still alive + if err := client.ensureConnectedLocked(); err != nil { + return err + } + return fn(client.conn) +} + +func (client *ldapClient) ensureConnectedLocked() error { if client.conn == nil || client.conn.IsClosing() { + // Ensure previous connection is fully closed + if client.conn != nil { + client.conn.Close() + } + conn, err := client.dial() if err != nil { - return nil, err + return err } client.conn = conn } - return client.conn, nil + return nil } // findObjectBy searches for an object and returns its mapped values. func (client *ldapClient) findObjectBy(searchBy string) ([]string, error) { - // Ensure the connection is alive or reconnect if necessary - conn, err := client.connection() - if err != nil { - return nil, fmt.Errorf("failed to reconnect: %w", err) - } - + var result *ldap.SearchResult // Format the filter and perform the search filter := fmt.Sprintf("(%s=%s)", client.searchAttr, searchBy) searchRequest := ldap.NewSearchRequest( @@ -126,8 +255,12 @@ func (client *ldapClient) findObjectBy(searchBy string) ([]string, error) { filter, []string{client.mappedAttr}, nil, ) - // Execute search - result, err := conn.Search(searchRequest) + // Execute search while holding the connection lock to avoid concurrent usage of *ldap.Conn + err := client.withLockedConnection(func(conn *ldap.Conn) error { + var searchErr error + result, searchErr = conn.Search(searchRequest) + return searchErr + }) if err != nil { return nil, fmt.Errorf("search failed: %w", err) } @@ -135,9 +268,9 @@ func (client *ldapClient) findObjectBy(searchBy string) ([]string, error) { return nil, fmt.Errorf("no entries found for search attribute %s", searchBy) } - // Retrieve the CN attribute - cn := result.Entries[0].GetAttributeValues(client.mappedAttr) - return cn, nil + // Retrieve the mapped attribute values + values := result.Entries[0].GetAttributeValues(client.mappedAttr) + return values, nil } // close closes the LDAP connection diff --git a/libbeat/processors/translate_ldap_attribute/ldap_other.go b/libbeat/processors/translate_ldap_attribute/ldap_other.go new file mode 100644 index 000000000000..0d53cc7ca337 --- /dev/null +++ b/libbeat/processors/translate_ldap_attribute/ldap_other.go @@ -0,0 +1,31 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//go:build !windows && !requirefips + +package translate_ldap_attribute + +import ( + "errors" + + "github.com/go-ldap/ldap/v3" +) + +// TODO: implement automatic authentication for other platforms using kerberos +func (*ldapClient) bindPlatformSpecific(*ldap.Conn, string) error { + return errors.New("unsupported platform for automatic authentication") +} diff --git a/libbeat/processors/translate_ldap_attribute/ldap_test.go b/libbeat/processors/translate_ldap_attribute/ldap_test.go new file mode 100644 index 000000000000..d63367ef1377 --- /dev/null +++ b/libbeat/processors/translate_ldap_attribute/ldap_test.go @@ -0,0 +1,169 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//go:build !requirefips + +package translate_ldap_attribute + +import ( + "testing" + + "github.com/go-ldap/ldap/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrepareSearchFilter(t *testing.T) { + validGUID := "{7fb125ee-ceaf-48ff-8385-32c516ab10ed}" + guidBytes, _ := guidToBytes(validGUID) + expectedEscaped := escapeBinaryForLDAP(guidBytes) + + tests := []struct { + name string + ldapSearchAttr string + adGuidTranslation string + input string + expect string + expectErr bool + }{ + { + name: "Auto mode converts when attribute is objectGUID", + ldapSearchAttr: "objectGUID", + input: validGUID, + expect: expectedEscaped, + }, + { + name: "Auto mode is case-insensitive", + ldapSearchAttr: "objectguid", + input: validGUID, + expect: expectedEscaped, + }, + { + name: "Auto mode does not convert other attribute", + ldapSearchAttr: "uid", + input: validGUID, + expect: validGUID, + }, + { + name: "Explicit true converts even if attribute different", + ldapSearchAttr: "uid", + adGuidTranslation: guidTranslationAlways, + input: validGUID, + expect: expectedEscaped, + }, + { + name: "Explicit false never converts", + ldapSearchAttr: "objectGUID", + adGuidTranslation: guidTranslationNever, + input: validGUID, + expect: validGUID, + }, + { + name: "Invalid GUID with conversion attempt returns error", + ldapSearchAttr: "objectGUID", + input: "invalid-guid", + expectErr: true, + }, + { + name: "Escapes filter characters when not converting", + ldapSearchAttr: "uid", + input: "value*)(|(cn=*)", + expect: ldap.EscapeFilter("value*)(|(cn=*)"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &processor{ + config: config{ + LDAPSearchAttribute: tt.ldapSearchAttr, + ADGUIDTranslation: tt.adGuidTranslation, + }, + client: &ldapClient{}, + } + out, err := p.prepareSearchFilter(tt.input) + if tt.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expect, out) + }) + } +} + +func TestMaybeConvertMappedGUID(t *testing.T) { + guidStr := "7fb125ee-ceaf-48ff-8385-32c516ab10ed" + raw, err := guidToBytes(guidStr) + require.NoError(t, err) + + tests := []struct { + name string + cfg config + values []string + expect []string + expectErr bool + }{ + { + name: "auto converts objectGUID", + cfg: config{ + LDAPMappedAttribute: "objectGUID", + ADGUIDTranslation: guidTranslationAuto, + }, + values: []string{string(raw)}, + expect: []string{guidStr}, + }, + { + name: "never leaves binary untouched", + cfg: config{ + LDAPMappedAttribute: "objectGUID", + ADGUIDTranslation: guidTranslationNever, + }, + values: []string{string(raw)}, + expect: []string{string(raw)}, + }, + { + name: "non objectGUID attribute is ignored", + cfg: config{ + LDAPMappedAttribute: "cn", + }, + values: []string{string(raw)}, + expect: []string{string(raw)}, + }, + { + name: "invalid length returns error", + cfg: config{ + LDAPMappedAttribute: "objectGUID", + }, + values: []string{"short"}, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &processor{config: tt.cfg} + converted, err := p.maybeConvertMappedGUID(tt.values) + if tt.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expect, converted) + }) + } +} diff --git a/libbeat/processors/translate_ldap_attribute/ldap_windows.go b/libbeat/processors/translate_ldap_attribute/ldap_windows.go new file mode 100644 index 000000000000..8bf4937b74f6 --- /dev/null +++ b/libbeat/processors/translate_ldap_attribute/ldap_windows.go @@ -0,0 +1,45 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//go:build windows && !requirefips + +package translate_ldap_attribute + +import ( + "fmt" + + "github.com/go-ldap/ldap/v3" + "github.com/go-ldap/ldap/v3/gssapi" +) + +func (client *ldapClient) bindPlatformSpecific(conn *ldap.Conn, spn string) error { + client.log.Info("Attempting Windows SSPI Bind") + + sspiClient, err := gssapi.NewSSPIClient() + if err != nil { + return fmt.Errorf("failed to create SSPI client: %w", err) + } + defer sspiClient.DeleteSecContext() + + err = conn.GSSAPIBind(sspiClient, spn, "") + if err != nil { + return fmt.Errorf("SSPI bind failed: %w", err) + } + + client.log.Info("Windows SSPI Bind Successful") + return nil +} diff --git a/libbeat/processors/translate_ldap_attribute/translate_ldap_attribute.go b/libbeat/processors/translate_ldap_attribute/translate_ldap_attribute.go index 634e0f2f4252..40b4e79cf9a0 100644 --- a/libbeat/processors/translate_ldap_attribute/translate_ldap_attribute.go +++ b/libbeat/processors/translate_ldap_attribute/translate_ldap_attribute.go @@ -22,6 +22,11 @@ package translate_ldap_attribute import ( "errors" "fmt" + "strings" + "sync" + "time" + + "github.com/go-ldap/ldap/v3" "github.com/elastic/beats/v7/libbeat/beat" "github.com/elastic/beats/v7/libbeat/processors" @@ -32,7 +37,10 @@ import ( "github.com/elastic/elastic-agent-libs/transport/tlscommon" ) -const logName = "processor.translate_ldap_attribute" +const ( + logName = "processor.translate_ldap_attribute" + clientRetryBackoff = 30 * time.Second +) var errInvalidType = errors.New("search attribute field value is not a string") @@ -45,6 +53,10 @@ type processor struct { config client *ldapClient log *logp.Logger + + clientMu sync.Mutex + clientErr error + nextClientAttempt time.Time } func New(cfg *conf.C, log *logp.Logger) (beat.Processor, error) { @@ -52,13 +64,37 @@ func New(cfg *conf.C, log *logp.Logger) (beat.Processor, error) { if err := cfg.Unpack(&c); err != nil { return nil, fmt.Errorf("fail to unpack the translate_ldap_attribute configuration: %w", err) } + if err := c.validate(); err != nil { + return nil, fmt.Errorf("invalid translate_ldap_attribute configuration: %w", err) + } return newFromConfig(c, log) } func newFromConfig(c config, logger *logp.Logger) (*processor, error) { + p := &processor{config: c} + p.log = logger.Named(logName).With(logp.Stringer("processor", p)) + return p, nil +} + +// newClient creates a new LDAP client by discovering and connecting to available servers. +func newClient(c config, log *logp.Logger) (*ldapClient, error) { + // Auto-discover LDAP addresses if not provided + var addresses []string + if c.LDAPAddress != "" { + addresses = []string{c.LDAPAddress} + } else { + log.Info("LDAP address not configured, attempting auto-discovery") + discoveredAddresses, err := discoverLDAPAddress(c.LDAPDomain, log) + if err != nil { + return nil, fmt.Errorf("failed to auto-discover LDAP server: %w", err) + } + addresses = discoveredAddresses + log.Infow("discovered LDAP servers", "count", len(addresses), "addresses", addresses) + } + + // Prepare base LDAP config ldapConfig := &ldapConfig{ - address: c.LDAPAddress, baseDN: c.LDAPBaseDN, username: c.LDAPBindUser, password: c.LDAPBindPassword, @@ -67,20 +103,34 @@ func newFromConfig(c config, logger *logp.Logger) (*processor, error) { searchTimeLimit: c.LDAPSearchTimeLimit, } if c.LDAPTLS != nil { - tlsConfig, err := tlscommon.LoadTLSConfig(c.LDAPTLS, logger) + tlsConfig, err := tlscommon.LoadTLSConfig(c.LDAPTLS, log) if err != nil { return nil, fmt.Errorf("could not load provided LDAP TLS configuration: %w", err) } ldapConfig.tlsConfig = tlsConfig.ToConfig() } - p := &processor{config: c} - p.log = logger.Named(logName).With(logp.Stringer("processor", p)) - client, err := newLDAPClient(ldapConfig, p.log) - if err != nil { - return nil, err + + // Try each discovered address in order until one succeeds + var lastErr error + for i, address := range addresses { + log.Debugw("attempting to connect to LDAP server", "attempt", i+1, "total", len(addresses), "address", address) + ldapConfig.address = address + + // newLDAPClient handles connection, base DN discovery, and AD detection + client, err := newLDAPClient(ldapConfig, log) + if err != nil { + log.Debugw("failed to initialize LDAP client", "address", address, "error", err) + lastErr = err + continue + } + + // Successfully connected and initialized + log.Infow("successfully connected to LDAP server", "address", address, "base_dn", client.baseDN) + return client, nil } - p.client = client - return p, nil + + // All addresses failed + return nil, fmt.Errorf("failed to connect to any discovered LDAP server (%d addresses tried): %w", len(addresses), lastErr) } func (p *processor) String() string { @@ -105,32 +155,135 @@ func (p *processor) Run(event *beat.Event) (*beat.Event, error) { } func (p *processor) translateLDAPAttr(event *beat.Event) error { + client, err := p.ensureClient() + if err != nil { + return err + } + v, err := event.GetValue(p.Field) if err != nil { return err } - guidString, ok := v.(string) + searchValue, ok := v.(string) if !ok { return errInvalidType } - p.log.Debugw("ldap search", "guid", guidString) - cn, err := p.client.findObjectBy(guidString) - p.log.Debugw("ldap result", "common_name", cn) + searchFilter, err := p.prepareSearchFilter(searchValue) + if err != nil { + return err + } + + p.log.Debugw("ldap search", "search_value", searchValue, "filter_value", searchFilter) + values, err := client.findObjectBy(searchFilter) + p.log.Debugw("ldap result", "common_name", values, "error", err) if err != nil { return err } + values, err = p.maybeConvertMappedGUID(values) + if err != nil { + return fmt.Errorf("objectGUID conversion failed: %w", err) + } + field := p.Field if p.TargetField != "" { field = p.TargetField } - _, err = event.PutValue(field, cn) + _, err = event.PutValue(field, values) return err } +// prepareSearchFilter converts the search value to the appropriate format for LDAP queries. +// It applies GUID binary conversion when required based on the ADGUIDTranslation configuration +// and server type detection. +func (p *processor) prepareSearchFilter(searchValue string) (string, error) { + // Determine if GUID conversion should be applied + var shouldConvertGUID bool + switch p.ADGUIDTranslation { + case guidTranslationAlways: + shouldConvertGUID = true + case guidTranslationNever: + shouldConvertGUID = false + default: // auto + shouldConvertGUID = strings.EqualFold(p.LDAPSearchAttribute, "objectGUID") + } + + if !shouldConvertGUID { + return ldap.EscapeFilter(searchValue), nil + } + + guidBytes, err := guidToBytes(searchValue) + if err != nil { + return "", fmt.Errorf("failed to convert GUID: %w", err) + } + searchFilter := escapeBinaryForLDAP(guidBytes) + return searchFilter, nil +} + +// maybeConvertMappedGUID converts binary LDAP responses to canonical GUID strings +// when AD GUID translation is enabled and the mapped attribute refers to objectGUID. +func (p *processor) maybeConvertMappedGUID(values []string) ([]string, error) { + if !p.shouldConvertMappedGUID() || len(values) == 0 { + return values, nil + } + + converted := make([]string, len(values)) + for i, raw := range values { + guid, err := guidBytesToString([]byte(raw)) + if err != nil { + return nil, err + } + converted[i] = guid + } + return converted, nil +} + +func (p *processor) shouldConvertMappedGUID() bool { + if p.ADGUIDTranslation == guidTranslationNever { + return false + } + return strings.EqualFold(p.LDAPMappedAttribute, "objectGUID") +} + func (p *processor) Close() error { - p.client.close() + p.clientMu.Lock() + defer p.clientMu.Unlock() + if p.client != nil { + p.client.close() + p.client = nil + } + p.clientErr = nil + p.nextClientAttempt = time.Time{} return nil } + +func (p *processor) ensureClient() (*ldapClient, error) { + p.clientMu.Lock() + defer p.clientMu.Unlock() + + if p.client != nil { + return p.client, nil + } + + now := time.Now() + if !p.nextClientAttempt.IsZero() && now.Before(p.nextClientAttempt) && p.clientErr != nil { + return nil, fmt.Errorf("ldap client initialization paused until %s: %w", p.nextClientAttempt.Format(time.RFC3339), p.clientErr) + } + + client, err := newClient(p.config, p.log) + if err != nil { + p.clientErr = err + p.nextClientAttempt = now.Add(clientRetryBackoff) + return nil, err + } + + // Update config with discovered values for logging/debugging. + p.client = client + p.LDAPBaseDN = client.baseDN + p.LDAPAddress = client.address + p.clientErr = nil + p.nextClientAttempt = time.Time{} + return client, nil +}