зеркало из https://github.com/mozilla/MozDef.git
228 строки
8.2 KiB
Python
228 строки
8.2 KiB
Python
#!/usr/bin/env python
|
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
#
|
|
|
|
import sys
|
|
from datetime import datetime, timedelta, tzinfo
|
|
|
|
try:
|
|
from datetime import timezone
|
|
|
|
utc = timezone.utc
|
|
except ImportError:
|
|
# Hi there python2 user
|
|
class UTC(tzinfo):
|
|
def utcoffset(self, dt):
|
|
return timedelta(0)
|
|
|
|
def tzname(self, dt):
|
|
return "UTC"
|
|
|
|
def dst(self, dt):
|
|
return timedelta(0)
|
|
|
|
utc = UTC()
|
|
from configlib import getConfig, OptionParser
|
|
import json
|
|
import duo_client
|
|
import mozdef_client as mozdef
|
|
import pickle
|
|
|
|
|
|
def normalize(details):
|
|
# Normalizes fields to conform to http://mozdef.readthedocs.io/en/latest/usage.html#mandatory-fields
|
|
# This is mainly used for common field names to put inside the details structure
|
|
# There might be faster ways to do this
|
|
normalized = {}
|
|
|
|
for f in details:
|
|
if f in ("ip", "ip_address", "client_ip"):
|
|
normalized["sourceipaddress"] = details[f]
|
|
continue
|
|
if f == "result":
|
|
if details[f].lower() == "success":
|
|
normalized["success"] = True
|
|
else:
|
|
normalized["success"] = False
|
|
normalized[f] = details[f]
|
|
|
|
if "user" in normalized and type(normalized["user"]) is dict:
|
|
if "name" in normalized["user"]:
|
|
normalized["username"] = normalized["user"]["name"]
|
|
if "key" in normalized["user"]:
|
|
normalized["userkey"] = normalized["user"]["key"]
|
|
del (normalized["user"])
|
|
|
|
return normalized
|
|
|
|
|
|
def process_events(mozmsg, duo_events, etype, state):
|
|
"""
|
|
Data format of duo_events in api_version == 2 (str):
|
|
duo_events.metadata = {u'total_objects': 49198, u'next_offset': [u'1547244648000', u'4da7180c-b1e5-47b4-9f4d-ee10dc3b5ac8']}
|
|
duo_events.authlogs = [{...}, {...}, ...]
|
|
authlogs entry = {u'access_device': {u'ip': u'a.b.c.d', u'location': {u'city': None, u'state': u'Anhui', u'country':
|
|
u'China'}}, u'event_type': u'authentication', u'timestamp': 1547244800, u'factor': u'not_available', u'reason':
|
|
u'deny_unenrolled_user', u'txid': u'68b33dd3-d341-46c6-a985-0640592fb7b0', u'application': {u'name': u'Integration
|
|
Name Here', u'key': u'SOME KEY HERE'}, u'host': u'api-blah.duosecurity.com', u'result': u'denied', u'eventtype': u'authentication', u'auth_device': {u'ip': None, u'location': {u'city': None, u'state': None, u'country': None}, u'name': None}, u'user': {u'name': u'root', u'key': None}}
|
|
"""
|
|
# There are some key fields that we use as MozDef fields, those are set to "noconsume"
|
|
# After processing these fields, we just pour everything into the "details" fields of Mozdef, except for the
|
|
# noconsume fields.
|
|
|
|
if etype == "administration":
|
|
noconsume = ["timestamp", "host", "action"]
|
|
elif etype == "telephony":
|
|
noconsume = ["timestamp", "host", "context"]
|
|
elif etype == "authentication":
|
|
noconsume = ["timestamp", "host", "eventtype"]
|
|
else:
|
|
return
|
|
|
|
# Care for API v2
|
|
if isinstance(duo_events, dict) and "authlogs" in duo_events:
|
|
offset = duo_events["metadata"]["next_offset"]
|
|
if offset is not None:
|
|
state["{}_offset".format(etype)] = offset
|
|
duo_events = duo_events["authlogs"]
|
|
api_version = 2
|
|
else:
|
|
api_version = 1
|
|
|
|
for e in duo_events:
|
|
details = {}
|
|
# Timestamp format: http://mozdef.readthedocs.io/en/latest/usage.html#mandatory-fields
|
|
# Duo logs come as a UTC timestamp
|
|
dt = datetime.utcfromtimestamp(e["timestamp"])
|
|
mozmsg.timestamp = dt.replace(tzinfo=utc).isoformat()
|
|
mozmsg.log["hostname"] = e["host"]
|
|
for i in e:
|
|
if i in noconsume:
|
|
continue
|
|
|
|
# Duo client doesn't translate inner dicts to dicts for some reason - its just a string, so we have to process and parse it
|
|
if e[i] is not None and type(e[i]) == str and e[i].startswith("{"):
|
|
j = json.loads(e[i])
|
|
for x in j:
|
|
details[x] = j[x]
|
|
continue
|
|
|
|
details[i] = e[i]
|
|
mozmsg.set_category(etype)
|
|
mozmsg.details = normalize(details)
|
|
if "access_device" in details:
|
|
if "ip" in details["access_device"]:
|
|
mozmsg.details["sourceipaddress"] = details["access_device"]["ip"]
|
|
if etype == "administration":
|
|
mozmsg.summary = e["action"]
|
|
elif etype == "telephony":
|
|
mozmsg.summary = e["context"]
|
|
elif etype == "authentication":
|
|
if api_version == 1:
|
|
mozmsg.summary = (
|
|
e["eventtype"] + " " + e["result"] + " for " + e["username"]
|
|
)
|
|
else:
|
|
mozmsg.summary = (
|
|
e["eventtype"] + " " + e["result"] + " for " + e["user"]["name"]
|
|
)
|
|
|
|
mozmsg.send()
|
|
|
|
# last event timestamp record is stored and returned so that we can save our last position in the log.
|
|
try:
|
|
state[etype] = e["timestamp"]
|
|
except UnboundLocalError:
|
|
# duo_events was empty, no new event
|
|
pass
|
|
return state
|
|
|
|
|
|
def main():
|
|
try:
|
|
state = pickle.load(open(options.statepath, "rb"))
|
|
except IOError:
|
|
# Oh, you're new.
|
|
# Note API v2 expect full, correct and within range timestamps in millisec so we start recently
|
|
# API v1 uses normal timestamps in seconds instead
|
|
state = {
|
|
"administration": 0,
|
|
"administration_offset": None,
|
|
"authentication": 1547000000000,
|
|
"authentication_offset": None,
|
|
"telephony": 0,
|
|
"telephony_offset": None,
|
|
}
|
|
|
|
# Convert v1 (sec) timestamp to v2 (ms)...
|
|
if state["authentication"] < 1547000000000:
|
|
state["authentication"] = int(str(state["authentication"]) + "000")
|
|
|
|
duo = duo_client.Admin(ikey=options.IKEY, skey=options.SKEY, host=options.URL)
|
|
mozmsg = mozdef.MozDefEvent(options.MOZDEF_URL)
|
|
mozmsg.tags = ["duosecurity"]
|
|
if options.update_tags != "":
|
|
mozmsg.tags.append(options.update_tags)
|
|
mozmsg.set_category("authentication")
|
|
mozmsg.source = "DuoSecurityAPI"
|
|
if options.DEBUG:
|
|
mozmsg.debug = options.DEBUG
|
|
mozmsg.set_send_to_syslog(True, only_syslog=True)
|
|
|
|
# This will process events for all 3 log types and send them to MozDef. the state stores the last position in the
|
|
# log when this script was last called.
|
|
# NOTE: If administration and telephone logs support a "v2" API in the future it will most likely need to have the
|
|
# same code with `next_offset` as authentication uses.
|
|
state = process_events(
|
|
mozmsg,
|
|
duo.get_administrator_log(mintime=state["administration"] + 1),
|
|
"administration",
|
|
state,
|
|
)
|
|
state = process_events(
|
|
mozmsg,
|
|
duo.get_authentication_log(
|
|
api_version=2,
|
|
limit="1000",
|
|
sort="ts:asc",
|
|
mintime=state["authentication"] + 1,
|
|
next_offset=state["authentication_offset"],
|
|
),
|
|
"authentication",
|
|
state,
|
|
)
|
|
state = process_events(
|
|
mozmsg,
|
|
duo.get_telephony_log(mintime=state["telephony"] + 1),
|
|
"telephony",
|
|
state,
|
|
)
|
|
|
|
pickle.dump(state, open(options.statepath, "wb"))
|
|
|
|
|
|
def initConfig():
|
|
options.IKEY = getConfig("IKEY", "", options.configfile)
|
|
options.SKEY = getConfig("SKEY", "", options.configfile)
|
|
options.URL = getConfig("URL", "", options.configfile)
|
|
options.MOZDEF_URL = getConfig("MOZDEF_URL", "", options.configfile)
|
|
options.DEBUG = getConfig("DEBUG", True, options.configfile)
|
|
options.statepath = getConfig("statepath", "", options.configfile)
|
|
options.update_tags = getConfig("addtag", "", options.configfile)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = OptionParser()
|
|
defaultconfigfile = sys.argv[0].replace(".py", ".conf")
|
|
parser.add_option(
|
|
"-c",
|
|
dest="configfile",
|
|
default=defaultconfigfile,
|
|
help="configuration file to use",
|
|
)
|
|
(options, args) = parser.parse_args()
|
|
initConfig()
|
|
main()
|