diff --git a/CHANGES b/CHANGES
index e6c11ef7..a2c90a92 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,4 +1,8 @@
+0.2.12 | 2021-05-19 14:16:26 +0200
+
+ * Add LDAP protocol analyzer. (Seth Grover)
+
0.2.11 | 2021-05-17 09:39:00 +0200
* Remove `analyzer_id` from scripts for IPSec. (Keith Jones)
diff --git a/README.md b/README.md
index 325ceabf..b9c5a225 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ Currently, the following analyzers are included:
- DNS [1]
- HTTP [1]
- IPSec
+- LDAP
- OpenVPN
- PNG
- Portable Executable (PE) [2]
diff --git a/analyzer/__load__.zeek b/analyzer/__load__.zeek
index 061ff90b..99b27b98 100644
--- a/analyzer/__load__.zeek
+++ b/analyzer/__load__.zeek
@@ -5,6 +5,7 @@
@load ./protocol/dns
@load ./protocol/http
@load ./protocol/ipsec
+@load ./protocol/ldap
@load ./protocol/openvpn
@load ./protocol/tftp
@load ./protocol/wireguard
diff --git a/analyzer/protocol/CMakeLists.txt b/analyzer/protocol/CMakeLists.txt
index 8f8b51a9..3d2c670e 100644
--- a/analyzer/protocol/CMakeLists.txt
+++ b/analyzer/protocol/CMakeLists.txt
@@ -4,6 +4,7 @@ add_subdirectory(dhcp)
add_subdirectory(dns)
add_subdirectory(http)
add_subdirectory(ipsec)
+add_subdirectory(ldap)
add_subdirectory(openvpn)
add_subdirectory(tftp)
add_subdirectory(wireguard)
diff --git a/analyzer/protocol/ldap/CMakeLists.txt b/analyzer/protocol/ldap/CMakeLists.txt
new file mode 100644
index 00000000..dcd42d00
--- /dev/null
+++ b/analyzer/protocol/ldap/CMakeLists.txt
@@ -0,0 +1,3 @@
+# Copyright (c) 2021 by the Zeek Project. See LICENSE for details.
+
+spicy_add_analyzer(LDAP ldap.spicy ldap_zeek.spicy ldap.evt)
diff --git a/analyzer/protocol/ldap/README.md b/analyzer/protocol/ldap/README.md
new file mode 100644
index 00000000..740d6fb9
--- /dev/null
+++ b/analyzer/protocol/ldap/README.md
@@ -0,0 +1,64 @@
+LDAP Analyzer
+=============
+
+Here's what it has:
+
+- ASN.1 structure decoding: this is probably generally useful for more than just the LDAP parser, so it may be of interest for this to be included somehow as part of spicy's standard modules or whatever
+ - everything is working except for the "constructed" forms of `ASN1BitString` and `ASN1OctetString`
+- LDAP: the LDAP parsing is basically "done once" through a single call to `ASN1Message` (which parses itself recursively) and then the application-level data is also parsed via `&parse-from` a byte array belonging to the outer ASN.1 sequence. This second level of parsing is also done using the ASN.1 data types.
+ - events
+ - `ldap::message` - called for each LDAP message
+ - `ldap::bindreq` - when a bind request is made
+ - `ldap::searchreq` - basic search request information
+ - `ldap::searchres` - called each time a search result is returned
+ - enums
+ - `ProtocolOpcode`
+ - `ResultCode`
+ - `BindAuthType`
+ - `SearchScope`
+ - `SearchDerefAlias`
+ - `FilterType`
+ - Zeek log files
+ - `ldap.log` - contains information about all LDAP messages except those that are search-related. Log lines are grouped by connection ID + message ID
+ - `ts` (time)
+ - `uid` (connection UID)
+ - `id` (connection ID 4-tuple)
+ - `proto` (transport protocol)
+ - `message_id` (LDAP message ID)
+ - `version` (LDAP version for bind requests)
+ - `opcode` (set of 1..n operations from this uid+message_id)
+ - `result` (set of 1..n results from this uid+message_id)
+ - `diagnostic_message` (vector of 0..n diagnostic message strings)
+ - `object` (vector of 0..n "objects," the meaning of which depends on the operation)
+ - `argument` (vector of 0..n "argument," the meaning of which depends on the operation)
+ - `ldap_search.log` - contains information about LDAP searches. Log lines are grouped by connection ID + message ID
+ - `ts` (time)
+ - `uid` (connection UID)
+ - `id` (connection ID 4-tuple)
+ - `proto` (transport protocol)
+ - `message_id` (LDAP message ID)
+ - `scope` (set of 1..n search scopes defined in this uid+message_id)
+ - `deref` (set of 1..n search deref alias options defined in this uid+message_id)
+ - `base_object` (vector of 0..n search base objects specified)
+ - `result_count` (number of result entries returned)
+ - `result` (set of 1..n results from this uid+message_id)
+ - `diagnostic_message` (vector of 0..n diagnostic message strings)
+ - test
+ - basic tests for detecting plugin presence and simple bind and search result/requests
+
+Here's what it doesn't have, which could be added by future parties interested in expanding it:
+
+- although LDAP can use UDP as transport, currently the analyzer only looks for `389/tcp, 3268/tcp`; this may be easy to change, but I don't have any examples of traffic to test it with so I haven't bothered
+- LDAP [referrals](https://tools.ietf.org/html/rfc4511#section-4.1.10) are not parsed out of the results
+- [SASL credentials](https://datatracker.ietf.org/doc/html/rfc4511#section-4.2) in bind requests are not being parsed beyond the mechanism string
+- SASL information in bind responses are not being parsed; for that matter, SASL-based LDAP stuff hasn't been tested much and may have issues
+- Search filters and attributes: while basic parsing is done in the `AttributeSelection`, `AttributeValueAssertion`, `SubstringFilter` and `SearchFilter` units, I'm not really pulling stuff out from the search filter tree (as the protocols allows arbitrarily complex combinations of AND, OR, substring, etc. that I don't think would be easily representable in a log file)
+- the details of `SearchResultReference` are not being parsed
+- the only detail of `ModifyRequest` being parsed is the object name
+- the details of `AddRequest` are not being parsed
+- the details of `ModDNRequest` are not being parsed
+- the details of `CompareRequest` are not being parsed
+- the details of `AbandonRequest` are not being parsed
+- the details of `ExtendedRequest` are not being parsed
+- the details of `ExtendedResponse` are not being parsed
+- the details of `IntermediateResponse` are not being parsed
diff --git a/analyzer/protocol/ldap/__load__.zeek b/analyzer/protocol/ldap/__load__.zeek
new file mode 100644
index 00000000..6a29e295
--- /dev/null
+++ b/analyzer/protocol/ldap/__load__.zeek
@@ -0,0 +1,3 @@
+# Copyright (c) 2021 by the Zeek Project. See LICENSE for details.
+
+@load ./main
diff --git a/analyzer/protocol/ldap/asn1.spicy b/analyzer/protocol/ldap/asn1.spicy
new file mode 100644
index 00000000..25ff6567
--- /dev/null
+++ b/analyzer/protocol/ldap/asn1.spicy
@@ -0,0 +1,286 @@
+# Copyright (c) 2021 by the Zeek Project. See LICENSE for details.
+
+module ASN1;
+
+###############################################################################
+# ASN.1 structure decoding
+#
+# A Layman's Guide to a Subset of ASN.1, BER, and DER
+# http://luca.ntop.org/Teaching/Appunti/asn1.html
+#
+# ASN.1 Tutorial from Computer Networks and Open Systems:
+# An Application Development Perspective
+# https://www.obj-sys.com/asn1tutorial/asn1only.html
+#
+# The ASN1JS tool (http://lapo.it/asn1js and https://github.com/lapo-luchini/asn1js)
+# is invaluable in debugging ASN.1
+###############################################################################
+
+import spicy;
+
+#- ASN.1 data types ----------------------------------------------------------
+# https://www.obj-sys.com/asn1tutorial/node124.html
+# https://www.obj-sys.com/asn1tutorial/node10.html
+
+public type ASN1Type = enum {
+ Boolean = 1,
+ Integer = 2,
+ BitString = 3,
+ OctetString = 4,
+ NullVal = 5,
+ ObjectIdentifier = 6,
+ ObjectDescriptor = 7,
+ InstanceOf = 8,
+ Real = 9,
+ Enumerated = 10,
+ EmbeddedPDV = 11,
+ UTF8String = 12,
+ RelativeOID = 13,
+ Sequence = 16,
+ Set = 17,
+ NumericString = 18,
+ PrintableString = 19,
+ TeletextString = 20,
+ VideotextString = 21,
+ IA5String = 22,
+ UTCTime = 23,
+ GeneralizedTime = 24,
+ GraphicString = 25,
+ VisibleString = 26,
+ GeneralString = 27,
+ UniversalString = 28,
+ CharacterString = 29,
+ BMPString = 30
+};
+
+#- ASN.1 data classes --------------------------------------------------------
+
+public type ASN1Class = enum {
+ Universal = 0,
+ Application = 1,
+ ContextSpecific = 2,
+ Private = 3
+};
+
+#- ASN.1 tag definition (including length) ------------------------------------
+
+type LengthType = unit {
+ var len: uint64;
+ var tag_len: uint8;
+
+ data : bitfield(8) {
+ num: 0..6;
+ islong: 7;
+ };
+
+
+ switch ( self.data.islong ) {
+ 0 -> : void {
+ self.len = self.data.num;
+ self.tag_len = 1;
+ }
+ 1 -> : bytes &size=self.data.num
+ &convert=$$.to_uint(spicy::ByteOrder::Network) {
+ self.len = $$;
+ self.tag_len = self.data.num + 1;
+ }
+ };
+};
+
+type ASN1Tag = unit {
+ var type_: ASN1Type;
+ var class: ASN1Class;
+ var constructed: bool;
+
+ : bitfield(8) {
+ num: 0..4;
+ constructed: 5;
+ class: 6..7;
+ } {
+ self.type_ = ASN1Type($$.num);
+ self.class = ASN1Class($$.class);
+ self.constructed = cast($$.constructed);
+ }
+};
+
+#- ASN.1 bit string -----------------------------------------------------------
+# https://www.obj-sys.com/asn1tutorial/node10.html
+
+type ASN1BitString = unit(len: uint64, constructed: bool) {
+ : uint8; # unused bits
+ value_bits: bytes &size=(len - 1);
+
+ # TODO - constructed form
+ # https://github.com/zeek/spicy/issues/921
+ # `bytes` needs << and >> support before we can implement complex bitstrings
+ #
+};
+
+#- ASN.1 octet string ---------------------------------------------------------
+# https://www.obj-sys.com/asn1tutorial/node10.html
+
+type ASN1OctetString = unit(len: uint64, constructed: bool) {
+ value: bytes &size = len;
+
+ # TODO - constructed form
+};
+
+#- ASN.1 various string types -------------------------------------------------
+# https://www.obj-sys.com/asn1tutorial/node124.html
+
+type ASN1String = unit(tag: ASN1Tag, len: uint64) {
+ var value: string = "";
+
+ : ASN1OctetString(len, tag.constructed) {
+ switch ( tag.type_ ) {
+
+ # see "Restricted Character String Types" in
+ # "Generic String Encoding Rules (GSER) for ASN.1 Types"
+ # (https://datatracker.ietf.org/doc/html/rfc3641#section-3.2)
+
+ case ASN1Type::PrintableString,
+ ASN1Type::GeneralizedTime,
+ ASN1Type::UTCTime: {
+ self.value = $$.value.decode(hilti::Charset::ASCII);
+ }
+
+ case ASN1Type::UTF8String,
+ ASN1Type::GeneralString,
+ ASN1Type::CharacterString,
+ ASN1Type::GraphicString,
+ ASN1Type::IA5String,
+ ASN1Type::NumericString,
+ ASN1Type::TeletextString,
+ ASN1Type::VideotextString,
+ ASN1Type::VisibleString,
+ # TODO: RFC3641 mentions special UTF-8 mapping rules for
+ # BMPString and UniversalString. This *may* not be correct.
+ ASN1Type::BMPString,
+ ASN1Type::UniversalString: {
+ self.value = $$.value.decode(hilti::Charset::UTF8);
+ }
+ }
+ }
+};
+
+#- ASN.1 OID ------------------------------------------------------------------
+# https://www.obj-sys.com/asn1tutorial/node124.html
+
+type ASN1ObjectIdentifierNibble = unit {
+ data : bitfield(8) {
+ num: 0..6;
+ more: 7;
+ };
+} &convert=self.data;
+
+type ASN1ObjectIdentifier = unit(len: uint64) {
+ var oid: vector;
+ var temp: uint64;
+ var oidstring: string;
+
+ : uint8 if ( len >= 1 ) {
+ self.temp = $$ / 40;
+ self.oid.push_back( self.temp );
+ self.oidstring = "%d" % (self.temp);
+ self.temp = $$ % 40;
+ self.oid.push_back( self.temp );
+ self.oidstring = self.oidstring + ".%d" % (self.temp);
+ self.temp = 0;
+ }
+
+ sublist: ASN1ObjectIdentifierNibble[len - 1] foreach {
+ self.temp = ( self.temp<<7 ) | $$.num;
+ if ( $$.more != 1 ) {
+ self.oid.push_back(self.temp);
+ self.oidstring = self.oidstring + ".%d" % (self.temp);
+ self.temp = 0;
+ }
+ }
+};
+
+
+#- ASN.1 message header (tag + length information) ----------------------------
+
+public type ASN1Header = unit {
+ tag: ASN1Tag;
+ len: LengthType;
+};
+
+#- ASN.1 message body ---------------------------------------------------------
+
+public type ASN1Body = unit(head: ASN1Header, recursive: bool) {
+ switch ( head.tag.type_ ) {
+
+ ASN1Type::Boolean -> bool_value: uint8 &convert=cast($$) &requires=head.len.len==1;
+
+ ASN1Type::Integer,
+ ASN1Type::Enumerated -> num_value: bytes &size=head.len.len
+ &convert=$$.to_int(spicy::ByteOrder::Big);
+
+ ASN1Type::NullVal -> null_value: bytes &size=0 &requires=head.len.len==0;
+
+ ASN1Type::BitString -> bitstr_value: ASN1BitString(head.len.len, head.tag.constructed);
+
+ ASN1Type::OctetString -> str_value: ASN1OctetString(head.len.len, head.tag.constructed)
+ &convert=$$.value.decode(hilti::Charset::ASCII);
+
+ ASN1Type::ObjectIdentifier -> str_value: ASN1ObjectIdentifier(head.len.len)
+ &convert=$$.oidstring;
+
+ ASN1Type::BMPString,
+ ASN1Type::CharacterString,
+ ASN1Type::GeneralizedTime,
+ ASN1Type::GeneralString,
+ ASN1Type::GraphicString,
+ ASN1Type::IA5String,
+ ASN1Type::NumericString,
+ ASN1Type::PrintableString,
+ ASN1Type::TeletextString,
+ ASN1Type::UTCTime,
+ ASN1Type::UTF8String,
+ ASN1Type::VideotextString,
+ ASN1Type::VisibleString,
+ ASN1Type::UniversalString -> str_value: ASN1String(head.tag, head.len.len)
+ &convert=$$.value;
+
+ ASN1Type::Sequence, ASN1Type::Set -> seq: ASN1SubMessages(head.len.len) if (recursive);
+
+ # TODO: ASN1Type values not handled yet
+ ASN1Type::ObjectDescriptor,
+ ASN1Type::InstanceOf,
+ ASN1Type::Real,
+ ASN1Type::EmbeddedPDV,
+ ASN1Type::RelativeOID -> unimplemented_value: bytes &size=head.len.len;
+
+ # unknown (to me) ASN.1 enumeration, skip over silently
+ * -> unimplemented_value: bytes &size=head.len.len;
+ };
+};
+
+#- ASN.1 array of ASN.1 sequence/set sub-messages (up to msgLen bytes) --------
+
+public type ASN1SubMessages = unit(msgLen: uint64) {
+ submessages: ASN1Message(True)[] &eod;
+} &size=msgLen;
+
+#- ASN.1 message with header and body -----------------------------------------
+# Universal or Application/ContextSpecific/Private
+# - if Universal, body:ASN1Body is parsed
+# - else, application_data:bytes stores data array
+
+public type ASN1Message = unit(recursive: bool) {
+ var application_id: int32;
+
+ head: ASN1Header;
+ switch ( self.head.tag.class ) {
+
+ ASN1Class::Universal -> body: ASN1Body(self.head, recursive);
+
+ ASN1Class::Application,
+ ASN1Class::ContextSpecific,
+ ASN1Class::Private -> application_data: bytes &size=self.head.len.len {
+ self.application_id = cast(self.head.tag.type_);
+ }
+
+ };
+};
diff --git a/analyzer/protocol/ldap/ldap.evt b/analyzer/protocol/ldap/ldap.evt
new file mode 100644
index 00000000..bdaadc84
--- /dev/null
+++ b/analyzer/protocol/ldap/ldap.evt
@@ -0,0 +1,39 @@
+# Copyright (c) 2021 by the Zeek Project. See LICENSE for details.
+
+protocol analyzer spicy::LDAP_TCP over TCP:
+ parse with LDAP::Messages,
+ ports { 389/tcp, 3268/tcp};
+
+# TODO: LDAP can also use UDP transport
+
+import LDAP;
+import LDAP_Zeek;
+
+on LDAP::Message -> event LDAP::message($conn,
+ self.messageID,
+ self.opcode,
+ self.result.code,
+ self.result.matchedDN,
+ self.result.diagnosticMessage,
+ self.obj,
+ self.arg);
+
+on LDAP::BindRequest -> event LDAP::bindreq($conn,
+ message.messageID,
+ self.version,
+ self.name,
+ self.authType,
+ message.arg);
+
+on LDAP::SearchRequest -> event LDAP::searchreq($conn,
+ message.messageID,
+ self.baseObject,
+ self.scope,
+ self.deref,
+ self.sizeLimit,
+ self.timeLimit,
+ self.typesOnly);
+
+on LDAP::SearchResultEntry -> event LDAP::searchres($conn,
+ message.messageID,
+ self.objectName);
diff --git a/analyzer/protocol/ldap/ldap.spicy b/analyzer/protocol/ldap/ldap.spicy
new file mode 100644
index 00000000..8c260894
--- /dev/null
+++ b/analyzer/protocol/ldap/ldap.spicy
@@ -0,0 +1,536 @@
+# Copyright (c) 2021 by the Zeek Project. See LICENSE for details.
+
+module LDAP;
+
+import ASN1;
+
+# https://tools.ietf.org/html/rfc4511#
+# https://ldap.com/ldapv3-wire-protocol-reference-asn1-ber/
+# https://lapo.it/asn1js
+
+#- Operation opcode ----------------------------------------------------------
+public type ProtocolOpcode = enum {
+ BIND_REQUEST = 0,
+ BIND_RESPONSE = 1,
+ UNBIND_REQUEST = 2,
+ SEARCH_REQUEST = 3,
+ SEARCH_RESULT_ENTRY = 4,
+ SEARCH_RESULT_DONE = 5,
+ MODIFY_REQUEST = 6,
+ MODIFY_RESPONSE = 7,
+ ADD_REQUEST = 8,
+ ADD_RESPONSE = 9,
+ DEL_REQUEST = 10,
+ DEL_RESPONSE = 11,
+ MOD_DN_REQUEST = 12,
+ MOD_DN_RESPONSE = 13,
+ COMPARE_REQUEST = 14,
+ COMPARE_RESPONSE = 15,
+ ABANDON_REQUEST = 16,
+ SEARCH_RESULT_REFERENCE = 19,
+ EXTENDED_REQUEST = 23,
+ EXTENDED_RESPONSE = 24,
+ INTERMEDIATE_RESPONSE = 25,
+ # TODO: remove once zeek/spicy-plugin#35 is fixed
+ NOT_SET = 255
+};
+
+#- Result code ---------------------------------------------------------------
+public type ResultCode = enum {
+ SUCCESS = 0,
+ OPERATIONS_ERROR = 1,
+ PROTOCOL_ERROR = 2,
+ TIME_LIMIT_EXCEEDED = 3,
+ SIZE_LIMIT_EXCEEDED = 4,
+ COMPARE_FALSE = 5,
+ COMPARE_TRUE = 6,
+ AUTH_METHOD_NOT_SUPPORTED = 7,
+ STRONGER_AUTH_REQUIRED = 8,
+ PARTIAL_RESULTS = 9,
+ REFERRAL = 10,
+ ADMIN_LIMIT_EXCEEDED = 11,
+ UNAVAILABLE_CRITICAL_EXTENSION = 12,
+ CONFIDENTIALITY_REQUIRED = 13,
+ SASL_BIND_IN_PROGRESS = 14,
+ NO_SUCH_ATTRIBUTE = 16,
+ UNDEFINED_ATTRIBUTE_TYPE = 17,
+ INAPPROPRIATE_MATCHING = 18,
+ CONSTRAINT_VIOLATION = 19,
+ ATTRIBUTE_OR_VALUE_EXISTS = 20,
+ INVALID_ATTRIBUTE_SYNTAX = 21,
+ NO_SUCH_OBJECT = 32,
+ ALIAS_PROBLEM = 33,
+ INVALID_DNSYNTAX = 34,
+ ALIAS_DEREFERENCING_PROBLEM = 36,
+ INAPPROPRIATE_AUTHENTICATION = 48,
+ INVALID_CREDENTIALS = 49,
+ INSUFFICIENT_ACCESS_RIGHTS = 50,
+ BUSY = 51,
+ UNAVAILABLE = 52,
+ UNWILLING_TO_PERFORM = 53,
+ LOOP_DETECT = 54,
+ SORT_CONTROL_MISSING = 60,
+ OFFSET_RANGE_ERROR = 61,
+ NAMING_VIOLATION = 64,
+ OBJECT_CLASS_VIOLATION = 65,
+ NOT_ALLOWED_ON_NON_LEAF = 66,
+ NOT_ALLOWED_ON_RDN = 67,
+ ENTRY_ALREADY_EXISTS = 68,
+ OBJECT_CLASS_MODS_PROHIBITED = 69,
+ RESULTS_TOO_LARGE = 70,
+ AFFECTS_MULTIPLE_DSAS = 71,
+ CONTROL_ERROR = 76,
+ OTHER = 80,
+ SERVER_DOWN = 81,
+ LOCAL_ERROR = 82,
+ ENCODING_ERROR = 83,
+ DECODING_ERROR = 84,
+ TIMEOUT = 85,
+ AUTH_UNKNOWN = 86,
+ FILTER_ERROR = 87,
+ USER_CANCELED = 88,
+ PARAM_ERROR = 89,
+ NO_MEMORY = 90,
+ CONNECT_ERROR = 91,
+ NOT_SUPPORTED = 92,
+ CONTROL_NOT_FOUND = 93,
+ NO_RESULTS_RETURNED = 94,
+ MORE_RESULTS_TO_RETURN = 95,
+ CLIENT_LOOP = 96,
+ REFERRAL_LIMIT_EXCEEDED = 97,
+ INVALID_RESPONSE = 100,
+ AMBIGUOUS_RESPONSE = 101,
+ TLS_NOT_SUPPORTED = 112,
+ INTERMEDIATE_RESPONSE = 113,
+ UNKNOWN_TYPE = 114,
+ LCUP_INVALID_DATA = 115,
+ LCUP_UNSUPPORTED_SCHEME = 116,
+ LCUP_RELOAD_REQUIRED = 117,
+ CANCELED = 118,
+ NO_SUCH_OPERATION = 119,
+ TOO_LATE = 120,
+ CANNOT_CANCEL = 121,
+ ASSERTION_FAILED = 122,
+ AUTHORIZATION_DENIED = 123,
+ # TODO: remove once zeek/spicy-plugin#35 is fixed
+ NOT_SET = 255
+};
+
+#-----------------------------------------------------------------------------
+public type Result = unit {
+ code: ASN1::ASN1Message(True) &convert=cast(cast($$.body.num_value))
+ &default=ResultCode::NOT_SET;
+ matchedDN: ASN1::ASN1Message(True) &convert=$$.body.str_value
+ &default="";
+ diagnosticMessage: ASN1::ASN1Message(True) &convert=$$.body.str_value
+ &default="";
+
+ # TODO: if we want to parse referral URIs in result
+ # https://tools.ietf.org/html/rfc4511#section-4.1.10
+};
+
+#-----------------------------------------------------------------------------
+public type Messages = unit {
+ : Message[];
+};
+
+public type Message = unit {
+ var messageID: int64;
+ var opcode: ProtocolOpcode = ProtocolOpcode::NOT_SET;
+ var applicationBytes: bytes;
+ var unsetResultDefault: Result;
+ var result: Result& = self.unsetResultDefault;
+ var obj: string = "";
+ var arg: string = "";
+
+ : ASN1::ASN1Message(True) {
+ if (($$.head.tag.type_ == ASN1::ASN1Type::Sequence) &&
+ ($$.body?.seq) &&
+ (|$$.body.seq.submessages| >= 2)) {
+ if ($$.body.seq.submessages[0].body?.num_value) {
+ self.messageID = $$.body.seq.submessages[0].body.num_value;
+ }
+ if ($$.body.seq.submessages[1]?.application_id) {
+ self.opcode = cast(cast($$.body.seq.submessages[1].application_id));
+ self.applicationBytes = $$.body.seq.submessages[1].application_data;
+ }
+ }
+ }
+
+ switch ( self.opcode ) {
+ ProtocolOpcode::BIND_REQUEST -> BIND_REQUEST: BindRequest(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::BIND_RESPONSE -> BIND_RESPONSE: BindResponse(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::UNBIND_REQUEST -> UNBIND_REQUEST: UnbindRequest(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::SEARCH_REQUEST -> SEARCH_REQUEST: SearchRequest(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::SEARCH_RESULT_ENTRY -> SEARCH_RESULT_ENTRY: SearchResultEntry(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::SEARCH_RESULT_DONE -> SEARCH_RESULT_DONE: SearchResultDone(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::MODIFY_REQUEST -> MODIFY_REQUEST: ModifyRequest(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::MODIFY_RESPONSE -> MODIFY_RESPONSE: ModifyResponse(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::ADD_RESPONSE -> ADD_RESPONSE: AddResponse(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::DEL_REQUEST -> DEL_REQUEST: DelRequest(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::DEL_RESPONSE -> DEL_RESPONSE: DelResponse(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::MOD_DN_RESPONSE -> MOD_DN_RESPONSE: ModDNResponse(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::COMPARE_RESPONSE -> COMPARE_RESPONSE: CompareResponse(self) &parse-from=self.applicationBytes;
+ ProtocolOpcode::ABANDON_REQUEST -> ABANDON_REQUEST: AbandonRequest(self) &parse-from=self.applicationBytes;
+
+ # TODO: not yet implemented
+ # ProtocolOpcode::ADD_REQUEST -> ADD_REQUEST: AddRequest(self) &parse-from=self.applicationBytes;
+ # ProtocolOpcode::COMPARE_REQUEST -> COMPARE_REQUEST: CompareRequest(self) &parse-from=self.applicationBytes;
+ # ProtocolOpcode::EXTENDED_REQUEST -> EXTENDED_REQUEST: ExtendedRequest(self) &parse-from=self.applicationBytes;
+ # ProtocolOpcode::EXTENDED_RESPONSE -> EXTENDED_RESPONSE: ExtendedResponse(self) &parse-from=self.applicationBytes;
+ # ProtocolOpcode::INTERMEDIATE_RESPONSE -> INTERMEDIATE_RESPONSE: IntermediateResponse(self) &parse-from=self.applicationBytes;
+ # ProtocolOpcode::MOD_DN_REQUEST -> MOD_DN_REQUEST: ModDNRequest(self) &parse-from=self.applicationBytes;
+ # ProtocolOpcode::SEARCH_RESULT_REFERENCE -> SEARCH_RESULT_REFERENCE: SearchResultReference(self) &parse-from=self.applicationBytes;
+ };
+ # TODO: add support for switch-level &parse-from/&parse-at
+ # https://github.com/zeek/spicy/issues/913
+} &requires=((self?.messageID) && (self?.opcode) && (self.opcode != ProtocolOpcode::NOT_SET));
+
+#-----------------------------------------------------------------------------
+# Bind Operation
+# https://tools.ietf.org/html/rfc4511#section-4.2
+
+public type BindAuthType = enum {
+ BIND_AUTH_SIMPLE = 0,
+ BIND_AUTH_SASL = 3,
+ # TODO: remove once zeek/spicy-plugin#35 is fixed
+ NOT_SET = 127
+};
+
+type SaslCredentials = unit() {
+ mechanism: ASN1::ASN1Message(True) &convert=$$.body.str_value;
+ # TODO: if we want to parse the (optional) credentials string
+};
+
+type BindRequest = unit(inout message: Message) {
+ version: ASN1::ASN1Message(True) &convert=$$.body.num_value;
+ name: ASN1::ASN1Message(True) &convert=$$.body.str_value {
+ message.obj = self.name;
+ }
+ var authType: BindAuthType = BindAuthType::NOT_SET;
+ var authData: bytes = b"";
+ var simpleCreds: string = "";
+
+ : ASN1::ASN1Message(True) {
+ if ($$?.application_id) {
+ self.authType = cast(cast($$.application_id));
+ self.authData = $$.application_data;
+ }
+ if ((self.authType == BindAuthType::BIND_AUTH_SIMPLE) && (|self.authData| > 0)) {
+ self.simpleCreds = self.authData.decode();
+ if (|self.simpleCreds| > 0) {
+ message.arg = self.simpleCreds;
+ }
+ }
+ }
+ saslCreds: SaslCredentials() &parse-from=self.authData if ((self.authType == BindAuthType::BIND_AUTH_SASL) &&
+ (|self.authData| > 0)) {
+ message.arg = self.saslCreds.mechanism;
+ }
+} &requires=((self?.authType) && (self.authType != BindAuthType::NOT_SET));
+
+type BindResponse = unit(inout message: Message) {
+ : Result {
+ message.result = $$;
+ }
+
+ # TODO: if we want to parse SASL credentials returned
+};
+
+#-----------------------------------------------------------------------------
+# Unbind Operation
+# https://tools.ietf.org/html/rfc4511#section-4.3
+
+type UnbindRequest = unit(inout message: Message) {
+ # this page intentionally left blank
+};
+
+#-----------------------------------------------------------------------------
+# Search Operation
+# https://tools.ietf.org/html/rfc4511#section-4.5
+
+public type SearchScope = enum {
+ SEARCH_BASE = 0,
+ SEARCH_SINGLE = 1,
+ SEARCH_TREE = 2,
+ # TODO: remove once zeek/spicy-plugin#35 is fixed
+ NOT_SET = 255
+};
+
+public type SearchDerefAlias = enum {
+ DEREF_NEVER = 0,
+ DEREF_IN_SEARCHING = 1,
+ DEREF_FINDING_BASE = 2,
+ DEREF_ALWAYS = 3,
+ # TODO: remove once zeek/spicy-plugin#35 is fixed
+ NOT_SET = 255
+};
+
+type FilterType = enum {
+ FILTER_AND = 0,
+ FILTER_OR = 1,
+ FILTER_NOT = 2,
+ FILTER_EQ = 3,
+ FILTER_SUBSTR = 4,
+ FILTER_GE = 5,
+ FILTER_LE = 6,
+ FILTER_PRESENT = 7,
+ FILTER_APPROX = 8,
+ FILTER_EXT = 9,
+ FILTER_INVALID = 254,
+ # TODO: remove once zeek/spicy-plugin#35 is fixed
+ NOT_SET = 255
+};
+
+type AttributeSelection = unit {
+ var attributes: vector;
+
+ # TODO: parse AttributeSelection as per
+ # https://tools.ietf.org/html/rfc4511#section-4.5.1
+ # and decide how deep that should be fleshed out.
+ : ASN1::ASN1Message(True) {
+ if (($$.head.tag.type_ == ASN1::ASN1Type::Sequence) &&
+ ($$.body?.seq)) {
+ for (i in $$.body.seq.submessages) {
+ if (i.body?.str_value) {
+ self.attributes.push_back(i.body.str_value);
+ }
+ }
+ }
+ }
+};
+
+type AttributeValueAssertion = unit {
+ var desc: string = "";
+ var val: string = "";
+
+ : ASN1::ASN1Message(True) {
+ if (($$.head.tag.type_ == ASN1::ASN1Type::Sequence) &&
+ ($$.body?.seq) &&
+ (|$$.body.seq.submessages| >= 2)) {
+ if ($$.body.seq.submessages[0].body?.str_value) {
+ self.desc = $$.body.seq.submessages[0].body.str_value;
+ }
+ if ($$.body.seq.submessages[1].body?.str_value) {
+ self.val = $$.body.seq.submessages[1].body.str_value;
+ }
+ }
+ }
+};
+
+type SubstringFilter = unit {
+ var ftype: string = "";
+ var substrings: ASN1::ASN1Message;
+
+ : ASN1::ASN1Message(True) {
+ if (($$.head.tag.type_ == ASN1::ASN1Type::Sequence) &&
+ ($$.body?.seq) &&
+ (|$$.body.seq.submessages| >= 2)) {
+ if ($$.body.seq.submessages[0].body?.str_value) {
+ self.ftype = $$.body.seq.submessages[0].body.str_value;
+ }
+ if ($$.body.seq.submessages[1].head.tag.type_ == ASN1::ASN1Type::Sequence) {
+ self.substrings = $$.body.seq.submessages[1];
+ }
+ }
+ # TODO: if we want to descend deeper into the substrings filter
+ # if (self?.substrings) {
+ #
+ #}
+ }
+};
+
+type SearchFilter = unit {
+ var filterType: FilterType = FilterType::NOT_SET;
+ var filterBytes: bytes = b"";
+ var filterLen: uint64 = 0;
+
+ : ASN1::ASN1Message(True) {
+ if ($$?.application_id) {
+ self.filterType = cast(cast($$.application_id));
+ self.filterBytes = $$.application_data;
+ self.filterLen = $$.head.len.len;
+ } else {
+ self.filterType = FilterType::FILTER_INVALID;
+ }
+ }
+
+ # TODO: parse search request filter as per
+ # https://tools.ietf.org/html/rfc4511#section-4.5.1.7
+ # This descent gets pretty involved... I wonder what is
+ # the best way to represent this as a string in a log.
+ # I've just left some of them as ASN1::ASN1Message for now.
+
+ switch ( self.filterType ) {
+ FilterType::FILTER_AND -> FILTER_AND: ASN1::ASN1Message(True)
+ &parse-from=self.filterBytes;
+ FilterType::FILTER_OR -> FILTER_OR: ASN1::ASN1Message(True)
+ &parse-from=self.filterBytes;
+ FilterType::FILTER_NOT -> FILTER_NOT: SearchFilter()
+ &parse-from=self.filterBytes;
+ FilterType::FILTER_EQ -> FILTER_EQ: AttributeValueAssertion()
+ &parse-from=self.filterBytes;
+ FilterType::FILTER_SUBSTR -> FILTER_SUBSTR: SubstringFilter()
+ &parse-from=self.filterBytes;
+ FilterType::FILTER_GE -> FILTER_GE: AttributeValueAssertion()
+ &parse-from=self.filterBytes;
+ FilterType::FILTER_LE -> FILTER_LE: AttributeValueAssertion()
+ &parse-from=self.filterBytes;
+ FilterType::FILTER_PRESENT -> FILTER_PRESENT: ASN1::ASN1OctetString(self.filterLen, False)
+ &convert=$$.value.decode(hilti::Charset::ASCII)
+ &parse-from=self.filterBytes;
+ FilterType::FILTER_APPROX -> FILTER_APPROX: AttributeValueAssertion()
+ &parse-from=self.filterBytes;
+ FilterType::FILTER_EXT -> FILTER_EXT: ASN1::ASN1Message(True)
+ &parse-from=self.filterBytes;
+ };
+};
+
+type SearchRequest = unit(inout message: Message) {
+ baseObject: ASN1::ASN1Message(True) &convert=$$.body.str_value {
+ message.obj = self.baseObject;
+ }
+ scope: ASN1::ASN1Message(True) &convert=cast(cast($$.body.num_value))
+ &default=SearchScope::NOT_SET {
+ message.arg = "%s" % self.scope;
+ }
+ deref: ASN1::ASN1Message(True) &convert=cast(cast($$.body.num_value))
+ &default=SearchDerefAlias::NOT_SET;
+ sizeLimit: ASN1::ASN1Message(True) &convert=$$.body.num_value &default=0;
+ timeLimit: ASN1::ASN1Message(True) &convert=$$.body.num_value &default=0;
+ typesOnly: ASN1::ASN1Message(True) &convert=$$.body.bool_value &default=False;
+ filter: SearchFilter;
+ attributes: AttributeSelection;
+};
+
+type SearchResultEntry = unit(inout message: Message) {
+ objectName: ASN1::ASN1Message(True) &convert=$$.body.str_value {
+ message.obj = self.objectName;
+ }
+ # TODO: if we want to descend down into PartialAttributeList
+ attributes: ASN1::ASN1Message(True);
+};
+
+type SearchResultDone = unit(inout message: Message) {
+ : Result {
+ message.result = $$;
+ }
+};
+
+# TODO: implement SearchResultReference
+# type SearchResultReference = unit(inout message: Message) {
+#
+# };
+
+#-----------------------------------------------------------------------------
+# Modify Operation
+# https://tools.ietf.org/html/rfc4511#section-4.6
+
+type ModifyRequest = unit(inout message: Message) {
+ objectName: ASN1::ASN1Message(True) &convert=$$.body.str_value {
+ message.obj = self.objectName;
+ }
+
+ # TODO: parse changes
+};
+
+type ModifyResponse = unit(inout message: Message) {
+ : Result {
+ message.result = $$;
+ }
+};
+
+#-----------------------------------------------------------------------------
+# Add Operation
+# https://tools.ietf.org/html/rfc4511#section-4.7
+
+# TODO: implement AddRequest
+# type AddRequest = unit(inout message: Message) {
+#
+#
+# };
+
+type AddResponse = unit(inout message: Message) {
+ : Result {
+ message.result = $$;
+ }
+};
+
+#-----------------------------------------------------------------------------
+# Delete Operation
+# https://tools.ietf.org/html/rfc4511#section-4.8
+
+type DelRequest = unit(inout message: Message) {
+ objectName: ASN1::ASN1Message(True) &convert=$$.body.str_value {
+ message.obj = self.objectName;
+ }
+};
+
+type DelResponse = unit(inout message: Message) {
+ : Result {
+ message.result = $$;
+ }
+};
+
+#-----------------------------------------------------------------------------
+# Modify DN Operation
+# https://tools.ietf.org/html/rfc4511#section-4.8
+
+# TODO: implement ModDNRequest
+# type ModDNRequest = unit(inout message: Message) {
+#
+# };
+
+type ModDNResponse = unit(inout message: Message) {
+ : Result {
+ message.result = $$;
+ }
+};
+
+#-----------------------------------------------------------------------------
+# Compare Operation
+# https://tools.ietf.org/html/rfc4511#section-4.10
+
+# TODO: implement CompareRequest
+# type CompareRequest = unit(inout message: Message) {
+#
+# };
+
+type CompareResponse = unit(inout message: Message) {
+ : Result {
+ message.result = $$;
+ }
+};
+
+#-----------------------------------------------------------------------------
+# Abandon Operation
+# https://tools.ietf.org/html/rfc4511#section-4.11
+
+type AbandonRequest = unit(inout message: Message) {
+ messageID: ASN1::ASN1Message(True) &convert=$$.body.num_value {
+ message.obj = "%d" % (self.messageID);
+ }
+};
+
+#-----------------------------------------------------------------------------
+# Extended Operation
+# https://tools.ietf.org/html/rfc4511#section-4.12
+
+# TODO: implement ExtendedRequest
+# type ExtendedRequest = unit(inout message: Message) {
+#
+# };
+
+# TODO: implement ExtendedResponse
+# type ExtendedResponse = unit(inout message: Message) {
+#
+# };
+
+#-----------------------------------------------------------------------------
+# IntermediateResponse Message
+# https://tools.ietf.org/html/rfc4511#section-4.13
+
+# TODO: implement IntermediateResponse
+# type IntermediateResponse = unit(inout message: Message) {
+#
+# };
diff --git a/analyzer/protocol/ldap/ldap_zeek.spicy b/analyzer/protocol/ldap/ldap_zeek.spicy
new file mode 100644
index 00000000..bc5a0228
--- /dev/null
+++ b/analyzer/protocol/ldap/ldap_zeek.spicy
@@ -0,0 +1,14 @@
+# Copyright (c) 2021 by the Zeek Project. See LICENSE for details.
+
+module LDAP_Zeek;
+
+import zeek;
+import LDAP;
+
+on LDAP::Message::%done {
+ zeek::confirm_protocol();
+}
+
+on LDAP::Message::%error {
+ zeek::reject_protocol("error while parsing LDAP message");
+}
diff --git a/analyzer/protocol/ldap/main.zeek b/analyzer/protocol/ldap/main.zeek
new file mode 100644
index 00000000..86f12920
--- /dev/null
+++ b/analyzer/protocol/ldap/main.zeek
@@ -0,0 +1,467 @@
+# Copyright (c) 2021 by the Zeek Project. See LICENSE for details.
+
+module LDAP;
+
+export {
+ redef enum Log::ID += { LDAP_LOG,
+ LDAP_SEARCH_LOG };
+
+ #############################################################################
+ # This is the format of ldap.log (ldap operations minus search-related)
+ # Each line represents a unique connection+message_id (requests/responses)
+ type Message: record {
+
+ # Timestamp for when the event happened.
+ ts: time &log;
+
+ # Unique ID for the connection.
+ uid: string &log;
+
+ # The connection's 4-tuple of endpoint addresses/ports.
+ id: conn_id &log;
+
+ # transport protocol
+ proto: string &log &optional;
+
+ # Message ID
+ message_id: int &log &optional;
+
+ # LDAP version
+ version: int &log &optional;
+
+ # normalized operations (e.g., bind_request and bind_response to "bind")
+ opcode: set[string] &log &optional;
+
+ # Result code(s)
+ result: set[string] &log &optional;
+
+ # result diagnostic message(s)
+ diagnostic_message: vector of string &log &optional;
+
+ # object(s)
+ object: vector of string &log &optional;
+
+ # argument(s)
+ argument: vector of string &log &optional;
+ };
+
+ #############################################################################
+ # This is the format of ldap_search.log (search-related messages only)
+ # Each line represents a unique connection+message_id (requests/responses)
+ type Search: record {
+
+ # Timestamp for when the event happened.
+ ts: time &log;
+
+ # Unique ID for the connection.
+ uid: string &log;
+
+ # The connection's 4-tuple of endpoint addresses/ports.
+ id: conn_id &log;
+
+ # transport protocol
+ proto: string &log &optional;
+
+ # Message ID
+ message_id: int &log &optional;
+
+ # sets of search scope and deref alias
+ scope: set[string] &log &optional;
+ deref: set[string] &log &optional;
+
+ # base search objects
+ base_object: vector of string &log &optional;
+
+ # number of results returned
+ result_count: count &log &optional;
+
+ # Result code (s)
+ result: set[string] &log &optional;
+
+ # result diagnostic message(s)
+ diagnostic_message: vector of string &log &optional;
+
+ };
+
+ # Event that can be handled to access the ldap record as it is sent on
+ # to the logging framework.
+ global log_ldap: event(rec: LDAP::Message);
+ global log_ldap_search: event(rec: LDAP::Search);
+
+ # Event called for each LDAP message (either direction)
+ global LDAP::message: event(c: connection,
+ message_id: int,
+ opcode: LDAP::ProtocolOpcode,
+ result: LDAP::ResultCode,
+ matched_dn: string,
+ diagnostic_message: string,
+ object: string,
+ argument: string);
+
+ const PROTOCOL_OPCODES = {
+ [LDAP::ProtocolOpcode_BIND_REQUEST] = "bind",
+ [LDAP::ProtocolOpcode_BIND_RESPONSE] = "bind",
+ [LDAP::ProtocolOpcode_UNBIND_REQUEST] = "unbind",
+ [LDAP::ProtocolOpcode_SEARCH_REQUEST] = "search",
+ [LDAP::ProtocolOpcode_SEARCH_RESULT_ENTRY] = "search",
+ [LDAP::ProtocolOpcode_SEARCH_RESULT_DONE] = "search",
+ [LDAP::ProtocolOpcode_MODIFY_REQUEST] = "modify",
+ [LDAP::ProtocolOpcode_MODIFY_RESPONSE] = "modify",
+ [LDAP::ProtocolOpcode_ADD_REQUEST] = "add",
+ [LDAP::ProtocolOpcode_ADD_RESPONSE] = "add",
+ [LDAP::ProtocolOpcode_DEL_REQUEST] = "delete",
+ [LDAP::ProtocolOpcode_DEL_RESPONSE] = "delete",
+ [LDAP::ProtocolOpcode_MOD_DN_REQUEST] = "modify",
+ [LDAP::ProtocolOpcode_MOD_DN_RESPONSE] = "modify",
+ [LDAP::ProtocolOpcode_COMPARE_REQUEST] = "compare",
+ [LDAP::ProtocolOpcode_COMPARE_RESPONSE] = "compare",
+ [LDAP::ProtocolOpcode_ABANDON_REQUEST] = "abandon",
+ [LDAP::ProtocolOpcode_SEARCH_RESULT_REFERENCE] = "search",
+ [LDAP::ProtocolOpcode_EXTENDED_REQUEST] = "extended",
+ [LDAP::ProtocolOpcode_EXTENDED_RESPONSE] = "extended",
+ [LDAP::ProtocolOpcode_INTERMEDIATE_RESPONSE] = "intermediate"
+ } &default = "unknown";
+
+ const BIND_SIMPLE = "bind simple";
+ const BIND_SASL = "bind SASL";
+
+ const RESULT_CODES = {
+ [LDAP::ResultCode_SUCCESS] = "success",
+ [LDAP::ResultCode_OPERATIONS_ERROR] = "operations error",
+ [LDAP::ResultCode_PROTOCOL_ERROR] = "protocol error",
+ [LDAP::ResultCode_TIME_LIMIT_EXCEEDED] = "time limit exceeded",
+ [LDAP::ResultCode_SIZE_LIMIT_EXCEEDED] = "size limit exceeded",
+ [LDAP::ResultCode_COMPARE_FALSE] = "compare false",
+ [LDAP::ResultCode_COMPARE_TRUE] = "compare true",
+ [LDAP::ResultCode_AUTH_METHOD_NOT_SUPPORTED] = "auth method not supported",
+ [LDAP::ResultCode_STRONGER_AUTH_REQUIRED] = "stronger auth required",
+ [LDAP::ResultCode_PARTIAL_RESULTS] = "partial results",
+ [LDAP::ResultCode_REFERRAL] = "referral",
+ [LDAP::ResultCode_ADMIN_LIMIT_EXCEEDED] = "admin limit exceeded",
+ [LDAP::ResultCode_UNAVAILABLE_CRITICAL_EXTENSION] = "unavailable critical extension",
+ [LDAP::ResultCode_CONFIDENTIALITY_REQUIRED] = "confidentiality required",
+ [LDAP::ResultCode_SASL_BIND_IN_PROGRESS] = "SASL bind in progress",
+ [LDAP::ResultCode_NO_SUCH_ATTRIBUTE] = "no such attribute",
+ [LDAP::ResultCode_UNDEFINED_ATTRIBUTE_TYPE] = "undefined attribute type",
+ [LDAP::ResultCode_INAPPROPRIATE_MATCHING] = "inappropriate matching",
+ [LDAP::ResultCode_CONSTRAINT_VIOLATION] = "constraint violation",
+ [LDAP::ResultCode_ATTRIBUTE_OR_VALUE_EXISTS] = "attribute or value exists",
+ [LDAP::ResultCode_INVALID_ATTRIBUTE_SYNTAX] = "invalid attribute syntax",
+ [LDAP::ResultCode_NO_SUCH_OBJECT] = "no such object",
+ [LDAP::ResultCode_ALIAS_PROBLEM] = "alias problem",
+ [LDAP::ResultCode_INVALID_DNSYNTAX] = "invalid DN syntax",
+ [LDAP::ResultCode_ALIAS_DEREFERENCING_PROBLEM] = "alias dereferencing problem",
+ [LDAP::ResultCode_INAPPROPRIATE_AUTHENTICATION] = "inappropriate authentication",
+ [LDAP::ResultCode_INVALID_CREDENTIALS] = "invalid credentials",
+ [LDAP::ResultCode_INSUFFICIENT_ACCESS_RIGHTS] = "insufficient access rights",
+ [LDAP::ResultCode_BUSY] = "busy",
+ [LDAP::ResultCode_UNAVAILABLE] = "unavailable",
+ [LDAP::ResultCode_UNWILLING_TO_PERFORM] = "unwilling to perform",
+ [LDAP::ResultCode_LOOP_DETECT] = "loop detect",
+ [LDAP::ResultCode_SORT_CONTROL_MISSING] = "sort control missing",
+ [LDAP::ResultCode_OFFSET_RANGE_ERROR] = "offset range error",
+ [LDAP::ResultCode_NAMING_VIOLATION] = "naming violation",
+ [LDAP::ResultCode_OBJECT_CLASS_VIOLATION] = "object class violation",
+ [LDAP::ResultCode_NOT_ALLOWED_ON_NON_LEAF] = "not allowed on non-leaf",
+ [LDAP::ResultCode_NOT_ALLOWED_ON_RDN] = "not allowed on RDN",
+ [LDAP::ResultCode_ENTRY_ALREADY_EXISTS] = "entry already exists",
+ [LDAP::ResultCode_OBJECT_CLASS_MODS_PROHIBITED] = "object class mods prohibited",
+ [LDAP::ResultCode_RESULTS_TOO_LARGE] = "results too large",
+ [LDAP::ResultCode_AFFECTS_MULTIPLE_DSAS] = "affects multiple DSAs",
+ [LDAP::ResultCode_CONTROL_ERROR] = "control error",
+ [LDAP::ResultCode_OTHER] = "other",
+ [LDAP::ResultCode_SERVER_DOWN] = "server down",
+ [LDAP::ResultCode_LOCAL_ERROR] = "local error",
+ [LDAP::ResultCode_ENCODING_ERROR] = "encoding error",
+ [LDAP::ResultCode_DECODING_ERROR] = "decoding error",
+ [LDAP::ResultCode_TIMEOUT] = "timeout",
+ [LDAP::ResultCode_AUTH_UNKNOWN] = "auth unknown",
+ [LDAP::ResultCode_FILTER_ERROR] = "filter error",
+ [LDAP::ResultCode_USER_CANCELED] = "user canceled",
+ [LDAP::ResultCode_PARAM_ERROR] = "param error",
+ [LDAP::ResultCode_NO_MEMORY] = "no memory",
+ [LDAP::ResultCode_CONNECT_ERROR] = "connect error",
+ [LDAP::ResultCode_NOT_SUPPORTED] = "not supported",
+ [LDAP::ResultCode_CONTROL_NOT_FOUND] = "control not found",
+ [LDAP::ResultCode_NO_RESULTS_RETURNED] = "no results returned",
+ [LDAP::ResultCode_MORE_RESULTS_TO_RETURN] = "more results to return",
+ [LDAP::ResultCode_CLIENT_LOOP] = "client loop",
+ [LDAP::ResultCode_REFERRAL_LIMIT_EXCEEDED] = "referral limit exceeded",
+ [LDAP::ResultCode_INVALID_RESPONSE] = "invalid response",
+ [LDAP::ResultCode_AMBIGUOUS_RESPONSE] = "ambiguous response",
+ [LDAP::ResultCode_TLS_NOT_SUPPORTED] = "TLS not supported",
+ [LDAP::ResultCode_INTERMEDIATE_RESPONSE] = "intermediate response",
+ [LDAP::ResultCode_UNKNOWN_TYPE] = "unknown type",
+ [LDAP::ResultCode_LCUP_INVALID_DATA] = "LCUP invalid data",
+ [LDAP::ResultCode_LCUP_UNSUPPORTED_SCHEME] = "LCUP unsupported scheme",
+ [LDAP::ResultCode_LCUP_RELOAD_REQUIRED] = "LCUP reload required",
+ [LDAP::ResultCode_CANCELED] = "canceled",
+ [LDAP::ResultCode_NO_SUCH_OPERATION] = "no such operation",
+ [LDAP::ResultCode_TOO_LATE] = "too late",
+ [LDAP::ResultCode_CANNOT_CANCEL] = "cannot cancel",
+ [LDAP::ResultCode_ASSERTION_FAILED] = "assertion failed",
+ [LDAP::ResultCode_AUTHORIZATION_DENIED] = "authorization denied"
+ } &default = "unknown";
+
+ const SEARCH_SCOPES = {
+ [LDAP::SearchScope_SEARCH_BASE] = "base",
+ [LDAP::SearchScope_SEARCH_SINGLE] = "single",
+ [LDAP::SearchScope_SEARCH_TREE] = "tree",
+ } &default = "unknown";
+
+ const SEARCH_DEREF_ALIASES = {
+ [LDAP::SearchDerefAlias_DEREF_NEVER] = "never",
+ [LDAP::SearchDerefAlias_DEREF_IN_SEARCHING] = "searching",
+ [LDAP::SearchDerefAlias_DEREF_FINDING_BASE] = "finding",
+ [LDAP::SearchDerefAlias_DEREF_ALWAYS] = "always",
+ } &default = "unknown";
+}
+
+#############################################################################
+global OPCODES_FINISHED: set[LDAP::ProtocolOpcode] = { LDAP::ProtocolOpcode_BIND_RESPONSE,
+ LDAP::ProtocolOpcode_UNBIND_REQUEST,
+ LDAP::ProtocolOpcode_SEARCH_RESULT_DONE,
+ LDAP::ProtocolOpcode_MODIFY_RESPONSE,
+ LDAP::ProtocolOpcode_ADD_RESPONSE,
+ LDAP::ProtocolOpcode_DEL_RESPONSE,
+ LDAP::ProtocolOpcode_MOD_DN_RESPONSE,
+ LDAP::ProtocolOpcode_COMPARE_RESPONSE,
+ LDAP::ProtocolOpcode_ABANDON_REQUEST,
+ LDAP::ProtocolOpcode_EXTENDED_RESPONSE };
+
+global OPCODES_SEARCH: set[LDAP::ProtocolOpcode] = { LDAP::ProtocolOpcode_SEARCH_REQUEST,
+ LDAP::ProtocolOpcode_SEARCH_RESULT_ENTRY,
+ LDAP::ProtocolOpcode_SEARCH_RESULT_DONE,
+ LDAP::ProtocolOpcode_SEARCH_RESULT_REFERENCE };
+
+#############################################################################
+redef record connection += {
+ ldap_proto: string &optional;
+ ldap_messages: table[int] of Message &optional;
+ ldap_searches: table[int] of Search &optional;
+};
+
+#############################################################################
+event zeek_init() &priority=5 {
+ Log::create_stream(LDAP::LDAP_LOG, [$columns=Message, $ev=log_ldap, $path="ldap"]);
+ Log::create_stream(LDAP::LDAP_SEARCH_LOG, [$columns=Search, $ev=log_ldap_search, $path="ldap_search"]);
+}
+
+#############################################################################
+function set_session(c: connection, message_id: int, opcode: LDAP::ProtocolOpcode) {
+
+ if (! c?$ldap_messages )
+ c$ldap_messages = table();
+
+ if (! c?$ldap_searches )
+ c$ldap_searches = table();
+
+ if ((opcode in OPCODES_SEARCH) && (message_id !in c$ldap_searches)) {
+ c$ldap_searches[message_id] = [$ts=network_time(),
+ $uid=c$uid,
+ $id=c$id,
+ $message_id=message_id,
+ $result_count=0];
+
+ } else if ((opcode !in OPCODES_SEARCH) && (message_id !in c$ldap_messages)) {
+ c$ldap_messages[message_id] = [$ts=network_time(),
+ $uid=c$uid,
+ $id=c$id,
+ $message_id=message_id];
+ }
+
+}
+
+#############################################################################
+event protocol_confirmation(c: connection, atype: Analyzer::Tag, aid: count) &priority=5 {
+
+ if ( atype == Analyzer::ANALYZER_SPICY_LDAP_TCP ) {
+ c$ldap_proto = "tcp";
+ }
+
+}
+
+#############################################################################
+event LDAP::message(c: connection,
+ message_id: int,
+ opcode: LDAP::ProtocolOpcode,
+ result: LDAP::ResultCode,
+ matched_dn: string,
+ diagnostic_message: string,
+ object: string,
+ argument: string) {
+
+ if (opcode == LDAP::ProtocolOpcode_SEARCH_RESULT_DONE) {
+ set_session(c, message_id, opcode);
+
+ if ( result != LDAP::ResultCode_NOT_SET ) {
+ if ( ! c$ldap_searches[message_id]?$result )
+ c$ldap_searches[message_id]$result = set();
+ add c$ldap_searches[message_id]$result[RESULT_CODES[result]];
+ }
+
+ if ( diagnostic_message != "" ) {
+ if ( ! c$ldap_searches[message_id]?$diagnostic_message )
+ c$ldap_searches[message_id]$diagnostic_message = vector();
+ c$ldap_searches[message_id]$diagnostic_message += diagnostic_message;
+ }
+
+ if (( ! c$ldap_searches[message_id]?$proto ) && c?$ldap_proto)
+ c$ldap_searches[message_id]$proto = c$ldap_proto;
+
+ Log::write(LDAP::LDAP_SEARCH_LOG, c$ldap_searches[message_id]);
+ delete c$ldap_searches[message_id];
+
+ } else if (opcode !in OPCODES_SEARCH) {
+ set_session(c, message_id, opcode);
+
+ if ( ! c$ldap_messages[message_id]?$opcode )
+ c$ldap_messages[message_id]$opcode = set();
+ add c$ldap_messages[message_id]$opcode[PROTOCOL_OPCODES[opcode]];
+
+ if ( result != LDAP::ResultCode_NOT_SET ) {
+ if ( ! c$ldap_messages[message_id]?$result )
+ c$ldap_messages[message_id]$result = set();
+ add c$ldap_messages[message_id]$result[RESULT_CODES[result]];
+ }
+
+ if ( diagnostic_message != "" ) {
+ if ( ! c$ldap_messages[message_id]?$diagnostic_message )
+ c$ldap_messages[message_id]$diagnostic_message = vector();
+ c$ldap_messages[message_id]$diagnostic_message += diagnostic_message;
+ }
+
+ if ( object != "" ) {
+ if ( ! c$ldap_messages[message_id]?$object )
+ c$ldap_messages[message_id]$object = vector();
+ c$ldap_messages[message_id]$object += object;
+ }
+
+ if ( argument != "" ) {
+ if ( ! c$ldap_messages[message_id]?$argument )
+ c$ldap_messages[message_id]$argument = vector();
+ c$ldap_messages[message_id]$argument += argument;
+ }
+
+ if (opcode in OPCODES_FINISHED) {
+
+ if ((BIND_SIMPLE in c$ldap_messages[message_id]$opcode) ||
+ (BIND_SASL in c$ldap_messages[message_id]$opcode)) {
+ # don't have both "bind" and "bind " in the operations list
+ delete c$ldap_messages[message_id]$opcode[PROTOCOL_OPCODES[LDAP::ProtocolOpcode_BIND_REQUEST]];
+ }
+
+ if (( ! c$ldap_messages[message_id]?$proto ) && c?$ldap_proto)
+ c$ldap_messages[message_id]$proto = c$ldap_proto;
+
+ Log::write(LDAP::LDAP_LOG, c$ldap_messages[message_id]);
+ delete c$ldap_messages[message_id];
+ }
+ }
+
+}
+
+#############################################################################
+event LDAP::searchreq(c: connection,
+ message_id: int,
+ base_object: string,
+ scope: LDAP::SearchScope,
+ deref: LDAP::SearchDerefAlias,
+ size_limit: int,
+ time_limit: int,
+ types_only: bool) {
+
+ set_session(c, message_id, LDAP::ProtocolOpcode_SEARCH_REQUEST);
+
+ if ( scope != LDAP::SearchScope_NOT_SET ) {
+ if ( ! c$ldap_searches[message_id]?$scope )
+ c$ldap_searches[message_id]$scope = set();
+ add c$ldap_searches[message_id]$scope[SEARCH_SCOPES[scope]];
+ }
+
+ if ( deref != LDAP::SearchDerefAlias_NOT_SET ) {
+ if ( ! c$ldap_searches[message_id]?$deref )
+ c$ldap_searches[message_id]$deref = set();
+ add c$ldap_searches[message_id]$deref[SEARCH_DEREF_ALIASES[deref]];
+ }
+
+ if ( base_object != "" ) {
+ if ( ! c$ldap_searches[message_id]?$base_object )
+ c$ldap_searches[message_id]$base_object = vector();
+ c$ldap_searches[message_id]$base_object += base_object;
+ }
+
+}
+
+#############################################################################
+event LDAP::searchres(c: connection,
+ message_id: int,
+ object_name: string) {
+
+ set_session(c, message_id, LDAP::ProtocolOpcode_SEARCH_RESULT_ENTRY);
+
+ c$ldap_searches[message_id]$result_count += 1;
+}
+
+#############################################################################
+event LDAP::bindreq(c: connection,
+ message_id: int,
+ version: int,
+ name: string,
+ authType: LDAP::BindAuthType,
+ authInfo: string) {
+
+ set_session(c, message_id, LDAP::ProtocolOpcode_BIND_REQUEST);
+
+ if ( ! c$ldap_messages[message_id]?$version )
+ c$ldap_messages[message_id]$version = version;
+
+ if ( ! c$ldap_messages[message_id]?$opcode )
+ c$ldap_messages[message_id]$opcode = set();
+
+ if (authType == LDAP::BindAuthType_BIND_AUTH_SIMPLE) {
+ add c$ldap_messages[message_id]$opcode[BIND_SIMPLE];
+ } else if (authType == LDAP::BindAuthType_BIND_AUTH_SASL) {
+ add c$ldap_messages[message_id]$opcode[BIND_SASL];
+ }
+
+}
+
+#############################################################################
+event connection_state_remove(c: connection) {
+
+ # log any "pending" unlogged LDAP messages/searches
+
+ if ( c?$ldap_messages && (|c$ldap_messages| > 0) ) {
+ for ( [mid], m in c$ldap_messages ) {
+ if (mid > 0) {
+
+ if ((BIND_SIMPLE in m$opcode) || (BIND_SASL in m$opcode)) {
+ # don't have both "bind" and "bind " in the operations list
+ delete m$opcode[PROTOCOL_OPCODES[LDAP::ProtocolOpcode_BIND_REQUEST]];
+ }
+
+ if (( ! m?$proto ) && c?$ldap_proto)
+ m$proto = c$ldap_proto;
+
+ Log::write(LDAP::LDAP_LOG, m);
+ }
+ }
+ delete c$ldap_messages;
+ }
+
+ if ( c?$ldap_searches && (|c$ldap_searches| > 0) ) {
+ for ( [mid], s in c$ldap_searches ) {
+ if (mid > 0) {
+
+ if (( ! s?$proto ) && c?$ldap_proto)
+ s$proto = c$ldap_proto;
+
+ Log::write(LDAP::LDAP_SEARCH_LOG, s);
+ }
+ }
+ delete c$ldap_searches;
+ }
+
+}
+
diff --git a/tests/Baseline/protocol.ldap.basic/conn.log b/tests/Baseline/protocol.ldap.basic/conn.log
new file mode 100644
index 00000000..5ab37878
--- /dev/null
+++ b/tests/Baseline/protocol.ldap.basic/conn.log
@@ -0,0 +1,12 @@
+### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
+### NOTE: This file has been sorted with diff-sort.
+#separator \x09
+#set_separator ,
+#empty_field (empty)
+#unset_field -
+#path conn
+#open XXXX-XX-XX-XX-XX-XX
+#fields ts uid id.orig_h id.orig_p id.resp_h id.resp_p proto service duration orig_bytes resp_bytes conn_state local_orig local_resp missed_bytes history orig_pkts orig_ip_bytes resp_pkts resp_ip_bytes tunnel_parents
+#types time string addr port addr port enum string interval count count string bool bool count string count count count count set[string]
+#close XXXX-XX-XX-XX-XX-XX
+XXXXXXXXXX.XXXXXX CHhAvVGS1DHFjwGM9 10.0.0.1 25936 10.0.0.2 3268 tcp spicy_ldap_tcp 181.520479 258 188 RSTO - - 0 ShADdR 8 590 4 360 -
diff --git a/tests/Baseline/protocol.ldap.basic/ldap.log b/tests/Baseline/protocol.ldap.basic/ldap.log
new file mode 100644
index 00000000..021bf93c
--- /dev/null
+++ b/tests/Baseline/protocol.ldap.basic/ldap.log
@@ -0,0 +1,13 @@
+### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
+### NOTE: This file has been sorted with diff-sort.
+#separator \x09
+#set_separator ,
+#empty_field (empty)
+#unset_field -
+#path ldap
+#open XXXX-XX-XX-XX-XX-XX
+#fields ts uid id.orig_h id.orig_p id.resp_h id.resp_p proto message_id version opcode result diagnostic_message object argument
+#types time string addr port addr port string int int set[string] set[string] vector[string] vector[string] vector[string]
+#close XXXX-XX-XX-XX-XX-XX
+XXXXXXXXXX.XXXXXX CHhAvVGS1DHFjwGM9 10.0.0.1 25936 10.0.0.2 3268 tcp 1 3 bind simple success - xxxxxxxxxxx@xx.xxx.xxxxx.net passwor8d1
+XXXXXXXXXX.XXXXXX CHhAvVGS1DHFjwGM9 10.0.0.1 25936 10.0.0.2 3268 tcp 3 3 bind simple success - CN=xxxxxxxx\x2cOU=Users\x2cOU=Accounts\x2cDC=xx\x2cDC=xxx\x2cDC=xxxxx\x2cDC=net /dev/rdsk/c0t0d0s0
diff --git a/tests/Baseline/protocol.ldap.basic/ldap_search.log b/tests/Baseline/protocol.ldap.basic/ldap_search.log
new file mode 100644
index 00000000..8e44022e
--- /dev/null
+++ b/tests/Baseline/protocol.ldap.basic/ldap_search.log
@@ -0,0 +1,12 @@
+### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
+### NOTE: This file has been sorted with diff-sort.
+#separator \x09
+#set_separator ,
+#empty_field (empty)
+#unset_field -
+#path ldap_search
+#open XXXX-XX-XX-XX-XX-XX
+#fields ts uid id.orig_h id.orig_p id.resp_h id.resp_p proto message_id scope deref base_object result_count result diagnostic_message
+#types time string addr port addr port string int set[string] set[string] vector[string] count set[string] vector[string]
+#close XXXX-XX-XX-XX-XX-XX
+XXXXXXXXXX.XXXXXX CHhAvVGS1DHFjwGM9 10.0.0.1 25936 10.0.0.2 3268 tcp 2 tree always DC=xx\x2cDC=xxx\x2cDC=xxxxx\x2cDC=net 1 success -
diff --git a/tests/Traces/README b/tests/Traces/README
index d0e41308..e2aaf5cc 100644
--- a/tests/Traces/README
+++ b/tests/Traces/README
@@ -19,6 +19,9 @@ IPSec
- [ipsec-ikev1-isakmp-main-mode.pcap](https://www.cloudshark.org/captures/ff740838f1c2)
- [ipsec-ikev1-isakmp-aggressive-mode.pcap](https://www.cloudshark.org/captures/e51f5c8a6b24)
+LDAP
+- [ldap-simpleauth.pcap](https://github.com/arkime/arkime/blob/main/tests/pcap/ldap-simpleauth.pcap)
+
OpenVPN
- [openvpn-tcp-tls-auth.pcap](https://wiki.wireshark.org/SampleCaptures?action=AttachFile&do=get&target=OpenVPN_TCP_tls-auth.pcapng)
- openvpn.pcap (self-made)
diff --git a/tests/Traces/ldap-simpleauth.pcap b/tests/Traces/ldap-simpleauth.pcap
new file mode 100644
index 00000000..1cf904a0
Binary files /dev/null and b/tests/Traces/ldap-simpleauth.pcap differ
diff --git a/tests/protocol/ldap/availability.zeek b/tests/protocol/ldap/availability.zeek
new file mode 100644
index 00000000..68cb285b
--- /dev/null
+++ b/tests/protocol/ldap/availability.zeek
@@ -0,0 +1,5 @@
+# Copyright (c) 2021 by the Zeek Project. See LICENSE for details.
+
+# @TEST-EXEC: ${ZEEK} -NN | grep -q ANALYZER_SPICY_LDAP_TCP
+#
+# @TEST-DOC: Check that LDAP (TCP) is analyzer is available.
diff --git a/tests/protocol/ldap/basic.zeek b/tests/protocol/ldap/basic.zeek
new file mode 100644
index 00000000..8e03fb0c
--- /dev/null
+++ b/tests/protocol/ldap/basic.zeek
@@ -0,0 +1,10 @@
+# Copyright (c) 2021 by the Zeek Project. See LICENSE for details.
+
+# @TEST-EXEC: ${ZEEK} -C -r ${TRACES}/ldap-simpleauth.pcap %INPUT
+# @TEST-EXEC: btest-diff conn.log
+# @TEST-EXEC: btest-diff ldap.log
+# @TEST-EXEC: btest-diff ldap_search.log
+#
+# @TEST-DOC: Test LDAP analyzer with small trace.
+
+@load spicy-analyzers/protocol/ldap