mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-06-04 18:09:44 +00:00
Update docs
This commit is contained in:
@@ -9,13 +9,10 @@ Package](https://img.shields.io/pypi/v/parsedmarc.svg)](https://pypi.org/project
|
||||
[](https://pypistats.org/packages/parsedmarc)
|
||||
|
||||
:::{note}
|
||||
**Help Wanted**
|
||||
*Sponsors*
|
||||
|
||||
This is a project is maintained by one developer.
|
||||
Please consider reviewing the open [issues] to see how you can contribute code, documentation, or user support.
|
||||
Assistance on the pinned issues would be particularly helpful.
|
||||
|
||||
Thanks to all [contributors]!
|
||||
Please consider [sponsoring my work](https://github.com/sponsors/seanthegeek) if you or your organization benefit from it.
|
||||
:::
|
||||
|
||||
```{image} _static/screenshots/dmarc-summary-charts.png
|
||||
@@ -79,6 +76,3 @@ dmarc
|
||||
contributing
|
||||
api
|
||||
```
|
||||
|
||||
[contributors]: https://github.com/domainaware/parsedmarc/graphs/contributors
|
||||
[issues]: https://github.com/domainaware/parsedmarc/issues
|
||||
|
||||
+310
-189
@@ -49,8 +49,8 @@ logger.setLevel(logging.INFO)
|
||||
|
||||
feedback_report_regex = re.compile(r"^([\w\-]+): (.+)$", re.MULTILINE)
|
||||
|
||||
MAGIC_ZIP = b"\x50\x4B\x03\x04"
|
||||
MAGIC_GZIP = b"\x1F\x8B"
|
||||
MAGIC_ZIP = b"\x50\x4b\x03\x04"
|
||||
MAGIC_GZIP = b"\x1f\x8b"
|
||||
MAGIC_XML = b"\x3c\x3f\x78\x6d\x6c\x20"
|
||||
|
||||
|
||||
@@ -108,8 +108,7 @@ def _get_base_domain(domain):
|
||||
if not os.path.exists(psl_path):
|
||||
psl = download_psl()
|
||||
else:
|
||||
psl_age = datetime.now() - datetime.fromtimestamp(
|
||||
os.stat(psl_path).st_mtime)
|
||||
psl_age = datetime.now() - datetime.fromtimestamp(os.stat(psl_path).st_mtime)
|
||||
if psl_age > timedelta(hours=24):
|
||||
psl = download_psl()
|
||||
else:
|
||||
@@ -136,15 +135,21 @@ def _query_dns(domain, record_type, nameservers=None, timeout=6.0):
|
||||
resolver = dns.resolver.Resolver()
|
||||
timeout = float(timeout)
|
||||
if nameservers is None:
|
||||
nameservers = ["1.1.1.1", "1.0.0.1",
|
||||
"2606:4700:4700::1111", "2606:4700:4700::1001",
|
||||
]
|
||||
nameservers = [
|
||||
"1.1.1.1",
|
||||
"1.0.0.1",
|
||||
"2606:4700:4700::1111",
|
||||
"2606:4700:4700::1001",
|
||||
]
|
||||
resolver.nameservers = nameservers
|
||||
resolver.timeout = timeout
|
||||
resolver.lifetime = timeout
|
||||
return list(map(
|
||||
lambda r: r.to_text().replace(' "', '').replace('"', '').rstrip("."),
|
||||
resolver.query(domain, record_type, tcp=True)))
|
||||
return list(
|
||||
map(
|
||||
lambda r: r.to_text().replace(' "', "").replace('"', "").rstrip("."),
|
||||
resolver.query(domain, record_type, tcp=True),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _get_reverse_dns(ip_address, nameservers=None, timeout=6.0):
|
||||
@@ -163,9 +168,9 @@ def _get_reverse_dns(ip_address, nameservers=None, timeout=6.0):
|
||||
hostname = None
|
||||
try:
|
||||
address = dns.reversename.from_address(ip_address)
|
||||
hostname = _query_dns(address, "PTR",
|
||||
nameservers=nameservers,
|
||||
timeout=timeout)[0]
|
||||
hostname = _query_dns(address, "PTR", nameservers=nameservers, timeout=timeout)[
|
||||
0
|
||||
]
|
||||
|
||||
except dns.exception.DNSException:
|
||||
pass
|
||||
@@ -231,8 +236,10 @@ def _get_ip_address_country(ip_address):
|
||||
Args:
|
||||
location (str): Local location for the database file
|
||||
"""
|
||||
url = "https://geolite.maxmind.com/download/geoip/database/" \
|
||||
"GeoLite2-Country.tar.gz"
|
||||
url = (
|
||||
"https://geolite.maxmind.com/download/geoip/database/"
|
||||
"GeoLite2-Country.tar.gz"
|
||||
)
|
||||
original_filename = "GeoLite2-Country.mmdb"
|
||||
tar_file = tarfile.open(fileobj=BytesIO(get(url).content), mode="r:gz")
|
||||
tar_dir = tar_file.getnames()[0]
|
||||
@@ -241,8 +248,10 @@ def _get_ip_address_country(ip_address):
|
||||
shutil.move(tar_path, location)
|
||||
shutil.rmtree(tar_dir)
|
||||
|
||||
system_paths = ["/usr/local/share/GeoIP/GeoLite2-Country.mmdb",
|
||||
"/usr/share/GeoIP/GeoLite2-Country.mmdb"]
|
||||
system_paths = [
|
||||
"/usr/local/share/GeoIP/GeoLite2-Country.mmdb",
|
||||
"/usr/share/GeoIP/GeoLite2-Country.mmdb",
|
||||
]
|
||||
db_path = ""
|
||||
|
||||
for system_path in system_paths:
|
||||
@@ -255,7 +264,8 @@ def _get_ip_address_country(ip_address):
|
||||
download_country_database(db_filename)
|
||||
else:
|
||||
db_age = datetime.now() - datetime.fromtimestamp(
|
||||
os.stat(db_filename).st_mtime)
|
||||
os.stat(db_filename).st_mtime
|
||||
)
|
||||
if db_age > timedelta(days=60):
|
||||
download_country_database()
|
||||
db_path = db_filename
|
||||
@@ -289,9 +299,7 @@ def _get_ip_address_info(ip_address, nameservers=None, timeout=6.0):
|
||||
ip_address = ip_address.lower()
|
||||
info = OrderedDict()
|
||||
info["ip_address"] = ip_address
|
||||
reverse_dns = _get_reverse_dns(ip_address,
|
||||
nameservers=nameservers,
|
||||
timeout=timeout)
|
||||
reverse_dns = _get_reverse_dns(ip_address, nameservers=nameservers, timeout=timeout)
|
||||
country = _get_ip_address_country(ip_address)
|
||||
info["country"] = country
|
||||
info["reverse_dns"] = reverse_dns
|
||||
@@ -321,16 +329,19 @@ def _parse_report_record(record, nameservers=None, timeout=6.0):
|
||||
nameservers = ["8.8.8.8", "4.4.4.4"]
|
||||
record = record.copy()
|
||||
new_record = OrderedDict()
|
||||
new_record["source"] = _get_ip_address_info(record["row"]["source_ip"],
|
||||
nameservers=nameservers,
|
||||
timeout=timeout)
|
||||
new_record["source"] = _get_ip_address_info(
|
||||
record["row"]["source_ip"], nameservers=nameservers, timeout=timeout
|
||||
)
|
||||
new_record["count"] = int(record["row"]["count"])
|
||||
policy_evaluated = record["row"]["policy_evaluated"].copy()
|
||||
new_policy_evaluated = OrderedDict([("disposition", "none"),
|
||||
("dkim", "fail"),
|
||||
("spf", "fail"),
|
||||
("policy_override_reasons", [])
|
||||
])
|
||||
new_policy_evaluated = OrderedDict(
|
||||
[
|
||||
("disposition", "none"),
|
||||
("dkim", "fail"),
|
||||
("spf", "fail"),
|
||||
("policy_override_reasons", []),
|
||||
]
|
||||
)
|
||||
if "disposition" in policy_evaluated:
|
||||
new_policy_evaluated["disposition"] = policy_evaluated["disposition"]
|
||||
if "dkim" in policy_evaluated:
|
||||
@@ -428,8 +439,7 @@ def parse_aggregate_report_xml(xml, nameservers=None, timeout=6.0):
|
||||
new_report_metadata["org_extra_contact_info"] = extra
|
||||
new_report_metadata["report_id"] = report_metadata["report_id"]
|
||||
report_id = new_report_metadata["report_id"]
|
||||
report_id = report_id.replace("<",
|
||||
"").replace(">", "").split("@")[0]
|
||||
report_id = report_id.replace("<", "").replace(">", "").split("@")[0]
|
||||
new_report_metadata["report_id"] = report_id
|
||||
date_range = report["report_metadata"]["date_range"]
|
||||
date_range["begin"] = _timestamp_to_human(date_range["begin"])
|
||||
@@ -478,9 +488,11 @@ def parse_aggregate_report_xml(xml, nameservers=None, timeout=6.0):
|
||||
|
||||
if type(report["record"]) == list:
|
||||
for record in report["record"]:
|
||||
records.append(_parse_report_record(record,
|
||||
nameservers=nameservers,
|
||||
timeout=timeout))
|
||||
records.append(
|
||||
_parse_report_record(
|
||||
record, nameservers=nameservers, timeout=timeout
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
records.append(_parse_report_record(report["record"]))
|
||||
@@ -490,8 +502,7 @@ def parse_aggregate_report_xml(xml, nameservers=None, timeout=6.0):
|
||||
return new_report
|
||||
|
||||
except KeyError as error:
|
||||
raise InvalidAggregateReport("Missing field: "
|
||||
"{0}".format(error.__str__()))
|
||||
raise InvalidAggregateReport("Missing field: {0}".format(error.__str__()))
|
||||
|
||||
|
||||
def extract_xml(input_):
|
||||
@@ -529,8 +540,7 @@ def extract_xml(input_):
|
||||
file_object.close()
|
||||
|
||||
except UnicodeDecodeError:
|
||||
raise InvalidAggregateReport("File objects must be opened in binary "
|
||||
"(rb) mode")
|
||||
raise InvalidAggregateReport("File objects must be opened in binary (rb) mode")
|
||||
|
||||
return xml
|
||||
|
||||
@@ -550,9 +560,7 @@ def parse_aggregate_report_file(_input, nameservers=None, timeout=6.0):
|
||||
"""
|
||||
xml = extract_xml(_input)
|
||||
|
||||
return parse_aggregate_report_xml(xml,
|
||||
nameservers=nameservers,
|
||||
timeout=timeout)
|
||||
return parse_aggregate_report_xml(xml, nameservers=nameservers, timeout=timeout)
|
||||
|
||||
|
||||
def parsed_aggregate_reports_to_csv(reports):
|
||||
@@ -566,15 +574,42 @@ def parsed_aggregate_reports_to_csv(reports):
|
||||
Returns:
|
||||
str: Parsed aggregate report data in flat CSV format, including headers
|
||||
"""
|
||||
fields = ["xml_schema", "org_name", "org_email",
|
||||
"org_extra_contact_info", "report_id", "begin_date", "end_date",
|
||||
"errors", "domain", "adkim", "aspf", "p", "sp", "pct", "fo",
|
||||
"source_ip_address", "source_country", "source_reverse_dns",
|
||||
"source_base_domain", "count", "disposition", "dkim_alignment",
|
||||
"spf_alignment", "policy_override_reasons",
|
||||
"policy_override_comments", "envelope_from", "header_from",
|
||||
"envelope_to", "dkim_domains", "dkim_selectors", "dkim_results",
|
||||
"spf_domains", "spf_scopes", "spf_results"]
|
||||
fields = [
|
||||
"xml_schema",
|
||||
"org_name",
|
||||
"org_email",
|
||||
"org_extra_contact_info",
|
||||
"report_id",
|
||||
"begin_date",
|
||||
"end_date",
|
||||
"errors",
|
||||
"domain",
|
||||
"adkim",
|
||||
"aspf",
|
||||
"p",
|
||||
"sp",
|
||||
"pct",
|
||||
"fo",
|
||||
"source_ip_address",
|
||||
"source_country",
|
||||
"source_reverse_dns",
|
||||
"source_base_domain",
|
||||
"count",
|
||||
"disposition",
|
||||
"dkim_alignment",
|
||||
"spf_alignment",
|
||||
"policy_override_reasons",
|
||||
"policy_override_comments",
|
||||
"envelope_from",
|
||||
"header_from",
|
||||
"envelope_to",
|
||||
"dkim_domains",
|
||||
"dkim_selectors",
|
||||
"dkim_results",
|
||||
"spf_domains",
|
||||
"spf_scopes",
|
||||
"spf_results",
|
||||
]
|
||||
|
||||
csv_file_object = StringIO()
|
||||
writer = DictWriter(csv_file_object, fields)
|
||||
@@ -600,12 +635,23 @@ def parsed_aggregate_reports_to_csv(reports):
|
||||
pct = report["policy_published"]["pct"]
|
||||
fo = report["policy_published"]["fo"]
|
||||
|
||||
report_dict = dict(xml_schema=xml_schema, org_name=org_name,
|
||||
org_email=org_email,
|
||||
org_extra_contact_info=org_extra_contact,
|
||||
report_id=report_id, begin_date=begin_date,
|
||||
end_date=end_date, errors=errors, domain=domain,
|
||||
adkim=adkim, aspf=aspf, p=p, sp=sp, pct=pct, fo=fo)
|
||||
report_dict = dict(
|
||||
xml_schema=xml_schema,
|
||||
org_name=org_name,
|
||||
org_email=org_email,
|
||||
org_extra_contact_info=org_extra_contact,
|
||||
report_id=report_id,
|
||||
begin_date=begin_date,
|
||||
end_date=end_date,
|
||||
errors=errors,
|
||||
domain=domain,
|
||||
adkim=adkim,
|
||||
aspf=aspf,
|
||||
p=p,
|
||||
sp=sp,
|
||||
pct=pct,
|
||||
fo=fo,
|
||||
)
|
||||
|
||||
for record in report["records"]:
|
||||
row = report_dict
|
||||
@@ -617,16 +663,20 @@ def parsed_aggregate_reports_to_csv(reports):
|
||||
row["disposition"] = record["policy_evaluated"]["disposition"]
|
||||
row["spf_alignment"] = record["policy_evaluated"]["spf"]
|
||||
row["dkim_alignment"] = record["policy_evaluated"]["dkim"]
|
||||
policy_override_reasons = list(map(lambda r: r["type"],
|
||||
record["policy_evaluated"]
|
||||
["policy_override_reasons"]))
|
||||
policy_override_comments = list(map(lambda r: r["comment"],
|
||||
record["policy_evaluated"]
|
||||
["policy_override_reasons"]))
|
||||
row["policy_override_reasons"] = ",".join(
|
||||
policy_override_reasons)
|
||||
row["policy_override_comments"] = "|".join(
|
||||
policy_override_comments)
|
||||
policy_override_reasons = list(
|
||||
map(
|
||||
lambda r: r["type"],
|
||||
record["policy_evaluated"]["policy_override_reasons"],
|
||||
)
|
||||
)
|
||||
policy_override_comments = list(
|
||||
map(
|
||||
lambda r: r["comment"],
|
||||
record["policy_evaluated"]["policy_override_reasons"],
|
||||
)
|
||||
)
|
||||
row["policy_override_reasons"] = ",".join(policy_override_reasons)
|
||||
row["policy_override_comments"] = "|".join(policy_override_comments)
|
||||
row["envelope_from"] = record["identifiers"]["envelope_from"]
|
||||
row["header_from"] = record["identifiers"]["header_from"]
|
||||
envelope_to = record["identifiers"]["envelope_to"]
|
||||
@@ -659,8 +709,9 @@ def parsed_aggregate_reports_to_csv(reports):
|
||||
return csv_file_object.getvalue()
|
||||
|
||||
|
||||
def parse_forensic_report(feedback_report, sample, sample_headers_only,
|
||||
nameservers=None, timeout=6.0):
|
||||
def parse_forensic_report(
|
||||
feedback_report, sample, sample_headers_only, nameservers=None, timeout=6.0
|
||||
):
|
||||
"""
|
||||
Converts a DMARC forensic report and sample to a ``OrderedDict``
|
||||
|
||||
@@ -683,8 +734,7 @@ def parse_forensic_report(feedback_report, sample, sample_headers_only,
|
||||
display_name = original_address[0]
|
||||
address = original_address[1]
|
||||
|
||||
return OrderedDict([("display_name", display_name),
|
||||
("address", address)])
|
||||
return OrderedDict([("display_name", display_name), ("address", address)])
|
||||
|
||||
def get_filename_safe_subject(_subject):
|
||||
"""
|
||||
@@ -695,8 +745,7 @@ def parse_forensic_report(feedback_report, sample, sample_headers_only,
|
||||
Returns:
|
||||
str: A string safe for a filename
|
||||
"""
|
||||
invalid_filename_chars = ['\\', '/', ':', '"', '*', '?', '|', '\n',
|
||||
'\r']
|
||||
invalid_filename_chars = ["\\", "/", ":", '"', "*", "?", "|", "\n", "\r"]
|
||||
if _subject is None:
|
||||
_subject = "No Subject"
|
||||
for char in invalid_filename_chars:
|
||||
@@ -712,15 +761,16 @@ def parse_forensic_report(feedback_report, sample, sample_headers_only,
|
||||
key = report_value[0].lower().replace("-", "_")
|
||||
parsed_report[key] = report_value[1]
|
||||
if key == "arrival_date":
|
||||
arrival_utc = dateparser.parse(parsed_report["arrival_date"],
|
||||
settings={"TO_TIMEZONE": "UTC"})
|
||||
arrival_utc = dateparser.parse(
|
||||
parsed_report["arrival_date"], settings={"TO_TIMEZONE": "UTC"}
|
||||
)
|
||||
arrival_utc = arrival_utc.strftime("%Y-%m-%d %H:%M:%S")
|
||||
parsed_report["arrival_date_utc"] = arrival_utc
|
||||
|
||||
ip_address = parsed_report["source_ip"]
|
||||
parsed_report["source"] = _get_ip_address_info(ip_address,
|
||||
nameservers=nameservers,
|
||||
timeout=timeout)
|
||||
parsed_report["source"] = _get_ip_address_info(
|
||||
ip_address, nameservers=nameservers, timeout=timeout
|
||||
)
|
||||
del parsed_report["source_ip"]
|
||||
|
||||
if "identity_alignment" not in parsed_report:
|
||||
@@ -739,8 +789,12 @@ def parse_forensic_report(feedback_report, sample, sample_headers_only,
|
||||
auth_failure = parsed_report["auth_failure"].split(",")
|
||||
parsed_report["auth_failure"] = auth_failure
|
||||
|
||||
optional_fields = ["original_envelope_id", "dkim_domain",
|
||||
"original_mail_from", "original_rcpt_to"]
|
||||
optional_fields = [
|
||||
"original_envelope_id",
|
||||
"dkim_domain",
|
||||
"original_mail_from",
|
||||
"original_rcpt_to",
|
||||
]
|
||||
for optional_field in optional_fields:
|
||||
if optional_field not in parsed_report:
|
||||
parsed_report[optional_field] = None
|
||||
@@ -756,34 +810,36 @@ def parse_forensic_report(feedback_report, sample, sample_headers_only,
|
||||
if "received" in parsed_message:
|
||||
for received in parsed_message["received"]:
|
||||
if "date_utc" in received:
|
||||
received["date_utc"] = received["date_utc"].replace("T",
|
||||
" ")
|
||||
received["date_utc"] = received["date_utc"].replace("T", " ")
|
||||
parsed_sample["from"] = convert_address(parsed_sample["from"][0])
|
||||
|
||||
if "reply_to" in parsed_sample:
|
||||
parsed_sample["reply_to"] = list(map(lambda x: convert_address(x),
|
||||
parsed_sample["reply_to"]))
|
||||
parsed_sample["reply_to"] = list(
|
||||
map(lambda x: convert_address(x), parsed_sample["reply_to"])
|
||||
)
|
||||
else:
|
||||
parsed_sample["reply_to"] = []
|
||||
|
||||
parsed_sample["to"] = list(map(lambda x: convert_address(x),
|
||||
parsed_sample["to"]))
|
||||
parsed_sample["to"] = list(
|
||||
map(lambda x: convert_address(x), parsed_sample["to"])
|
||||
)
|
||||
if "cc" in parsed_sample:
|
||||
parsed_sample["cc"] = list(map(lambda x: convert_address(x),
|
||||
parsed_sample["cc"]))
|
||||
parsed_sample["cc"] = list(
|
||||
map(lambda x: convert_address(x), parsed_sample["cc"])
|
||||
)
|
||||
else:
|
||||
parsed_sample["cc"] = []
|
||||
|
||||
if "bcc" in parsed_sample:
|
||||
parsed_sample["bcc"] = list(map(lambda x: convert_address(x),
|
||||
parsed_sample["bcc"]))
|
||||
parsed_sample["bcc"] = list(
|
||||
map(lambda x: convert_address(x), parsed_sample["bcc"])
|
||||
)
|
||||
else:
|
||||
parsed_sample["bcc"] = []
|
||||
|
||||
if "delivered_to" in parsed_sample:
|
||||
parsed_sample["delivered_to"] = list(
|
||||
map(lambda x: convert_address(x),
|
||||
parsed_sample["delivered_to"])
|
||||
map(lambda x: convert_address(x), parsed_sample["delivered_to"])
|
||||
)
|
||||
|
||||
if "attachments" not in parsed_sample:
|
||||
@@ -793,7 +849,8 @@ def parse_forensic_report(feedback_report, sample, sample_headers_only,
|
||||
parsed_sample["subject"] = None
|
||||
|
||||
parsed_sample["filename_safe_subject"] = get_filename_safe_subject(
|
||||
parsed_sample["subject"])
|
||||
parsed_sample["subject"]
|
||||
)
|
||||
|
||||
if "body" not in parsed_sample:
|
||||
parsed_sample["body"] = None
|
||||
@@ -809,8 +866,7 @@ def parse_forensic_report(feedback_report, sample, sample_headers_only,
|
||||
return parsed_report
|
||||
|
||||
except KeyError as error:
|
||||
raise InvalidForensicReport("Missing value: {0}".format(
|
||||
error.__str__()))
|
||||
raise InvalidForensicReport("Missing value: {0}".format(error.__str__()))
|
||||
|
||||
|
||||
def parsed_forensic_reports_to_csv(reports):
|
||||
@@ -823,14 +879,30 @@ def parsed_forensic_reports_to_csv(reports):
|
||||
|
||||
Returns:
|
||||
str: Parsed forensic report data in flat CSV format, including headers
|
||||
"""
|
||||
fields = ["feedback_type", "user_agent", "version", "original_envelope_id",
|
||||
"original_mail_from", "original_rcpt_to", "arrival_date",
|
||||
"arrival_date_utc", "subject", "message_id",
|
||||
"authentication_results", "dkim_domain", "source_ip_address",
|
||||
"source_country", "source_reverse_dns", "source_base_domain",
|
||||
"delivery_result", "auth_failure", "reported_domain",
|
||||
"authentication_mechanisms", "sample_headers_only"]
|
||||
"""
|
||||
fields = [
|
||||
"feedback_type",
|
||||
"user_agent",
|
||||
"version",
|
||||
"original_envelope_id",
|
||||
"original_mail_from",
|
||||
"original_rcpt_to",
|
||||
"arrival_date",
|
||||
"arrival_date_utc",
|
||||
"subject",
|
||||
"message_id",
|
||||
"authentication_results",
|
||||
"dkim_domain",
|
||||
"source_ip_address",
|
||||
"source_country",
|
||||
"source_reverse_dns",
|
||||
"source_base_domain",
|
||||
"delivery_result",
|
||||
"auth_failure",
|
||||
"reported_domain",
|
||||
"authentication_mechanisms",
|
||||
"sample_headers_only",
|
||||
]
|
||||
|
||||
if type(reports) == OrderedDict:
|
||||
reports = [reports]
|
||||
@@ -847,8 +919,7 @@ def parsed_forensic_reports_to_csv(reports):
|
||||
row["subject"] = report["parsed_sample"]["subject"]
|
||||
row["auth_failure"] = ",".join(report["auth_failure"])
|
||||
authentication_mechanisms = report["authentication_mechanisms"]
|
||||
row["authentication_mechanisms"] = ",".join(
|
||||
authentication_mechanisms)
|
||||
row["authentication_mechanisms"] = ",".join(authentication_mechanisms)
|
||||
del row["sample"]
|
||||
del row["parsed_sample"]
|
||||
csv_writer.writerow(row)
|
||||
@@ -873,7 +944,7 @@ def parse_report_email(input_, nameservers=None, timeout=6.0):
|
||||
|
||||
def is_outlook_msg(suspect_bytes):
|
||||
"""Checks if the given content is a Outlook msg OLE file"""
|
||||
return suspect_bytes.startswith(b"\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1")
|
||||
return suspect_bytes.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1")
|
||||
|
||||
def convert_outlook_msg(msg_bytes):
|
||||
"""
|
||||
@@ -903,7 +974,8 @@ def parse_report_email(input_, nameservers=None, timeout=6.0):
|
||||
"Error running msgconvert. Please ensure it is installed\n"
|
||||
"sudo apt install libemail-outlook-message-perl\n"
|
||||
"https://github.com/mvz/email-outlook-message-perl\n\n"
|
||||
"{0}".format(e))
|
||||
"{0}".format(e)
|
||||
)
|
||||
finally:
|
||||
os.chdir(orig_dir)
|
||||
shutil.rmtree(tmp_dir)
|
||||
@@ -918,8 +990,7 @@ def parse_report_email(input_, nameservers=None, timeout=6.0):
|
||||
for header_part in decoded_header:
|
||||
if type(header_part[0]) == bytes:
|
||||
encoding = header_part[1] or "ascii"
|
||||
header_part = header_part[0].decode(encoding=encoding,
|
||||
errors="replace")
|
||||
header_part = header_part[0].decode(encoding=encoding, errors="replace")
|
||||
else:
|
||||
header_part = header_part[0]
|
||||
header += header_part
|
||||
@@ -953,32 +1024,37 @@ def parse_report_email(input_, nameservers=None, timeout=6.0):
|
||||
sample = payload
|
||||
sample_headers_only = False
|
||||
if feedback_report and sample:
|
||||
forensic_report = parse_forensic_report(feedback_report,
|
||||
sample,
|
||||
sample_headers_only,
|
||||
nameservers=nameservers,
|
||||
timeout=timeout)
|
||||
forensic_report = parse_forensic_report(
|
||||
feedback_report,
|
||||
sample,
|
||||
sample_headers_only,
|
||||
nameservers=nameservers,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
result = OrderedDict([("report_type", "forensic"),
|
||||
("report", forensic_report)])
|
||||
result = OrderedDict(
|
||||
[("report_type", "forensic"), ("report", forensic_report)]
|
||||
)
|
||||
return result
|
||||
try:
|
||||
payload = b64decode(payload)
|
||||
if payload.startswith(MAGIC_ZIP) or \
|
||||
payload.startswith(MAGIC_GZIP) or \
|
||||
payload.startswith(MAGIC_XML):
|
||||
if (
|
||||
payload.startswith(MAGIC_ZIP)
|
||||
or payload.startswith(MAGIC_GZIP)
|
||||
or payload.startswith(MAGIC_XML)
|
||||
):
|
||||
ns = nameservers
|
||||
aggregate_report = parse_aggregate_report_file(payload,
|
||||
nameservers=ns,
|
||||
timeout=timeout)
|
||||
result = OrderedDict([("report_type", "aggregate"),
|
||||
("report", aggregate_report)])
|
||||
aggregate_report = parse_aggregate_report_file(
|
||||
payload, nameservers=ns, timeout=timeout
|
||||
)
|
||||
result = OrderedDict(
|
||||
[("report_type", "aggregate"), ("report", aggregate_report)]
|
||||
)
|
||||
except (TypeError, binascii.Error):
|
||||
pass
|
||||
|
||||
if result is None:
|
||||
error = 'Message with subject "{0}" is ' \
|
||||
'not a valid DMARC report'.format(subject)
|
||||
error = 'Message with subject "{0}" is not a valid DMARC report'.format(subject)
|
||||
raise InvalidDMARCReport(error)
|
||||
|
||||
return result
|
||||
@@ -1006,27 +1082,31 @@ def parse_report_file(input_, nameservers=None, timeout=6.0):
|
||||
|
||||
content = file_object.read()
|
||||
try:
|
||||
report = parse_aggregate_report_file(content, nameservers=nameservers,
|
||||
timeout=timeout)
|
||||
results = OrderedDict([("report_type", "aggregate"),
|
||||
("report", report)])
|
||||
report = parse_aggregate_report_file(
|
||||
content, nameservers=nameservers, timeout=timeout
|
||||
)
|
||||
results = OrderedDict([("report_type", "aggregate"), ("report", report)])
|
||||
except InvalidAggregateReport:
|
||||
try:
|
||||
results = parse_report_email(content,
|
||||
nameservers=nameservers,
|
||||
timeout=timeout)
|
||||
results = parse_report_email(
|
||||
content, nameservers=nameservers, timeout=timeout
|
||||
)
|
||||
except InvalidDMARCReport:
|
||||
raise InvalidDMARCReport("Not a valid aggregate or forensic "
|
||||
"report")
|
||||
raise InvalidDMARCReport("Not a valid aggregate or forensic report")
|
||||
return results
|
||||
|
||||
|
||||
def get_dmarc_reports_from_inbox(host, user, password,
|
||||
reports_folder="INBOX",
|
||||
archive_folder="Archive",
|
||||
delete=False, test=False,
|
||||
nameservers=None,
|
||||
dns_timeout=6.0):
|
||||
def get_dmarc_reports_from_inbox(
|
||||
host,
|
||||
user,
|
||||
password,
|
||||
reports_folder="INBOX",
|
||||
archive_folder="Archive",
|
||||
delete=False,
|
||||
test=False,
|
||||
nameservers=None,
|
||||
dns_timeout=6.0,
|
||||
):
|
||||
"""
|
||||
Fetches and parses DMARC reports from sn inbox
|
||||
|
||||
@@ -1048,7 +1128,7 @@ def get_dmarc_reports_from_inbox(host, user, password,
|
||||
def chunks(l, n):
|
||||
"""Yield successive n-sized chunks from l."""
|
||||
for i in range(0, len(l), n):
|
||||
yield l[i:i + n]
|
||||
yield l[i : i + n]
|
||||
|
||||
if delete and test:
|
||||
raise ValueError("delete and test options are mutually exclusive")
|
||||
@@ -1072,14 +1152,13 @@ def get_dmarc_reports_from_inbox(host, user, password,
|
||||
server.create_folder(forensic_reports_folder)
|
||||
messages = server.search()
|
||||
for message_uid in messages:
|
||||
raw_msg = server.fetch(message_uid,
|
||||
["RFC822"])[message_uid][b"RFC822"]
|
||||
raw_msg = server.fetch(message_uid, ["RFC822"])[message_uid][b"RFC822"]
|
||||
msg_content = raw_msg.decode("utf-8", errors="replace")
|
||||
|
||||
try:
|
||||
parsed_email = parse_report_email(msg_content,
|
||||
nameservers=nameservers,
|
||||
timeout=dns_timeout)
|
||||
parsed_email = parse_report_email(
|
||||
msg_content, nameservers=nameservers, timeout=dns_timeout
|
||||
)
|
||||
if parsed_email["report_type"] == "aggregate":
|
||||
aggregate_reports.append(parsed_email["report"])
|
||||
aggregate_report_msg_uids.append(message_uid)
|
||||
@@ -1091,22 +1170,25 @@ def get_dmarc_reports_from_inbox(host, user, password,
|
||||
|
||||
if not test:
|
||||
if delete:
|
||||
processed_messages = aggregate_report_msg_uids + \
|
||||
forensic_report_msg_uids
|
||||
processed_messages = (
|
||||
aggregate_report_msg_uids + forensic_report_msg_uids
|
||||
)
|
||||
server.add_flags(processed_messages, [imapclient.DELETED])
|
||||
server.expunge()
|
||||
else:
|
||||
if len(aggregate_report_msg_uids) > 0:
|
||||
for chunk in chunks(aggregate_report_msg_uids, 100):
|
||||
server.move(chunk,
|
||||
aggregate_reports_folder)
|
||||
server.move(chunk, aggregate_reports_folder)
|
||||
if len(forensic_report_msg_uids) > 0:
|
||||
for chunk in chunks(forensic_report_msg_uids, 100):
|
||||
server.move(chunk,
|
||||
forensic_reports_folder)
|
||||
server.move(chunk, forensic_reports_folder)
|
||||
|
||||
results = OrderedDict([("aggregate_reports", aggregate_reports),
|
||||
("forensic_reports", forensic_reports)])
|
||||
results = OrderedDict(
|
||||
[
|
||||
("aggregate_reports", aggregate_reports),
|
||||
("forensic_reports", forensic_reports),
|
||||
]
|
||||
)
|
||||
|
||||
return results
|
||||
except imapclient.exceptions.IMAPClientError as error:
|
||||
@@ -1146,23 +1228,37 @@ def save_output(results, output_directory="output"):
|
||||
else:
|
||||
os.makedirs(output_directory)
|
||||
|
||||
with open("{0}".format(os.path.join(output_directory, "aggregate.json")),
|
||||
"w", newline="\n", encoding="utf-8") as agg_json:
|
||||
agg_json.write(json.dumps(aggregate_reports, ensure_ascii=False,
|
||||
indent=2))
|
||||
with open(
|
||||
"{0}".format(os.path.join(output_directory, "aggregate.json")),
|
||||
"w",
|
||||
newline="\n",
|
||||
encoding="utf-8",
|
||||
) as agg_json:
|
||||
agg_json.write(json.dumps(aggregate_reports, ensure_ascii=False, indent=2))
|
||||
|
||||
with open("{0}".format(os.path.join(output_directory, "aggregate.csv")),
|
||||
"w", newline="\n", encoding="utf-8") as agg_csv:
|
||||
with open(
|
||||
"{0}".format(os.path.join(output_directory, "aggregate.csv")),
|
||||
"w",
|
||||
newline="\n",
|
||||
encoding="utf-8",
|
||||
) as agg_csv:
|
||||
csv = parsed_aggregate_reports_to_csv(aggregate_reports)
|
||||
agg_csv.write(csv)
|
||||
|
||||
with open("{0}".format(os.path.join(output_directory, "forensic.json")),
|
||||
"w", newline="\n", encoding="utf-8") as for_json:
|
||||
for_json.write(json.dumps(forensic_reports, ensure_ascii=False,
|
||||
indent=2))
|
||||
with open(
|
||||
"{0}".format(os.path.join(output_directory, "forensic.json")),
|
||||
"w",
|
||||
newline="\n",
|
||||
encoding="utf-8",
|
||||
) as for_json:
|
||||
for_json.write(json.dumps(forensic_reports, ensure_ascii=False, indent=2))
|
||||
|
||||
with open("{0}".format(os.path.join(output_directory, "forensic.csv")),
|
||||
"w", newline="\n", encoding="utf-8") as for_csv:
|
||||
with open(
|
||||
"{0}".format(os.path.join(output_directory, "forensic.csv")),
|
||||
"w",
|
||||
newline="\n",
|
||||
encoding="utf-8",
|
||||
) as for_csv:
|
||||
csv = parsed_forensic_reports_to_csv(forensic_reports)
|
||||
for_csv.write(csv)
|
||||
|
||||
@@ -1200,6 +1296,7 @@ def get_report_zip(results):
|
||||
Returns:
|
||||
bytes: zip file bytes
|
||||
"""
|
||||
|
||||
def add_subdir(root_path, subdir):
|
||||
subdir_path = os.path.join(root_path, subdir)
|
||||
for subdir_root, subdir_dirs, subdir_files in os.walk(subdir_path):
|
||||
@@ -1216,13 +1313,12 @@ def get_report_zip(results):
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
save_output(results, tmp_dir)
|
||||
with zipfile.ZipFile(storage, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
with zipfile.ZipFile(storage, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for root, dirs, files in os.walk(tmp_dir):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
if os.path.isfile(file_path):
|
||||
arcname = os.path.join(os.path.relpath(root, tmp_dir),
|
||||
file)
|
||||
arcname = os.path.join(os.path.relpath(root, tmp_dir), file)
|
||||
zip_file.write(file_path, arcname)
|
||||
for directory in dirs:
|
||||
dir_path = os.path.join(root, directory)
|
||||
@@ -1235,9 +1331,21 @@ def get_report_zip(results):
|
||||
return storage.getvalue()
|
||||
|
||||
|
||||
def email_results(results, host, mail_from, mail_to, port=0, starttls=True,
|
||||
use_ssl=False, user=None, password=None, subject=None,
|
||||
attachment_filename=None, message=None, ssl_context=None):
|
||||
def email_results(
|
||||
results,
|
||||
host,
|
||||
mail_from,
|
||||
mail_to,
|
||||
port=0,
|
||||
starttls=True,
|
||||
use_ssl=False,
|
||||
user=None,
|
||||
password=None,
|
||||
subject=None,
|
||||
attachment_filename=None,
|
||||
message=None,
|
||||
ssl_context=None,
|
||||
):
|
||||
"""
|
||||
Emails parsing results as a zip file
|
||||
|
||||
@@ -1267,10 +1375,10 @@ def email_results(results, host, mail_from, mail_to, port=0, starttls=True,
|
||||
assert isinstance(mail_to, list)
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = mail_from
|
||||
msg['To'] = COMMASPACE.join(mail_to)
|
||||
msg['Date'] = formatdate(localtime=True)
|
||||
msg['Subject'] = subject or "DMARC results for {0}".format(date_string)
|
||||
msg["From"] = mail_from
|
||||
msg["To"] = COMMASPACE.join(mail_to)
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg["Subject"] = subject or "DMARC results for {0}".format(date_string)
|
||||
text = message or "Please see the attached zip file\n"
|
||||
|
||||
msg.attach(MIMEText(text))
|
||||
@@ -1278,7 +1386,7 @@ def email_results(results, host, mail_from, mail_to, port=0, starttls=True,
|
||||
zip_bytes = get_report_zip(results)
|
||||
part = MIMEApplication(zip_bytes, Name=filename)
|
||||
|
||||
part['Content-Disposition'] = 'attachment; filename="{0}"'.format(filename)
|
||||
part["Content-Disposition"] = 'attachment; filename="{0}"'.format(filename)
|
||||
msg.attach(part)
|
||||
|
||||
try:
|
||||
@@ -1315,9 +1423,19 @@ def email_results(results, host, mail_from, mail_to, port=0, starttls=True,
|
||||
raise SMTPError("Certificate error: {0}".format(error.__str__()))
|
||||
|
||||
|
||||
def watch_inbox(host, username, password, callback, reports_folder="INBOX",
|
||||
archive_folder="Archive", delete=False, test=False, wait=30,
|
||||
nameservers=None, dns_timeout=6.0):
|
||||
def watch_inbox(
|
||||
host,
|
||||
username,
|
||||
password,
|
||||
callback,
|
||||
reports_folder="INBOX",
|
||||
archive_folder="Archive",
|
||||
delete=False,
|
||||
test=False,
|
||||
wait=30,
|
||||
nameservers=None,
|
||||
dns_timeout=6.0,
|
||||
):
|
||||
"""
|
||||
Use an IDLE IMAP connection to parse incoming emails, and pass the results
|
||||
to a callback function
|
||||
@@ -1379,15 +1497,18 @@ def watch_inbox(host, username, password, callback, reports_folder="INBOX",
|
||||
responses = server.idle_check(timeout=wait)
|
||||
if responses is not None:
|
||||
for response in responses:
|
||||
if response[1] == b'RECENT' and response[0] > 0:
|
||||
res = get_dmarc_reports_from_inbox(host, username,
|
||||
password,
|
||||
reports_folder=rf,
|
||||
archive_folder=af,
|
||||
delete=delete,
|
||||
test=test,
|
||||
nameservers=ns,
|
||||
dns_timeout=dt)
|
||||
if response[1] == b"RECENT" and response[0] > 0:
|
||||
res = get_dmarc_reports_from_inbox(
|
||||
host,
|
||||
username,
|
||||
password,
|
||||
reports_folder=rf,
|
||||
archive_folder=af,
|
||||
delete=delete,
|
||||
test=test,
|
||||
nameservers=ns,
|
||||
dns_timeout=dt,
|
||||
)
|
||||
callback(res)
|
||||
break
|
||||
except imapclient.exceptions.IMAPClientError as error:
|
||||
|
||||
+169
-101
@@ -3,7 +3,6 @@
|
||||
|
||||
"""A CLI for parsing DMARC reports"""
|
||||
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from glob import glob
|
||||
import logging
|
||||
@@ -12,17 +11,26 @@ import json
|
||||
|
||||
from elasticsearch.exceptions import ElasticsearchException
|
||||
|
||||
from parsedmarc import logger, IMAPError, get_dmarc_reports_from_inbox, \
|
||||
parse_report_file, elastic, save_output, watch_inbox, email_results, \
|
||||
SMTPError, ParserError, __version__
|
||||
from parsedmarc import (
|
||||
logger,
|
||||
IMAPError,
|
||||
get_dmarc_reports_from_inbox,
|
||||
parse_report_file,
|
||||
elastic,
|
||||
save_output,
|
||||
watch_inbox,
|
||||
email_results,
|
||||
SMTPError,
|
||||
ParserError,
|
||||
__version__,
|
||||
)
|
||||
|
||||
|
||||
def _main():
|
||||
"""Called when the module is executed"""
|
||||
|
||||
def process_reports(reports_):
|
||||
output_str = "{0}\n".format(json.dumps(reports_,
|
||||
ensure_ascii=False,
|
||||
indent=2))
|
||||
output_str = "{0}\n".format(json.dumps(reports_, ensure_ascii=False, indent=2))
|
||||
if not args.silent:
|
||||
print(output_str)
|
||||
if args.save_aggregate:
|
||||
@@ -32,8 +40,7 @@ def _main():
|
||||
except elastic.AlreadySaved as warning:
|
||||
logger.warning(warning.__str__())
|
||||
except ElasticsearchException as error_:
|
||||
logger.error("Elasticsearch Error: {0}".format(
|
||||
error_.__str__()))
|
||||
logger.error("Elasticsearch Error: {0}".format(error_.__str__()))
|
||||
exit(1)
|
||||
if args.save_forensic:
|
||||
for report in reports_["forensic_reports"]:
|
||||
@@ -42,77 +49,120 @@ def _main():
|
||||
except elastic.AlreadySaved as warning:
|
||||
logger.warning(warning.__str__())
|
||||
except ElasticsearchException as error_:
|
||||
logger.error("Elasticsearch Error: {0}".format(
|
||||
error_.__str__()))
|
||||
logger.error("Elasticsearch Error: {0}".format(error_.__str__()))
|
||||
|
||||
arg_parser = ArgumentParser(description="Parses DMARC reports")
|
||||
arg_parser.add_argument("file_path", nargs="*",
|
||||
help="one or more paths to aggregate or forensic "
|
||||
"report files or emails")
|
||||
arg_parser.add_argument("-o", "--output",
|
||||
help="Write output files to the given directory")
|
||||
arg_parser.add_argument("-n", "--nameservers", nargs="+",
|
||||
help="nameservers to query "
|
||||
"(Default is Cloudflare's)")
|
||||
arg_parser.add_argument("-t", "--timeout",
|
||||
help="number of seconds to wait for an answer "
|
||||
"from DNS (default 6.0)",
|
||||
type=float,
|
||||
default=6.0)
|
||||
arg_parser.add_argument(
|
||||
"file_path",
|
||||
nargs="*",
|
||||
help="one or more paths to aggregate or forensic report files or emails",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-o", "--output", help="Write output files to the given directory"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-n",
|
||||
"--nameservers",
|
||||
nargs="+",
|
||||
help="nameservers to query (Default is Cloudflare's)",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-t",
|
||||
"--timeout",
|
||||
help="number of seconds to wait for an answer from DNS (default 6.0)",
|
||||
type=float,
|
||||
default=6.0,
|
||||
)
|
||||
arg_parser.add_argument("-H", "--host", help="IMAP hostname or IP address")
|
||||
arg_parser.add_argument("-u", "--user", help="IMAP user")
|
||||
arg_parser.add_argument("-p", "--password", help="IMAP password")
|
||||
arg_parser.add_argument("-r", "--reports-folder", default="INBOX",
|
||||
help="The IMAP folder containing the reports\n"
|
||||
"Default: INBOX")
|
||||
arg_parser.add_argument("-a", "--archive-folder",
|
||||
help="Specifies the IMAP folder to move "
|
||||
"messages to after processing them\n"
|
||||
"Default: Archive",
|
||||
default="Archive")
|
||||
arg_parser.add_argument("-d", "--delete",
|
||||
help="Delete the reports after processing them",
|
||||
action="store_true", default=False)
|
||||
arg_parser.add_argument(
|
||||
"-r",
|
||||
"--reports-folder",
|
||||
default="INBOX",
|
||||
help="The IMAP folder containing the reports\nDefault: INBOX",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-a",
|
||||
"--archive-folder",
|
||||
help="Specifies the IMAP folder to move "
|
||||
"messages to after processing them\n"
|
||||
"Default: Archive",
|
||||
default="Archive",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-d",
|
||||
"--delete",
|
||||
help="Delete the reports after processing them",
|
||||
action="store_true",
|
||||
default=False,
|
||||
)
|
||||
|
||||
arg_parser.add_argument("-E", "--elasticsearch-host", nargs="*",
|
||||
help="A list of one or more Elasticsearch "
|
||||
"hostnames or URLs to use (Default "
|
||||
"localhost:9200)",
|
||||
default=["localhost:9200"])
|
||||
arg_parser.add_argument("--save-aggregate", action="store_true",
|
||||
default=False,
|
||||
help="Save aggregate reports to Elasticsearch")
|
||||
arg_parser.add_argument("--save-forensic", action="store_true",
|
||||
default=False,
|
||||
help="Save forensic reports to Elasticsearch")
|
||||
arg_parser.add_argument("-O", "--outgoing-host",
|
||||
help="Email the results using this host")
|
||||
arg_parser.add_argument("-U", "--outgoing-user",
|
||||
help="Email the results using this user")
|
||||
arg_parser.add_argument("-P", "--outgoing-password",
|
||||
help="Email the results using this password")
|
||||
arg_parser.add_argument("-F", "--outgoing-from",
|
||||
help="Email the results using this from address")
|
||||
arg_parser.add_argument("-T", "--outgoing-to", nargs="+",
|
||||
help="Email the results to these addresses")
|
||||
arg_parser.add_argument("-S", "--outgoing-subject",
|
||||
help="Email the results using this subject")
|
||||
arg_parser.add_argument("-A", "--outgoing-attachment",
|
||||
help="Email the results using this filename")
|
||||
arg_parser.add_argument("-M", "--outgoing-message",
|
||||
help="Email the results using this message")
|
||||
arg_parser.add_argument("-w", "--watch", action="store_true",
|
||||
help="Use an IMAP IDLE connection to process "
|
||||
"reports as they arrive in the inbox")
|
||||
arg_parser.add_argument("--test",
|
||||
help="Do not move or delete IMAP messages",
|
||||
action="store_true", default=False)
|
||||
arg_parser.add_argument("-s", "--silent", action="store_true",
|
||||
help="Only print errors")
|
||||
arg_parser.add_argument("--debug", action="store_true",
|
||||
help="Print debugging information")
|
||||
arg_parser.add_argument("-v", "--version", action="version",
|
||||
version=__version__)
|
||||
arg_parser.add_argument(
|
||||
"-E",
|
||||
"--elasticsearch-host",
|
||||
nargs="*",
|
||||
help="A list of one or more Elasticsearch "
|
||||
"hostnames or URLs to use (Default "
|
||||
"localhost:9200)",
|
||||
default=["localhost:9200"],
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--save-aggregate",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Save aggregate reports to Elasticsearch",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--save-forensic",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Save forensic reports to Elasticsearch",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-O", "--outgoing-host", help="Email the results using this host"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-U", "--outgoing-user", help="Email the results using this user"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-P", "--outgoing-password", help="Email the results using this password"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-F", "--outgoing-from", help="Email the results using this from address"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-T", "--outgoing-to", nargs="+", help="Email the results to these addresses"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-S", "--outgoing-subject", help="Email the results using this subject"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-A", "--outgoing-attachment", help="Email the results using this filename"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-M", "--outgoing-message", help="Email the results using this message"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-w",
|
||||
"--watch",
|
||||
action="store_true",
|
||||
help="Use an IMAP IDLE connection to process "
|
||||
"reports as they arrive in the inbox",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--test",
|
||||
help="Do not move or delete IMAP messages",
|
||||
action="store_true",
|
||||
default=False,
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-s", "--silent", action="store_true", help="Only print errors"
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--debug", action="store_true", help="Print debugging information"
|
||||
)
|
||||
arg_parser.add_argument("-v", "--version", action="version", version=__version__)
|
||||
|
||||
aggregate_reports = []
|
||||
forensic_reports = []
|
||||
@@ -143,33 +193,33 @@ def _main():
|
||||
|
||||
for file_path in file_paths:
|
||||
try:
|
||||
file_results = parse_report_file(file_path,
|
||||
nameservers=args.nameservers,
|
||||
timeout=args.timeout)
|
||||
file_results = parse_report_file(
|
||||
file_path, nameservers=args.nameservers, timeout=args.timeout
|
||||
)
|
||||
if file_results["report_type"] == "aggregate":
|
||||
aggregate_reports.append(file_results["report"])
|
||||
elif file_results["report_type"] == "forensic":
|
||||
forensic_reports.append(file_results["report"])
|
||||
|
||||
except ParserError as error:
|
||||
logger.error("Failed to parse {0} - {1}".format(file_path,
|
||||
error))
|
||||
logger.error("Failed to parse {0} - {1}".format(file_path, error))
|
||||
|
||||
if args.host:
|
||||
try:
|
||||
if args.user is None or args.password is None:
|
||||
logger.error("user and password must be specified if"
|
||||
"host is specified")
|
||||
logger.error("user and password must be specified ifhost is specified")
|
||||
|
||||
rf = args.reports_folder
|
||||
af = args.archive_folder
|
||||
reports = get_dmarc_reports_from_inbox(args.host,
|
||||
args.user,
|
||||
args.password,
|
||||
reports_folder=rf,
|
||||
archive_folder=af,
|
||||
delete=args.delete,
|
||||
test=args.test)
|
||||
reports = get_dmarc_reports_from_inbox(
|
||||
args.host,
|
||||
args.user,
|
||||
args.password,
|
||||
reports_folder=rf,
|
||||
archive_folder=af,
|
||||
delete=args.delete,
|
||||
test=args.test,
|
||||
)
|
||||
|
||||
aggregate_reports += reports["aggregate_reports"]
|
||||
forensic_reports += reports["forensic_reports"]
|
||||
@@ -178,8 +228,12 @@ def _main():
|
||||
logger.error("IMAP Error: {0}".format(error.__str__()))
|
||||
exit(1)
|
||||
|
||||
results = OrderedDict([("aggregate_reports", aggregate_reports),
|
||||
("forensic_reports", forensic_reports)])
|
||||
results = OrderedDict(
|
||||
[
|
||||
("aggregate_reports", aggregate_reports),
|
||||
("forensic_reports", forensic_reports),
|
||||
]
|
||||
)
|
||||
|
||||
if args.output:
|
||||
save_output(results, output_directory=args.output)
|
||||
@@ -188,15 +242,22 @@ def _main():
|
||||
|
||||
if args.outgoing_host:
|
||||
if args.outgoing_from is None or args.outgoing_to is None:
|
||||
logger.error("--outgoing-from and --outgoing-to must "
|
||||
"be provided if --outgoing-host is used")
|
||||
logger.error(
|
||||
"--outgoing-from and --outgoing-to must "
|
||||
"be provided if --outgoing-host is used"
|
||||
)
|
||||
exit(1)
|
||||
|
||||
try:
|
||||
email_results(results, args.outgoing_host, args.outgoing_from,
|
||||
args.outgoing_to, user=args.outgoing_user,
|
||||
password=args.outgoing_password,
|
||||
subject=args.outgoing_subject)
|
||||
email_results(
|
||||
results,
|
||||
args.outgoing_host,
|
||||
args.outgoing_from,
|
||||
args.outgoing_to,
|
||||
user=args.outgoing_user,
|
||||
password=args.outgoing_password,
|
||||
subject=args.outgoing_subject,
|
||||
)
|
||||
except SMTPError as error:
|
||||
logger.error("SMTP Error: {0}".format(error.__str__()))
|
||||
exit(1)
|
||||
@@ -204,11 +265,18 @@ def _main():
|
||||
if args.host and args.watch:
|
||||
logger.info("Watching for email - Quit with ^c")
|
||||
try:
|
||||
watch_inbox(args.host, args.user, args.password, process_reports,
|
||||
reports_folder=args.reports_folder,
|
||||
archive_folder=args.archive_folder, delete=args.delete,
|
||||
test=args.test, nameservers=args.nameservers,
|
||||
dns_timeout=args.timeout)
|
||||
watch_inbox(
|
||||
args.host,
|
||||
args.user,
|
||||
args.password,
|
||||
process_reports,
|
||||
reports_folder=args.reports_folder,
|
||||
archive_folder=args.archive_folder,
|
||||
delete=args.delete,
|
||||
test=args.test,
|
||||
nameservers=args.nameservers,
|
||||
dns_timeout=args.timeout,
|
||||
)
|
||||
except IMAPError as error:
|
||||
logger.error("IMAP Error: {0}".format(error.__str__()))
|
||||
exit(1)
|
||||
|
||||
@@ -4,8 +4,20 @@ from collections import OrderedDict
|
||||
|
||||
import parsedmarc
|
||||
from elasticsearch_dsl.search import Q
|
||||
from elasticsearch_dsl import connections, Object, DocType, Index, Nested, \
|
||||
InnerDoc, Integer, Text, Boolean, DateRange, Ip, Date
|
||||
from elasticsearch_dsl import (
|
||||
connections,
|
||||
Object,
|
||||
DocType,
|
||||
Index,
|
||||
Nested,
|
||||
InnerDoc,
|
||||
Integer,
|
||||
Text,
|
||||
Boolean,
|
||||
DateRange,
|
||||
Ip,
|
||||
Date,
|
||||
)
|
||||
|
||||
aggregate_index = Index("dmarc_aggregate")
|
||||
forensic_index = Index("dmarc_forensic")
|
||||
@@ -67,24 +79,21 @@ class _AggregateReportDoc(DocType):
|
||||
spf_results = Nested(_SPFResult)
|
||||
|
||||
def add_policy_override(self, type_, comment):
|
||||
self.policy_overrides.append(_PolicyOverride(type=type_,
|
||||
comment=comment))
|
||||
self.policy_overrides.append(_PolicyOverride(type=type_, comment=comment))
|
||||
|
||||
def add_dkim_result(self, domain, selector, result):
|
||||
self.dkim_results.append(_DKIMResult(domain=domain,
|
||||
selector=selector,
|
||||
result=result))
|
||||
self.dkim_results.append(
|
||||
_DKIMResult(domain=domain, selector=selector, result=result)
|
||||
)
|
||||
|
||||
def add_spf_result(self, domain, scope, result):
|
||||
self.spf_results.append(_SPFResult(domain=domain,
|
||||
scope=scope,
|
||||
result=result))
|
||||
self.spf_results.append(_SPFResult(domain=domain, scope=scope, result=result))
|
||||
|
||||
def save(self, ** kwargs):
|
||||
def save(self, **kwargs):
|
||||
self.passed_dmarc = False
|
||||
self.passed_dmarc = self.spf_aligned or self.dkim_aligned
|
||||
|
||||
return super().save(** kwargs)
|
||||
return super().save(**kwargs)
|
||||
|
||||
|
||||
class _EmailAddressDoc(InnerDoc):
|
||||
@@ -113,24 +122,21 @@ class _ForensicSampleDoc(InnerDoc):
|
||||
attachments = Nested(_EmailAttachmentDoc)
|
||||
|
||||
def add_to(self, display_name, address):
|
||||
self.to.append(_EmailAddressDoc(display_name=display_name,
|
||||
address=address))
|
||||
self.to.append(_EmailAddressDoc(display_name=display_name, address=address))
|
||||
|
||||
def add_reply_to(self, display_name, address):
|
||||
self.reply_to.append(_EmailAddressDoc(display_name=display_name,
|
||||
address=address))
|
||||
self.reply_to.append(
|
||||
_EmailAddressDoc(display_name=display_name, address=address)
|
||||
)
|
||||
|
||||
def add_cc(self, display_name, address):
|
||||
self.cc.append(_EmailAddressDoc(display_name=display_name,
|
||||
address=address))
|
||||
self.cc.append(_EmailAddressDoc(display_name=display_name, address=address))
|
||||
|
||||
def add_bcc(self, display_name, address):
|
||||
self.bcc.append(_EmailAddressDoc(display_name=display_name,
|
||||
address=address))
|
||||
self.bcc.append(_EmailAddressDoc(display_name=display_name, address=address))
|
||||
|
||||
def add_attachment(self, filename, content_type):
|
||||
self.attachments.append(filename=filename,
|
||||
content_type=content_type)
|
||||
self.attachments.append(filename=filename, content_type=content_type)
|
||||
|
||||
|
||||
class _ForensicReportDoc(DocType):
|
||||
@@ -201,8 +207,7 @@ def save_aggregate_report_to_elasticsearch(aggregate_report):
|
||||
end_date_human = end_date.strftime("%Y-%m-%d %H:%M:%S")
|
||||
aggregate_report["begin_date"] = begin_date
|
||||
aggregate_report["end_date"] = end_date
|
||||
date_range = (aggregate_report["begin_date"],
|
||||
aggregate_report["end_date"])
|
||||
date_range = (aggregate_report["begin_date"], aggregate_report["end_date"])
|
||||
|
||||
org_name_query = Q(dict(match=dict(org_name=org_name)))
|
||||
report_id_query = Q(dict(match=dict(report_id=report_id)))
|
||||
@@ -211,26 +216,31 @@ def save_aggregate_report_to_elasticsearch(aggregate_report):
|
||||
end_date_query = Q(dict(match=dict(date_range=end_date)))
|
||||
|
||||
search = aggregate_index.search()
|
||||
search.query = org_name_query & report_id_query & domain_query & \
|
||||
begin_date_query & end_date_query
|
||||
search.query = (
|
||||
org_name_query
|
||||
& report_id_query
|
||||
& domain_query
|
||||
& begin_date_query
|
||||
& end_date_query
|
||||
)
|
||||
|
||||
existing = search.execute()
|
||||
if len(existing) > 0:
|
||||
raise AlreadySaved("An aggregate report ID {0} from {1} about {2} "
|
||||
"with a date range of {3} UTC to {4} UTC already "
|
||||
"exists in "
|
||||
"Elasticsearch".format(report_id,
|
||||
org_name,
|
||||
domain,
|
||||
begin_date_human,
|
||||
end_date_human))
|
||||
raise AlreadySaved(
|
||||
"An aggregate report ID {0} from {1} about {2} "
|
||||
"with a date range of {3} UTC to {4} UTC already "
|
||||
"exists in "
|
||||
"Elasticsearch".format(
|
||||
report_id, org_name, domain, begin_date_human, end_date_human
|
||||
)
|
||||
)
|
||||
published_policy = _PublishedPolicy(
|
||||
adkim=aggregate_report["policy_published"]["adkim"],
|
||||
aspf=aggregate_report["policy_published"]["aspf"],
|
||||
p=aggregate_report["policy_published"]["p"],
|
||||
sp=aggregate_report["policy_published"]["sp"],
|
||||
pct=aggregate_report["policy_published"]["pct"],
|
||||
fo=aggregate_report["policy_published"]["fo"]
|
||||
fo=aggregate_report["policy_published"]["fo"],
|
||||
)
|
||||
|
||||
for record in aggregate_report["records"]:
|
||||
@@ -254,36 +264,41 @@ def save_aggregate_report_to_elasticsearch(aggregate_report):
|
||||
spf_aligned=record["policy_evaluated"]["spf"] == "pass",
|
||||
header_from=record["identifiers"]["header_from"],
|
||||
envelope_from=record["identifiers"]["envelope_from"],
|
||||
envelope_to=record["identifiers"]["envelope_to"]
|
||||
envelope_to=record["identifiers"]["envelope_to"],
|
||||
)
|
||||
|
||||
for override in record["policy_evaluated"]["policy_override_reasons"]:
|
||||
agg_doc.add_policy_override(type_=override["type"],
|
||||
comment=override["comment"])
|
||||
agg_doc.add_policy_override(
|
||||
type_=override["type"], comment=override["comment"]
|
||||
)
|
||||
|
||||
for dkim_result in record["auth_results"]["dkim"]:
|
||||
agg_doc.add_dkim_result(domain=dkim_result["domain"],
|
||||
selector=dkim_result["selector"],
|
||||
result=dkim_result["result"])
|
||||
agg_doc.add_dkim_result(
|
||||
domain=dkim_result["domain"],
|
||||
selector=dkim_result["selector"],
|
||||
result=dkim_result["result"],
|
||||
)
|
||||
|
||||
for spf_result in record["auth_results"]["spf"]:
|
||||
agg_doc.add_spf_result(domain=spf_result["domain"],
|
||||
scope=spf_result["scope"],
|
||||
result=spf_result["result"])
|
||||
agg_doc.add_spf_result(
|
||||
domain=spf_result["domain"],
|
||||
scope=spf_result["scope"],
|
||||
result=spf_result["result"],
|
||||
)
|
||||
agg_doc.save()
|
||||
|
||||
|
||||
def save_forensic_report_to_elasticsearch(forensic_report):
|
||||
"""
|
||||
Saves a parsed DMARC forensic report to ElasticSearch
|
||||
Saves a parsed DMARC forensic report to ElasticSearch
|
||||
|
||||
Args:
|
||||
forensic_report (OrderedDict): A parsed forensic report
|
||||
Args:
|
||||
forensic_report (OrderedDict): A parsed forensic report
|
||||
|
||||
Raises:
|
||||
AlreadySaved
|
||||
Raises:
|
||||
AlreadySaved
|
||||
|
||||
"""
|
||||
"""
|
||||
forensic_report = forensic_report.copy()
|
||||
sample_date = forensic_report["parsed_sample"]["date"]
|
||||
sample_date = parsedmarc.human_timestamp_to_datetime(sample_date)
|
||||
@@ -299,21 +314,20 @@ def save_forensic_report_to_elasticsearch(forensic_report):
|
||||
to_query = {"match": {"sample.headers.to": headers["to"]}}
|
||||
from_query = {"match": {"sample.headers.from": headers["from"]}}
|
||||
subject_query = {"match": {"sample.headers.subject": headers["subject"]}}
|
||||
arrival_date_query = {"match": {"sample.headers.arrival_date": arrival_date
|
||||
}}
|
||||
arrival_date_query = {"match": {"sample.headers.arrival_date": arrival_date}}
|
||||
q = Q(to_query) & Q(from_query) & Q(subject_query) & Q(arrival_date_query)
|
||||
search.query = q
|
||||
existing = search.execute()
|
||||
|
||||
if len(existing) > 0:
|
||||
raise AlreadySaved("A forensic sample to {0} from {1} "
|
||||
"with a subject of {2} and arrival date of {3} "
|
||||
"already exists in "
|
||||
"Elasticsearch".format(headers["to"],
|
||||
headers["from"],
|
||||
headers["subject"],
|
||||
arrival_date_human
|
||||
))
|
||||
raise AlreadySaved(
|
||||
"A forensic sample to {0} from {1} "
|
||||
"with a subject of {2} and arrival date of {3} "
|
||||
"already exists in "
|
||||
"Elasticsearch".format(
|
||||
headers["to"], headers["from"], headers["subject"], arrival_date_human
|
||||
)
|
||||
)
|
||||
|
||||
parsed_sample = forensic_report["parsed_sample"]
|
||||
sample = _ForensicSampleDoc(
|
||||
@@ -323,24 +337,24 @@ def save_forensic_report_to_elasticsearch(forensic_report):
|
||||
date=sample_date,
|
||||
subject=forensic_report["parsed_sample"]["subject"],
|
||||
filename_safe_subject=parsed_sample["filename_safe_subject"],
|
||||
body=forensic_report["parsed_sample"]["body"]
|
||||
body=forensic_report["parsed_sample"]["body"],
|
||||
)
|
||||
|
||||
for address in forensic_report["parsed_sample"]["to"]:
|
||||
sample.add_to(display_name=address["display_name"],
|
||||
address=address["address"])
|
||||
sample.add_to(display_name=address["display_name"], address=address["address"])
|
||||
for address in forensic_report["parsed_sample"]["reply_to"]:
|
||||
sample.add_reply_to(display_name=address["display_name"],
|
||||
address=address["address"])
|
||||
sample.add_reply_to(
|
||||
display_name=address["display_name"], address=address["address"]
|
||||
)
|
||||
for address in forensic_report["parsed_sample"]["cc"]:
|
||||
sample.add_cc(display_name=address["display_name"],
|
||||
address=address["address"])
|
||||
sample.add_cc(display_name=address["display_name"], address=address["address"])
|
||||
for address in forensic_report["parsed_sample"]["bcc"]:
|
||||
sample.add_bcc(display_name=address["display_name"],
|
||||
address=address["address"])
|
||||
sample.add_bcc(display_name=address["display_name"], address=address["address"])
|
||||
for attachment in forensic_report["parsed_sample"]["attachments"]:
|
||||
sample.add_attachment(filename=attachment["filename"],
|
||||
content_type=attachment["mail_content_type"])
|
||||
sample.add_attachment(
|
||||
filename=attachment["filename"],
|
||||
content_type=attachment["mail_content_type"],
|
||||
)
|
||||
|
||||
forensic_doc = _ForensicReportDoc(
|
||||
feedback_type=forensic_report["feedback_type"],
|
||||
@@ -360,7 +374,7 @@ def save_forensic_report_to_elasticsearch(forensic_report):
|
||||
auth_failure=forensic_report["auth_failure"],
|
||||
dkim_domain=forensic_report["dkim_domain"],
|
||||
original_rcpt_to=forensic_report["original_rcpt_to"],
|
||||
sample=sample
|
||||
sample=sample,
|
||||
)
|
||||
|
||||
forensic_doc.save()
|
||||
|
||||
+2
-4
@@ -88,11 +88,9 @@
|
||||
<a class="reference external" href="https://pypistats.org/packages/parsedmarc"><img alt="PyPI - Downloads" src="https://img.shields.io/pypi/dm/parsedmarc?color=blue" /></a></p>
|
||||
<div class="admonition note">
|
||||
<p class="admonition-title">Note</p>
|
||||
<p><strong>Help Wanted</strong></p>
|
||||
<p><em>Sponsors</em></p>
|
||||
<p>This is a project is maintained by one developer.
|
||||
Please consider reviewing the open <a class="reference external" href="https://github.com/domainaware/parsedmarc/issues">issues</a> to see how you can contribute code, documentation, or user support.
|
||||
Assistance on the pinned issues would be particularly helpful.</p>
|
||||
<p>Thanks to all <a class="reference external" href="https://github.com/domainaware/parsedmarc/graphs/contributors">contributors</a>!</p>
|
||||
Please consider <a class="reference external" href="https://github.com/sponsors/seanthegeek">sponsoring my work</a> if you or your organization benefit from it.</p>
|
||||
</div>
|
||||
<a class="reference external image-reference" href="_static/screenshots/dmarc-summary-charts.png"><img alt="A screenshot of DMARC summary charts in Kibana" class="align-center" src="_images/dmarc-summary-charts.png" style="width: 754.0px; height: 449.0px;" />
|
||||
</a>
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user