##! Base DNS analysis script which tracks and logs DNS queries along with
##! their responses.

@load base/utils/queue
@load ./consts
@load base/protocols/conn/removal-hooks

module DNS;

export {
	## The DNS logging stream identifier.
	redef enum Log::ID += { LOG };

	## A default logging policy hook for the stream.
	global log_policy: Log::PolicyHook;

	## The record type which contains the column fields of the DNS log.
	type Info: record {
		## The earliest time at which a DNS protocol message over the
		## associated connection is observed.
		ts:            time               &log;
		## A unique identifier of the connection over which DNS messages
		## are being transferred.
		uid:           string             &log;
		## The connection's 4-tuple of endpoint addresses/ports.
		id:            conn_id            &log;
		## The transport layer protocol of the connection.
		proto:         transport_proto    &log;
		## A 16-bit identifier assigned by the program that generated
		## the DNS query.  Also used in responses to match up replies to
		## outstanding queries.
		trans_id:      count              &log &optional;
		## Round trip time for the query and response. This indicates
		## the delay between when the request was seen until the
		## answer started.
		rtt:           interval           &log &optional;
		## The domain name that is the subject of the DNS query.
		query:         string             &log &optional;
		## The QCLASS value specifying the class of the query.
		qclass:        count              &log &optional;
		## A descriptive name for the class of the query.
		qclass_name:   string             &log &optional;
		## A QTYPE value specifying the type of the query.
		qtype:         count              &log &optional;
		## A descriptive name for the type of the query.
		qtype_name:    string             &log &optional;
		## The response code value in DNS response messages.
		rcode:         count              &log &optional;
		## A descriptive name for the response code value.
		rcode_name:    string             &log &optional;
		## The Authoritative Answer bit for response messages specifies
		## that the responding name server is an authority for the
		## domain name in the question section.
		AA:            bool               &log &default=F;
		## The Truncation bit specifies that the message was truncated.
		TC:            bool               &log &default=F;
		## The Recursion Desired bit in a request message indicates that
		## the client wants recursive service for this query.
		RD:            bool               &log &default=F;
		## The Recursion Available bit in a response message indicates
		## that the name server supports recursive queries.
		RA:            bool               &log &default=F;
		## A reserved field that is zero in queries and responses unless
		## using DNSSEC. This field represents the 3-bit Z field using
		## the specification from RFC 1035.
		Z:             count              &log &default=0;
		## The set of resource descriptions in the query answer.
		answers:       vector of string   &log &optional;
		## The caching intervals of the associated RRs described by the
		## *answers* field.
		TTLs:          vector of interval &log &optional;
		## The DNS query was rejected by the server.
		rejected:      bool               &log &default=F;

		## The total number of resource records in a reply message's
		## answer section.
		total_answers: count           &optional;
		## The total number of resource records in a reply message's
		## answer, authority, and additional sections.
		total_replies: count           &optional;

		## Whether the full DNS query has been seen.
		saw_query: bool                &default=F;
		## Whether the full DNS reply has been seen.
		saw_reply: bool                &default=F;
	};

	## An event that can be handled to access the :zeek:type:`DNS::Info`
	## record as it is sent to the logging framework.
	global log_dns: event(rec: Info);

	## This is called by the specific dns_*_reply events with a "reply"
	## which may not represent the full data available from the resource
	## record, but it's generally considered a summarization of the
	## responses.
	##
	## c: The connection record for which to fill in DNS reply data.
	##
	## msg: The DNS message header information for the response.
	##
	## ans: The general information of a RR response.
	##
	## reply: The specific response information according to RR type/class.
	global do_reply: hook(c: connection, msg: dns_msg, ans: dns_answer, reply: string);

	## A hook that is called whenever a session is being set.
	## This can be used if additional initialization logic needs to happen
	## when creating a new session value.
	##
	## c: The connection involved in the new session.
	##
	## msg: The DNS message header information.
	##
	## is_query: Indicator for if this is being called for a query or a response.
	global set_session: hook(c: connection, msg: dns_msg, is_query: bool);

	## Yields a queue of :zeek:see:`DNS::Info` objects for a given
	## DNS message query/transaction ID.
	type PendingMessages: table[count] of Queue::Queue;

	## Give up trying to match pending DNS queries or replies for a given
	## query/transaction ID once this number of unmatched queries or replies
	## is reached (this shouldn't happen unless either the DNS server/resolver
	## is broken, Zeek is not seeing all the DNS traffic, or an AXFR query
	## response is ongoing).
	option max_pending_msgs = 50;

	## Give up trying to match pending DNS queries or replies across all
	## query/transaction IDs once there is at least one unmatched query or
	## reply across this number of different query IDs.
	option max_pending_query_ids = 50;

	## A record type which tracks the status of DNS queries for a given
	## :zeek:type:`connection`.
	type State: record {
		## A single query that hasn't been matched with a response yet.
		## Note this is maintained separate from the *pending_queries*
		## field solely for performance reasons -- it's possible that
		## *pending_queries* contains further queries for which a response
		## has not yet been seen, even for the same transaction ID.
		pending_query: Info &optional;

		## Indexed by query id, returns Info record corresponding to
		## queries that haven't been matched with a response yet.
		pending_queries: PendingMessages &optional;

		## Indexed by query id, returns Info record corresponding to
		## replies that haven't been matched with a query yet.
		pending_replies: PendingMessages &optional;
	};

	## DNS finalization hook.  Remaining DNS info may get logged when it's called.
	global finalize_dns: Conn::RemovalHook;
}


redef record connection += {
	dns:       Info  &optional;
	dns_state: State &optional;
};

const ports = { 53/udp, 53/tcp, 137/udp, 5353/udp, 5355/udp };
redef likely_server_ports += { ports };

event zeek_init() &priority=5
	{
	Log::create_stream(DNS::LOG, [$columns=Info, $ev=log_dns, $path="dns", $policy=log_policy]);
	Analyzer::register_for_ports(Analyzer::ANALYZER_DNS, ports);
	}

function new_session(c: connection, trans_id: count): Info
	{
	local info: Info;
	info$ts       = network_time();
	info$id       = c$id;
	info$uid      = c$uid;
	info$proto    = get_port_transport_proto(c$id$resp_p);
	info$trans_id = trans_id;
	return info;
	}

function log_unmatched_msgs_queue(q: Queue::Queue)
	{
	local infos: vector of Info;
	Queue::get_vector(q, infos);

	for ( i in infos )
		{
		Log::write(DNS::LOG, infos[i]);
		}
	}

function log_unmatched_msgs(msgs: PendingMessages)
	{
	for ( _, q in msgs )
		{
		log_unmatched_msgs_queue(q);
		}

	clear_table(msgs);
	}

function enqueue_new_msg(msgs: PendingMessages, id: count, msg: Info)
	{
	if ( id !in msgs )
		{
		if ( |msgs| > max_pending_query_ids )
			{
			# Throw away all unmatched on assumption they'll never be matched.
			log_unmatched_msgs(msgs);
			}

		msgs[id] = Queue::init();
		}
	else
		{
		if ( Queue::len(msgs[id]) > max_pending_msgs )
			{
			log_unmatched_msgs_queue(msgs[id]);
			# Throw away all unmatched on assumption they'll never be matched.
			msgs[id] = Queue::init();
			}
		}

	Queue::put(msgs[id], msg);
	}

function pop_msg(msgs: PendingMessages, id: count): Info
	{
	local rval: Info = Queue::get(msgs[id]);

	if ( Queue::len(msgs[id]) == 0 )
		delete msgs[id];

	return rval;
	}

hook set_session(c: connection, msg: dns_msg, is_query: bool) &priority=5
	{
	if ( ! c?$dns_state )
		{
		local state: State;
		c$dns_state = state;
		Conn::register_removal_hook(c, finalize_dns);
		}

	if ( is_query )
		{
		if ( c$dns_state?$pending_replies && msg$id in c$dns_state$pending_replies &&
		     Queue::len(c$dns_state$pending_replies[msg$id]) > 0 )
			{
			# Match this DNS query w/ what's at head of pending reply queue.
			c$dns = pop_msg(c$dns_state$pending_replies, msg$id);
			}
		else
			{
			# Create a new DNS session and put it in the query queue so
			# we can wait for a matching reply.
			c$dns = new_session(c, msg$id);

			if( ! c$dns_state?$pending_query )
				c$dns_state$pending_query = c$dns;
			else
				{
				if( !c$dns_state?$pending_queries )
					c$dns_state$pending_queries = table();

				enqueue_new_msg(c$dns_state$pending_queries, msg$id, c$dns);
				}
			}
		}
	else
		{
		if ( c$dns_state?$pending_query && c$dns_state$pending_query$trans_id == msg$id )
			{
			c$dns = c$dns_state$pending_query;
			delete c$dns_state$pending_query;

			if ( c$dns_state?$pending_queries )
				{
				# Popping off an arbitrary, unpaired query to set as the
				# new fastpath is necessary in order to preserve the overall
				# queuing order of any pending queries that may share a
				# transaction ID.  If we didn't fill c$dns_state$pending_query
				# back in, then it's possible a new query would jump ahead in
				# the queue of some other pending query since
				# c$dns_state$pending_query is filled first if available.

				if ( msg$id in c$dns_state$pending_queries &&
				     Queue::len(c$dns_state$pending_queries[msg$id]) > 0 )
					# Prioritize any pending query with matching ID to the one
					# that just got paired with a response.
					c$dns_state$pending_query = pop_msg(c$dns_state$pending_queries, msg$id);
				else
					{
					# Just pick an arbitrary, unpaired query.
					local tid: count &is_assigned;
					local found_one = F;

					for ( trans_id, q in c$dns_state$pending_queries )
						if ( Queue::len(q) > 0 )
							{
							tid = trans_id;
							found_one = T;
							break;
							}

					if ( found_one )
						c$dns_state$pending_query = pop_msg(c$dns_state$pending_queries, tid);
					}
				}
			}
		else if ( c$dns_state?$pending_queries && msg$id in c$dns_state$pending_queries &&
		     Queue::len(c$dns_state$pending_queries[msg$id]) > 0 )
			{
			# Match this DNS reply w/ what's at head of pending query queue.
			c$dns = pop_msg(c$dns_state$pending_queries, msg$id);
			}
		else
			{
			# Create a new DNS session and put it in the reply queue so
			# we can wait for a matching query.
			c$dns = new_session(c, msg$id);

			if( ! c$dns_state?$pending_replies )
				c$dns_state$pending_replies = table();

			enqueue_new_msg(c$dns_state$pending_replies, msg$id, c$dns);
			}
		}

	if ( ! is_query )
		{
		c$dns$rcode = msg$rcode;
		c$dns$rcode_name = base_errors[msg$rcode];

		if ( ! c$dns?$total_answers )
			c$dns$total_answers = msg$num_answers;

		if ( ! c$dns?$total_replies )
			c$dns$total_replies = msg$num_answers + msg$num_addl + msg$num_auth;

		if ( msg$rcode != 0 && msg$num_queries == 0 )
			c$dns$rejected = T;
		}
	}

event dns_message(c: connection, is_orig: bool, msg: dns_msg, len: count) &priority=5
	{
	if ( msg$opcode != 0 )
		# Currently only standard queries are tracked.
		return;

	hook set_session(c, msg, ! msg$QR);
	}

hook DNS::do_reply(c: connection, msg: dns_msg, ans: dns_answer, reply: string) &priority=5
	{
	if ( msg$opcode != 0 )
		# Currently only standard queries are tracked.
		return;

	if ( ! msg$QR )
		# This is weird: the inquirer must also be providing answers in
		# the request, which is not what we want to track.
		return;

	if ( ans$answer_type == DNS_ANS )
		{
		if ( ! c$dns?$query )
			c$dns$query = ans$query;

		c$dns$AA    = msg$AA;
		c$dns$RA    = msg$RA;

		if ( ! c$dns?$rtt )
			{
			c$dns$rtt = network_time() - c$dns$ts;
			# This could mean that only a reply was seen since
			# we assume there must be some passage of time between
			# request and response.
			if ( c$dns$rtt == 0secs )
				delete c$dns$rtt;
			}

		if ( reply != "" )
			{
			if ( ! c$dns?$answers )
				c$dns$answers = vector();
			c$dns$answers += reply;

			if ( ! c$dns?$TTLs )
				c$dns$TTLs = vector();
			c$dns$TTLs += ans$TTL;
			}
		}
	}

event dns_end(c: connection, msg: dns_msg) &priority=5
	{
	if ( ! c?$dns )
		return;

	if ( msg$QR )
		c$dns$saw_reply = T;
	else
		c$dns$saw_query = T;
	}

event dns_end(c: connection, msg: dns_msg) &priority=-5
	{
	if ( c?$dns && c$dns$saw_reply && c$dns$saw_query )
		{
		Log::write(DNS::LOG, c$dns);
		delete c$dns;
		}
	}

event dns_request(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count) &priority=5
	{
	if ( msg$opcode != 0 )
		# Currently only standard queries are tracked.
		return;

	c$dns$RD          = msg$RD;
	c$dns$TC          = msg$TC;
	c$dns$qclass      = qclass;
	c$dns$qclass_name = classes[qclass];
	c$dns$qtype       = qtype;
	c$dns$qtype_name  = query_types[qtype];
	c$dns$Z           = msg$Z;

	# Decode netbios name queries
	# Note: I'm ignoring the name type for now.  Not sure if this should be
	#       worked into the query/response in some fashion.
	if ( c$id$resp_p == 137/udp )
		{
		local decoded_query = decode_netbios_name(query);

		if ( |decoded_query| != 0 )
			query = decoded_query;

		if ( c$dns$qtype_name == "SRV" )
			{
			# The SRV RFC used the ID used for NetBios Status RRs.
			# So if this is NetBios Name Service we name it correctly.
			c$dns$qtype_name = "NBSTAT";
			}
		}
	c$dns$query = query;
	}


event dns_unknown_reply(c: connection, msg: dns_msg, ans: dns_answer) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, fmt("<unknown type=%s>", ans$qtype));
	}

event dns_A_reply(c: connection, msg: dns_msg, ans: dns_answer, a: addr) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, fmt("%s", a));
	}

event dns_TXT_reply(c: connection, msg: dns_msg, ans: dns_answer, strs: string_vec) &priority=5
	{
	local txt_strings: string = "";

	for ( i in strs )
		{
		if ( i > 0 )
			txt_strings += " ";

		txt_strings += fmt("TXT %d %s", |strs[i]|, strs[i]);
		}

	hook DNS::do_reply(c, msg, ans, txt_strings);
	}

event dns_SPF_reply(c: connection, msg: dns_msg, ans: dns_answer, strs: string_vec) &priority=5
	{
	local spf_strings: string = "";

	for ( i in strs )
		{
		if ( i > 0 )
			spf_strings += " ";

		spf_strings += fmt("SPF %d %s", |strs[i]|, strs[i]);
		}

	hook DNS::do_reply(c, msg, ans, spf_strings);
	}

event dns_AAAA_reply(c: connection, msg: dns_msg, ans: dns_answer, a: addr) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, fmt("%s", a));
	}

event dns_A6_reply(c: connection, msg: dns_msg, ans: dns_answer, a: addr) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, fmt("%s", a));
	}

event dns_NS_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, name);
	}

event dns_CNAME_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, name);
	}

event dns_MX_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string,
                   preference: count) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, name);
	}

event dns_PTR_reply(c: connection, msg: dns_msg, ans: dns_answer, name: string) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, name);
	}

event dns_SOA_reply(c: connection, msg: dns_msg, ans: dns_answer, soa: dns_soa) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, soa$mname);
	}

event dns_WKS_reply(c: connection, msg: dns_msg, ans: dns_answer) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, "");
	}

event dns_SRV_reply(c: connection, msg: dns_msg, ans: dns_answer, target: string, priority: count, weight: count, p: count) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, target);
	}

event dns_NAPTR_reply(c: connection, msg: dns_msg, ans: dns_answer, naptr: dns_naptr_rr) &priority=5
	{
	# Just encode all the fields for NAPTR RR in the reply string.
	local tmp = "";

	if ( |naptr$regexp| > 0 )
		tmp += naptr$regexp;

	if ( |naptr$replacement| > 0 )
		{
		if ( |tmp| > 0 )
			tmp += " ";

		tmp += naptr$replacement;
		}

	local r = fmt("NAPTR %s %s %s %s %s", naptr$order, naptr$preference, naptr$flags, naptr$service, tmp);

	hook DNS::do_reply(c, msg, ans, r);
	}

# TODO: figure out how to handle these
#event dns_EDNS(c: connection, msg: dns_msg, ans: dns_answer)
#	{
#
#	}
#
#event dns_EDNS_addl(c: connection, msg: dns_msg, ans: dns_edns_additional)
#	{
#
#	}
# event dns_EDNS_ecs(c: connection, msg: dns_msg, opt: dns_edns_ecs)
#	{
#
#	}
#
#event dns_TSIG_addl(c: connection, msg: dns_msg, ans: dns_tsig_additional)
#	{
#
#	}

event dns_RRSIG(c: connection, msg: dns_msg, ans: dns_answer, rrsig: dns_rrsig_rr) &priority=5
	{
	local s: string;
	s = fmt("RRSIG %s %s", rrsig$type_covered,
	        rrsig$signer_name == "" ? "<Root>" : rrsig$signer_name);
	hook DNS::do_reply(c, msg, ans, s);
	}

event dns_DNSKEY(c: connection, msg: dns_msg, ans: dns_answer, dnskey: dns_dnskey_rr) &priority=5
	{
	local s: string;
	s = fmt("DNSKEY %s", dnskey$algorithm);
	hook DNS::do_reply(c, msg, ans, s);
	}

event dns_NSEC(c: connection, msg: dns_msg, ans: dns_answer, next_name: string, bitmaps: string_vec) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, fmt("NSEC %s %s", ans$query, next_name));
	}

event dns_NSEC3(c: connection, msg: dns_msg, ans: dns_answer, nsec3: dns_nsec3_rr) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, "NSEC3");
	}

event dns_NSEC3PARAM(c: connection, msg: dns_msg, ans: dns_answer, nsec3param: dns_nsec3param_rr) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, "NSEC3PARAM");
	}

event dns_DS(c: connection, msg: dns_msg, ans: dns_answer, ds: dns_ds_rr) &priority=5
	{
	local s: string;
	s = fmt("DS %s %s", ds$algorithm, ds$digest_type);
	hook DNS::do_reply(c, msg, ans, s);
	}

event dns_BINDS(c: connection, msg: dns_msg, ans: dns_answer, binds: dns_binds_rr) &priority=5
	{
	hook DNS::do_reply(c, msg, ans, "BIND9 signing signal");
	}

event dns_SSHFP(c: connection, msg: dns_msg, ans: dns_answer, algo: count, fptype: count, fingerprint: string) &priority=5
	{
	local s: string;
	s = fmt("SSHFP: %s", bytestring_to_hexstr(fingerprint));
	hook DNS::do_reply(c, msg, ans, s);
	}

event dns_LOC(c: connection, msg: dns_msg, ans: dns_answer, loc: dns_loc_rr) &priority=5
	{
	local s: string;
	s = fmt("LOC:  %d %d %d", loc$size, loc$horiz_pre, loc$vert_pre);
	hook DNS::do_reply(c, msg, ans, s);
	}

event dns_rejected(c: connection, msg: dns_msg, query: string, qtype: count, qclass: count) &priority=5
	{
	if ( c?$dns )
		c$dns$rejected = T;
	}

hook finalize_dns(c: connection)
	{
	if ( ! c?$dns_state )
		return;

	# If Zeek is expiring state, we should go ahead and log all unmatched
	# queries and replies now.
	if( c$dns_state?$pending_query )
		Log::write(DNS::LOG, c$dns_state$pending_query);

	if( c$dns_state?$pending_queries )
		log_unmatched_msgs(c$dns_state$pending_queries);

	if( c$dns_state?$pending_replies )
		log_unmatched_msgs(c$dns_state$pending_replies);
	}
