LDAP Sensor — Reference
File: DiscoveryManager/Sensors/discover_ldap/ldap_sensor.py Sensor name: Discover_LDAP Protocol: LDAP / LDAPS (port 389 / 636) Last updated: 2026-05-18
Status legend
| Symbol | Meaning |
|---|---|
X | Completed — implemented, tested, output verified |
W | Works — implemented and running, limited real-server testing |
D | Developing — partially coded, not finished |
F | Future — not started, planned for a later phase |
R | Removed — was implemented, pulled out of scope for now (see §6) |
— | Out of scope — not key/cert material, skip by design |
1 — Vendor support matrix
| Vendor | Detected via Root DSE | Common phases | Shared phases | Vendor phases |
|---|---|---|---|---|
| OpenLDAP | vendorName contains OpenLDAP | X | SSH generic | none |
| FreeIPA / 389-Directory / RHDS | vendorName contains 389-Directory or Red Hat | X | SSH generic | R — all removed (see §6c, §6d) |
| Oracle Internet Directory | vendorName contains Oracle | X | SSH generic | R — removed (see §6a) |
| Novell eDirectory / NetIQ | vendorName contains Novell or NetIQ | X | SSH generic | R — removed (see §6b) |
| Unknown / any other | fallback | X | SSH generic | none |
Current state: All vendor-specific phases removed. Every vendor runs common phases + SSH generic + custom_attributes only. Vendor detection still runs (vendorName stamped in the status doc) but drives no extra phases.
2 — Common phases (all vendors)
These phases run on every target regardless of vendor.
| Scan target | Status | LDAP attribute | What is discovered |
|---|---|---|---|
| Server TLS certificate | X | TLS handshake (not an LDAP attribute) | The LDAPS server's own TLS certificate — issuer, subject, validity, algorithm, public key |
| User certificates | X | userCertificate | X.509 DER certs on user/computer objects — smart card, email signing, VPN |
| CA certificates | X | cACertificate | Root and intermediate CA certs |
| S/MIME certificates | X | userSMIMECertificate | PKCS#7 wrapped cert bundles — each cert in the bundle extracted individually |
| Cross-certificate pairs | X | crossCertificatePair | Two-cert DER pairs — each cert extracted individually |
| PKCS#12 bundles | X | userPKCS12 | Password-protected key + cert bundles — passwords supplied via pkcs12_passwords config |
3 — Shared phases (all vendors)
| Scan target | Status | LDAP attribute | Notes |
|---|---|---|---|
| SSH public keys (AD-style) | X | altSecurityIdentities | Values prefixed SSH: <type> <b64> — AD / Samba4 format |
| SSH public keys (generic) | X | sshPublicKey | Bare OpenSSH public key lines — openssh-lpk community schema, de-facto standard for OpenLDAP |
| SSH public keys (FreeIPA attr) | X | ipaSshPubKey | Same wire format as sshPublicKey — included in the same LDAP filter at zero cost; returns nothing on non-FreeIPA servers |
| FIDO / U2F SSH keys | X | all three attrs above | [email protected] and [email protected] key types handled by _parse_ssh_wire() — no special config needed |
| Kerberos keys | R | krbPrincipalKey, krbMKey | Removed — see §6d |
All three SSH attributes are handled in one LDAP search by _shared_ssh_generic.py. No FreeIPA-specific logic remains active.
4 — Custom attribute discovery (any vendor)
The sensor supports user-declared attribute scanning via config. Any LDAP attribute value is passed through EC_Libs.parser which auto-detects the format and type.
| Capability | Status | Notes |
|---|---|---|
| Unprotected custom attributes | X | parse(raw) — format auto-detected: PEM / DER / JWK / OpenSSH / PGP / PKCS#12 / PKCS#7 / hex / base64 |
| Password-protected attributes | X | Passwords tried in order — first successful decryption wins (PKCS#12, JKS) |
type hint for raw symmetric keys | X | Required in both custom_attributes and custom_protected_attributes when the attribute holds raw symmetric key bytes with no self-describing header (e.g. raw AES, HMAC). Without it parse() cannot produce asset_type="secret_key" and the algorithm/key_size overrides have no effect |
| Fingerprint-based deduplication | X | Certificates deduped on cert_sha256; public keys on public_key_sha256 |
Design decision: custom_attributes eliminates the need for vendor-specific code in the large category of vendors that store crypto material in self-describing formats (PEM, DER, JWK, PKCS#12). A new unknown vendor that stores certs in a proprietary attribute name needs only a YAML config entry — no code change.
Vendor-specific code is still required when:
Crypto material is split across two or more LDAP attributes (e.g. key bytes in one attribute, algorithm in another)
Bytes have no self-describing format or header (e.g. raw HMAC shared secrets)
Domain objects correlate multiple attributes into one crypto concept (OTP token, vault, NICI secret store)
5 — Future vendor scope
Vendors not yet covered by any dedicated phase. For most, the generic common phases already pick up the bulk of crypto material (standard userCertificate, sshPublicKey, etc.). A dedicated phase would only be needed if the vendor has proprietary attributes with non-standard content.
| Vendor | Status | Notes |
|---|---|---|
| IBM Security Directory Server (SDS) | F | Uses standard LDAP schema for certs — common phases cover most material. IBM-specific attributes (ibm-entryUuid, IBM SSL config) are not crypto material. Low priority. |
| OpenDJ / ForgeRock Directory | F | Uses standard LDAP schema. No proprietary crypto attributes known. Common phases + custom_attributes sufficient. |
| 389-Directory standalone (no FreeIPA schema) | F | Detected as FREEIPA by Root DSE but will not have ipa* attributes. Vendor phases return 0 results silently. No action needed unless proprietary attrs surface. |
| Microsoft AD LDS | F | Standalone LDAP, not a full AD DC. Shares some AD schema. Evaluate whether a shared phase with discover_activedirectory makes more sense than a separate LDAP sensor phase. |
| Apache Directory Server / ApacheDS | F | Niche, mostly used in testing. Standard schema. Common phases cover it. |
| Ping Identity / PingDirectory | F | Enterprise directory. Standard LDAP schema + some proprietary extensions. Evaluate on demand. |
6 — Removed vendor phases (research notes for future reinstatement)
All vendor-specific phases were implemented and then removed to reduce maintenance surface. Full research notes are preserved here so they can be reinstated without starting from scratch.
6a — Oracle Internet Directory (OID)
Detection: vendorName contains Oracle Removed file: _vendor_oracle_pki.py Removed: 2026-05-14 Reason: Oracle Internet Directory is a niche enterprise product with very few new deployments. Real testing requires OID infrastructure. Reinstate when a customer engagement surfaces OID.
Attributes targeted
| Attribute | Type | What it holds |
|---|---|---|
orclpkiCertificate | DER bytes | X.509 certificate — fully parseable by EC_Libs.parser |
orclpkiPrivateKey | Encrypted DER | Oracle-proprietary encrypted key envelope — existence + metadata only; no unwrap |
orclCertificateUsage | String flags | Comma-separated purpose flags e.g. "signing,encryption" — stored in sourceProperties |
Implementation notes
Filter:
(orclpkiCertificate=*)orclpkiCertificateis standard DER — parseable directly; the vendor phase addsorclCertificateUsagecontext thatcustom_attributescannot supply (it reads one attribute at a time)orclCertificateUsageshould be mapped to akeyUsagebitmask in the CBOM doc on reinstatementorclpkiPrivateKeyblob is an Oracle-proprietary envelope — no open spec; existence-only reporting is the correct approach
To reinstate
Restore
_vendor_oracle_pki.pyfrom git historyIn
ldap_sensor.py: addfrom . import _vendor_oracle_pki as _phase_oracleChange
VENDOR_PHASES[ORACLE]from[]to[_phase_oracle]
6b — Novell eDirectory / NetIQ (NICI)
Detection: vendorName contains Novell or NetIQ Removed file: _vendor_edirectory_nici.py Removed: 2026-05-14 Reason: Novell is effectively dead (acquired by Micro Focus, then OpenText). eDirectory is pure legacy — organisations migrate away, not toward it. NICI is completely proprietary with no open specification. Requires real NetIQ infrastructure to test.
Attributes targeted
| Attribute | Type | What it holds |
|---|---|---|
ndspkiPublicKey | DER bytes | RSA / EC / Ed25519 public key — standard DER SubjectPublicKeyInfo |
ndspkiPrivateKey | Encrypted blob | NICI-wrapped private key — existence only; NICI unwrap is proprietary |
ndspkiCertificateChain | Concatenated DER | One or more X.509 certs concatenated end-to-end — no delimiter |
ndspkiKeyPairStatus | String | "Active" / "Inactive" key pair validity flag |
ndspkiSD | Opaque blob | NICI secret store symmetric key — no algorithm header |
Implementation notes
Filter:
(ndspkiPublicKey=*)ndspkiCertificateChain— split by walking DER TLV length fields (0x30 <len> ...); helper functions_split_cert_chain()and_der_length()existed in the removed file and are straightforward to recoverndspkiSD— NICI secret store key.len(blob) * 8= size in bits. No way to determine algorithm — reportalgorithm="Symmetric",secured_by="Novell NICI"ndspkiPrivateKey— algorithm can be inferred from the correspondingndspkiPublicKeyif both are on the same entry; the phase correlated them per DNndspkiKeyPairStatus→sourceProperties["keyPairStatus"]
To reinstate
Restore
_vendor_edirectory_nici.pyfrom git historyIn
ldap_sensor.py: addfrom . import _vendor_edirectory_nici as _phase_edirChange
VENDOR_PHASES[EDIRECTORY]from[]to[_phase_edir]
6c — FreeIPA vendor phases (OTP, RADIUS, Vault)
Detection: vendorName contains 389-Directory or Red Hat Removed files: _vendor_freeipa_otp.py, _vendor_freeipa_radius.py, _vendor_freeipa_vault.py Removed: 2026-05-14 Reason: FreeIPA phases were implemented against the schema spec but never run against a live FreeIPA instance. Removing to keep the active sensor lean while the core is being stabilised. FreeIPA is an actively deployed product — reinstatement is higher priority than Oracle or eDirectory.
OTP / TOTP / HOTP token secrets
| Attribute | Type | What it holds |
|---|---|---|
ipaTokenOTPkey | Raw bytes | HMAC symmetric key material |
ipaTokenOTPalgorithm | String | SHA1 / SHA256 / SHA512 — algorithm for the HMAC key |
ipaTokenOTPtype | String | totp / hotp — token type |
ipaTokenOTPdigits | String | 6 / 8 — OTP digit count |
ipatokenuniqueid | String | Unique token identifier — stored in sourceProperties |
Why vendor code is required: ipaTokenOTPkey is raw bytes with no self-describing header. The algorithm comes from ipaTokenOTPalgorithm — a separate attribute. custom_attributes reads one attribute at a time and cannot perform this correlation. Without the sibling attribute, algorithm is unknown.
enctype → algorithm mapping used:
| ipaTokenOTPalgorithm | CBOM algorithm | algorithmRef | size |
|---|---|---|---|
SHA1 | HMAC-SHA1 | HMAC-SHA1 | 160 |
SHA256 | HMAC-SHA256 | HMAC-SHA256 | 256 |
SHA512 | HMAC-SHA512 | HMAC-SHA512 | 512 |
Filter:
(ipaTokenOTPkey=*)secured_by = "FreeIPA",state = "Active"discoverID= UUID5 seeded fromldap::otp::{host}::{dn}::{sha256}
RADIUS shared secrets
| Attribute | Type | What it holds |
|---|---|---|
ipaTokenRadiusSecret | Raw bytes | RADIUS HMAC shared secret — no header, no algorithm tag |
ipaTokenRadiusServer | String | RADIUS server hostname/IP — stored in sourceProperties |
ipaTokenRadiusUserName | String | RADIUS username — stored in sourceProperties |
Why vendor code is required: ipaTokenRadiusSecret is raw symmetric bytes with no self-describing format. parse() would produce nothing or SIZE_HEURISTIC at best. The vendor phase gives it meaningful context: algorithm="RADIUS-SharedSecret", algorithmRef="RADIUS/SharedSecret", secured_by="FreeIPA".
Filter:
(ipaTokenRadiusSecret=*)Size =
len(secret_bytes) * 8bitsdiscoverID= UUID5 seeded fromldap::radius::{host}::{dn}::{sha256}
Vault keys
| Attribute | Type | What it holds |
|---|---|---|
ipaVaultPublicKey | DER bytes | RSA / EC public key for asymmetric vaults — parseable by EC_Libs.parser |
ipaVaultSalt | Raw bytes | Salt for symmetric vault encryption |
ipaVaultType | String | standard / symmetric / asymmetric — vault type context |
Note: ipaVaultPublicKey is standard DER and could technically be covered by custom_attributes. However ipaVaultType context (which vault type this key belongs to) comes from a sibling attribute — the vendor phase correlates them per entry. ipaVaultSalt is domain-specific salt material; parse() would not recognise it as crypto material.
Filter:
(|(ipaVaultPublicKey=*)(ipaVaultSalt=*))ipaVaultSaltreported assecret_key,algorithm="AES",algorithmRef="AES/VaultSalt", size =len(salt) * 8discoverID= UUID5 seeded fromldap::vault::pubkey::{host}::{dn}::{sha256}orldap::vault::salt::{host}::{dn}::{sha256}
To reinstate
Restore
_vendor_freeipa_otp.py,_vendor_freeipa_radius.py,_vendor_freeipa_vault.pyfrom git history-
In
ldap_sensor.py:from . import _vendor_freeipa_otp as _phase_otp from . import _vendor_freeipa_radius as _phase_radius from . import _vendor_freeipa_vault as _phase_vault Change
VENDOR_PHASES[FREEIPA]from[]to[_phase_otp, _phase_radius, _phase_vault]Test against a live FreeIPA instance — these phases were never run on real infrastructure
6d — Kerberos key scanning (FreeIPA / MIT KDC)
Detection: Ran as a shared phase for FreeIPA only Removed file: _shared_krb_keys_freeipa.py Removed: 2026-05-14 Also removed: KRB_ENCTYPES dict from _helpers.py (was only used by this phase) Reason: Never tested on a live FreeIPA or MIT KDC server. The ASN.1 blob parsing is non-trivial (relies on pyasn1 with a fallback byte scan). Removed alongside FreeIPA vendor phases.
Attributes targeted
| Attribute | Type | What it holds |
|---|---|---|
krbPrincipalKey | ASN.1 blob | Encrypted Kerberos session keys — one value per enctype per principal |
krbMKey | Raw blob | Kerberos master key — encrypts all principal keys in the KDC database |
Implementation notes
Filter:
(|(krbPrincipalKey=*)(krbMKey=*))krbPrincipalKeyis an MIT KDB ASN.1 envelope:SEQUENCE OF EncryptionKey { keytype [0] INTEGER, keyvalue [1] OCTET STRING }. Multiple DER structs may be concatenated in one attribute valueEnctype extraction: try pyasn1 BER decode first; fall back to a byte scan for
0x02 <len> <val>patterns wherevalis a known enctype integerkrbMKeyis the KDC master key — reported asAES/KerberosMasterKey; actual algorithm not inferrable from the blob aloneKey material is always encrypted under the KDC master key — never extractable
Enctype → algorithm mapping:
| Enctype int | Name | CBOM algorithm | size |
|---|---|---|---|
| 1 | DES-CBC-CRC | DES | 56 |
| 3 | DES-CBC-MD5 | DES | 56 |
| 17 | AES128-CTS-HMAC-SHA1-96 | AES | 128 |
| 18 | AES256-CTS-HMAC-SHA1-96 | AES | 256 |
| 23 | RC4-HMAC | RC4 | 128 |
secured_by = "Kerberos KDC",state = "Active"discoverID= UUID5 seeded fromldap::krb::{host}::{dn}::enctype{n}
To reinstate
Restore
_shared_krb_keys_freeipa.pyfrom git historyRestore
KRB_ENCTYPESdict to_helpers.py(the 5-entry enctype table above)In
ldap_sensor.py: addfrom . import _shared_krb_keys_freeipa as _phase_krbChange
SHARED_PHASES[FREEIPA]from[_phase_ssh_generic]to[_phase_ssh_generic, _phase_krb]Test on a live FreeIPA server before declaring working
7 — What the sensor currently emits
CBOM type | keyType | Source | Notes |
|---|---|---|---|
certificate | — | userCertificate, cACertificate, userSMIMECertificate, crossCertificatePair, TLS handshake | subject, issuer, serialNumber, validFrom, validTo, algorithm, publicKey (b64), certSha256 |
key | public_key | SSH keys (altSecurityIdentities, sshPublicKey, ipaSshPubKey) | SSH wire format — RSA / ECDSA / Ed25519 |
key | private_key | PKCS#12 bundles (userPKCS12) | Existence + algorithm; material never extracted |
key | secret_key | PKCS#12 symmetric keys | Existence + size; material never extracted |
key | any | Custom attributes (user-declared) | Format and type auto-detected by EC_Libs.parser |
status | — | End of each target scan | vendor, phase_counts, total, errors |
8 — Phase execution order
doDiscover(config)
│
│ ── per target ──────────────────────────────────────────────────────────
│
├── connect() _connection.connect()
│ auto-detects LDAPS/636 → StartTLS/389 → plain/389
│
├── resolve base DNs base_dn supplied → use as-is
│ base_dn empty → _connection.get_naming_contexts()
│ reads Root DSE; filters out AD
│ infrastructure partitions
│
├── _phase_server_tls runs once (TLS handshake, ignores base_dn)
│
└── per base DN ─────────────────────────────────────────────────────────
├── _phase_stored_certs userCertificate, cACertificate, userSMIMECertificate,
│ crossCertificatePair, userPKCS12
│ Phase 1: CRL/ARL scan → populates revoked_serials set
│ stored as (issuer_dn, colon-hex-serial) tuples
│ (RFC 5280 §5.1 — serials are unique per issuer only)
│ Phase 2: cert scan → emits certificate + public_key docs
│
├── _phase_ssh_keys altSecurityIdentities, sshPublicKey, ipaSshPubKey
│ (one LDAP search covers all three attributes)
│
├── _phase_custom_attrs user-declared attributes via custom_attributes /
│ (if configured) custom_protected_attributes config keys
│ EC_Libs.parser auto-detects format and type
│
└── status doc one per target — phase_counts, total, errors
Memory model: _connection.search() is a generator — entries are never accumulated in a Python list. Each phase iterates directly over the generator, processing and discarding one entry at a time. Results are written to a ListOnDisk (SQLite-backed, PRAGMA synchronous=OFF) so the full result set never lives in RAM regardless of directory size.
9 — sourceProperties — fields on every document
| Field | Type | Description |
|---|---|---|
host | string | IP / hostname of the LDAP server scanned |
distinguishedName | string | Full DN of the LDAP entry containing the material |
baseDn | string | Search base DN used for this scan |
ldapAttribute | string | Attribute that contained the crypto material |
10 — Configuration reference
Full annotated example: DiscoveryManager/Sensors/discover_ldap/ConfigExample.yaml
All config keys
| Key | Type | Default | Description |
|---|---|---|---|
host | string | — | Hostname or IP of the LDAP server |
port | int | 0 | 0 = auto-detect (LDAPS/636 → StartTLS/389 → plain/389) |
use_ssl | bool | true | Use LDAPS. Ignored when port=0 |
verify_ssl | bool | true | Validate server TLS cert against trusted CAs. Disable only for lab servers with self-signed certs |
ca_cert_path | string | "" | Path to a PEM CA certificate (or bundle) used to verify the server cert. Only used when verify_ssl: true. Leave blank to use the system trust store. Useful for internal/self-signed CAs without importing them into the OS store |
auth_method | string | "simple" | simple / anonymous / ntlm / sasl |
username | string | "" | Full bind DN (simple/NTLM) or username (SASL) |
password | string | "" | Bind password |
base_dn | string | "" | Search base. Empty = auto-discover from Root DSE |
connect_timeout | int | 10 | TCP + TLS handshake timeout in seconds |
search_timeout | int | 30 | Per-operation LDAP search timeout in seconds |
page_size | int | 1000 | Entries per LDAP round-trip (RFC 2696 Simple Paged Results). AD enforces a hard server-side cap of 1000 — setting higher has no effect on AD. Reduce to 250–500 for slow or throttled servers; increase beyond 1000 for OpenLDAP/FreeIPA bulk scans |
pkcs12_passwords | list | [] | Passwords tried in order when opening encrypted userPKCS12 bundles |
custom_attributes | list | [] | User-declared unprotected attributes to scan |
custom_protected_attributes | list | [] | User-declared password-protected attributes to scan |
Minimal — single OpenLDAP server
ExtName: Discover_LDAP
host: ldap.corp.example.com
port: 636
use_ssl: true
verify_ssl: true
auth_method: simple
username: "CN=svc-ldap,OU=ServiceAccounts,DC=corp,DC=example,DC=com"
password: "s3cr3t!"
base_dn: "DC=corp,DC=example,DC=com"
With PKCS#12 passwords, custom attributes, and tuned page size
ExtName: Discover_LDAP
host: ldap.corp.example.com
port: 636
use_ssl: true
verify_ssl: true
ca_cert_path: /etc/ssl/certs/internal-ca.pem # pin internal CA; omit to use OS trust store
auth_method: simple
username: "cn=admin,dc=corp,dc=example,dc=com"
password: "s3cr3t!"
base_dn: "dc=corp,dc=example,dc=com"
page_size: 500 # reduce if the server throttles large pages
pkcs12_passwords:
- "changeit"
- "export123"
- ""
custom_attributes:
- name: pgpKey
- name: myInternalCert
- name: mySSHKey
- name: myAESKey
type: secret_key # hint required — raw bytes have no detectable structure
algorithm: AES
key_size: "256"
custom_protected_attributes:
- name: companyPKCS12
passwords:
- "export123"
- "changeit"
- ""
- name: myHMACKey
type: secret_key # hint required for raw symmetric bytes in protected attrs too
algorithm: HMAC-SHA256
key_size: "256"
passwords:
- "vaultpass"
- ""
Multi-host
ExtName: Discover_LDAP
username: "cn=admin,dc=corp,dc=example,dc=com"
password: "s3cr3t!"
auth_method: simple
page_size: 1000
hosts:
- host: ldap1.corp.example.com
port: 636
use_ssl: true
base_dn: "dc=corp,dc=example,dc=com"
- host: ldap2.corp.example.com
port: 636
use_ssl: true
base_dn: "dc=eu,dc=corp,dc=example,dc=com"
11 — Known limitations
| Limitation | Detail |
|---|---|
| Custom attrs — symmetric key hint | Raw symmetric key bytes have no self-describing header. A raw AES key in a custom attribute requires type: secret_key in config; otherwise parse() returns nothing. |
verify_ssl: true | Server cert must be trusted. For internal / self-signed CAs, set ca_cert_path to the PEM CA cert file instead of disabling verification entirely — verify_ssl: false should be used only in throwaway lab environments. |
krbPrincipalKey material | Encrypted under KDC master key — never extractable. Existence + enctype reported only. (Phase currently removed — see §6d.) |
| FreeIPA OTP / RADIUS key bytes | Raw symmetric bytes — existence and size only; material never extracted. (Phases currently removed — see §6c.) |
12 — Permanent out-of-scope
| Item | Reason |
|---|---|
| NTLM password hashes | Never exposed over LDAP |
| Kerberos TGT key bytes | Encrypted under KDC master key — not extractable via LDAP |
| NICI private key unwrapping | Proprietary Novell format — no open specification or SDK |
| Certificate template analysis | Blueprints only — classification is CBOM server's job |
| Password policy objects | Policy rules, not key material |
13 — Performance benchmarks
Measured on a local OpenLDAP container (osixia/openldap) seeded with 10,002 entries (one userCertificate each → 2 CBOM docs per entry = 20,004 crypto docs + 1 status doc). Host: Windows 11 dev laptop, Python 3.13, page_size=1000.
| Metric | Run 1 | Run 2 |
|---|---|---|
| Total wall time | 562.75 s | 574.79 s |
| Throughput | 35.5 docs/s | 34.8 docs/s |
| LDAP search calls | 7 | 7 |
| LDAP entries streamed | 10,002 | 10,002 |
| Total docs returned | 20,005 | 20,005 |
| — certificate | 10,002 | 10,002 |
| — key | 10,002 | 10,002 |
| — status | 1 | 1 |
| RSS before scan | 47.3 MB | 48.5 MB |
| RSS after scan | 80.9 MB | 77.7 MB |
| RSS peak (sampled 100 ms) | 137.0 MB | 134.5 MB |
| Python heap peak (tracemalloc) | 16.0 MB | 15.9 MB |
| Net RSS delta | +33.5 MB | +29.2 MB |
Phase breakdown (Run 1):
| Phase | Base DN | ms | docs | searches | entries |
|---|---|---|---|---|---|
| server_tls | — | 117 | 0 | 0 | 0 |
| stored_certs | dc=test,dc=local | 480,186 | 20,004 | 2 | 10,002 |
| shared_ssh_generic | dc=test,dc=local | 2 | 0 | 1 | 0 |
| custom_attrs | dc=test,dc=local | 73 | 0 | 4 | 0 |
Notes:
~95% of wall time is inside
stored_certs— dominated by the LDAP wire round-trips and cert parsing, not Python overheadPython heap stays flat at ~16 MB regardless of result set size —
ListOnDisk(SQLite-backed) keeps docs off the heapRSS peak of ~137 MB includes the SQLite page cache; the net delta of ~33 MB is the actual sensor footprint
page_size=1000issues 2 searches for 10,002 entries instored_certs(CRL scan + cert scan); no full accumulation in RAM
2026-05-15 18:18:20,291 INFO discover_ldap.ldap_sensor [Discover_LDAP] scan complete host=localhost total=20005 errors=0
============================================================================== LDAP SENSOR -- BENCHMARK REPORT Host : localhost Base DN : dc=test,dc=local ==============================================================================
TIMING -------------------------------------------------------------------------- Total wall time 562.75 s Throughput 35.5 docs / sec Total LDAP search calls 7 Total LDAP entries streamed 10002
OUTPUT -------------------------------------------------------------------------- Total docs returned 20005 certificate 10002 key 10002 status 1
PHASES -------------------------------------------------------------------------- Phase Base DN ms docs searches entries -------------------------------------------------------------------------- server_tls 117 0 0 0 stored_certs dc=test,dc=local 480186 20004 2 10002 shared_ssh_generic dc=test,dc=local 2 0 1 0 custom_attrs dc=test,dc=local 73 0 4 0 -------------------------------------------------------------------------- TOTAL 480378 20004 7 10002
MEMORY -------------------------------------------------------------------------- Process RSS before scan 47.3 MB Process RSS after scan 80.9 MB Process RSS peak 137.0 MB (sampled every 100 ms) Python heap peak 16.0 MB (tracemalloc) Net RSS delta +33.5 MB
==============================================================================
8 minutes to emit, 20K records | 10K certs and 10K keys with a pageSize of 1K records
14 — Python dependencies
ldap3 >= 2.9
cryptography >= 41.0
EC_Libs.parser (internal — auto-detect + parse all crypto formats)
diskcache (internal — ListOnDisk SQLite-backed result store)
Optional (enables JKS parsing in custom_protected_attributes):
pyjks
Required for ldap_profiler.py (performance benchmarking tool):
psutil
pyyaml
Required if Kerberos phase is reinstated (§6d):
pyasn1
14 — Remaining work
| Priority | Item | Notes |
|---|---|---|
| 🔴 High | FreeIPA reinstatement + live test | Restore OTP / RADIUS / vault phases (§6c) and test on a real FreeIPA server — these were implemented against schema spec only, never run on live infrastructure |
| 🔴 High | Kerberos phase reinstatement + live test | Restore _shared_krb_keys_freeipa.py (§6d) — ASN.1 blob parsing needs validation on real FreeIPA / MIT KDC data |
| 🟡 Medium | Oracle Internet Directory | Restore _vendor_oracle_pki.py (§6a) when a customer OID engagement surfaces |
| 🟡 Medium | eDirectory / NetIQ | Restore _vendor_edirectory_nici.py (§6b) if a legacy eDirectory environment is in scope |
| 🟡 Medium | Schema validation on all emitted docs | Validate every emitted doc against Schema.json before appending to results |
| 🟢 Low | IBM Security Directory Server | Likely covered by common phases — verify with a real SDS instance |
| 🟢 Low | AD LDS evaluation | Determine if AD LDS should share phase code with discover_activedirectory |
| 🟢 Low | AD LDS evaluation | Determine if AD LDS should share phase code with discover_activedirectory |
Completed
| Item | Done |
|---|---|
ca_cert_path config key | ✅ Added to _connection.py, ldap_sensor.py, Schema.json, and §10 docs — allows pinning an internal CA cert for verify_ssl: true without importing into OS trust store |
| FIDO / U2F SSH key support | ✅ [email protected] and [email protected] now handled — whitelist gate in _parse_ssh_entry() removed; _parse_ssh_wire() is the authority |
| CRL revocation issuer-scoped | ✅ revoked_serials stores (issuer_dn, serial) tuples — RFC 5280 §5.1 compliance; prevents false positives when two CAs share a serial number |
type hint for custom_protected_attributes | ✅ Protected path now reads and forwards type_hint to parse() — required for raw symmetric key bytes; algorithm/key_size overrides were unreachable without it |
| Null-safety for YAML null values | ✅ page_size, connect_timeout, search_timeout use or-fallback idiom; passwords: null in custom_protected_attributes normalised to [None] |
| Phase architecture | ✅ Sensor split into _phase_server_tls, _phase_stored_certs, _phase_ssh_keys, _phase_custom_attrs; orchestrated by ldap_sensor.py |
| Base DN auto-discovery | ✅ _connection.get_naming_contexts() reads Root DSE; filters out AD infrastructure partitions |
| EC_Libs.parser integration | ✅ _phase_custom_attrs routes all custom attribute bytes through the shared parser library |
| SSH generic phase | ✅ _phase_ssh_keys.py covers altSecurityIdentities, sshPublicKey, ipaSshPubKey in one LDAP search |
| PKCS#12 password support | ✅ Passwords merged from pkcs12_passwords + custom_protected_attributes[userPKCS12] |
| Lab seeding scripts | ✅ lab-setup/seed-openldap.py, seed-openldap-ssh.ps1, seed-custom-attrs.py |
Generator streaming in search() | ✅ _connection.search() is a generator — entries yielded one at a time; no full result-set accumulation in RAM regardless of directory size |
ListOnDisk write performance | ✅ PRAGMA synchronous=OFF on the SQLite store — eliminates per-write fsyncs; turns ~480 s for 20 K docs into seconds |
Configurable page_size | ✅ page_size config key (default 1000) controls LDAP Simple Paged Results page size; threaded through all phases and exposed in Schema.json |
| Confidence scale 0–10 | ✅ EC_Libs.parser.confidence rescaled: PARSED_DIRECTLY=10 (never emitted), CERT_CONFIRMED=8, SIZE_HEURISTIC=5, ENCRYPTED_CERT_SIZE=2, EXISTS_ONLY=0 |
| Performance profiler | ✅ ldap_profiler.py — benchmarks wall time, per-phase timing, LDAP search calls, entries streamed, RSS peak, Python heap peak; outputs human-readable report or --json |
doDiscover() returns ListOnDisk | ✅ Disk-backed sealed reader; DiscoveryManager iterates and calls _close() to delete temp dir |
| FreeIPA OTP / RADIUS / vault phases | ✅ Implemented against spec — removed 2026-05-14; research preserved in §6c |
| Kerberos key phase | ✅ Implemented with pyasn1 + fallback byte scan — removed 2026-05-14; research preserved in §6d |
| Oracle + eDirectory cleanup | ✅ Removed 2026-05-14; research preserved in §6a, §6b |
| FreeIPA + Kerberos cleanup | ✅ Removed 2026-05-14; research preserved in §6c, §6d |