Update docs

This commit is contained in:
Sean Whalen
2026-04-04 21:55:11 -04:00
parent 493c0512f5
commit 57415ea955
6 changed files with 570 additions and 375 deletions
+2 -8
View File
@@ -9,13 +9,10 @@ Package](https://img.shields.io/pypi/v/parsedmarc.svg)](https://pypi.org/project
[![PyPI - Downloads](https://img.shields.io/pypi/dm/parsedmarc?color=blue)](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
View File
@@ -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
View File
@@ -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)
+86 -72
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because one or more lines are too long