Files
parsedmarc/parsedmarc/splunk.py
T
48ea47adee Merge commit from fork
* Stop logging credentials from configured URLs and the IPinfo token (GHSA-hjr3-rx4c-24cv)

With --debug, the Splunk HEC, Elasticsearch and OpenSearch client-init
lines and opensearch.set_hosts() logged configured URLs verbatim, so
userinfo passwords reached the console and log_file (CWE-532).

Triage found more paths of the same class:
- the httpx "HTTP Request:" line, forwarded at --verbose/--debug,
  contains the full URL (str(httpx.URL) keeps userinfo and query), so
  webhook and HEC URL credentials and the IPinfo ?token= were logged
  on every request;
- the PSL overrides, IP database and reverse DNS map downloaders logged
  their URL at DEBUG and str(HTTPStatusError), which embeds the full
  URL, at WARNING;
- libpq echoes the DSN or a password fragment for some malformed
  PostgreSQL connection strings, logged as an output-client error.

Add redact_url_credentials() (string methods only; urlsplit misparses
scheme-less "user:pw@host:port") and use it at each log site; add a
filter on the httpx logger that masks userinfo and the token query
value; build download-failure warnings from the status code and the
redacted request URL instead of the exception text; replace psycopg
ProgrammingError detail from a connection_string with a fixed message.
Docs now recommend separate user/password settings over URL
credentials.

Reported by helixnebulatech.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Mask credentials in the Loki URL in the log and LokiError text

The Loki output (#926) landed on master after this fix was written. Its
url keeps userinfo (copy_with(path=...) preserves it), and the --debug
"Initializing Loki client" line and both LokiError messages, which the
CLI logs on a failed push, included it as-is: the same CWE-532 path as
the Splunk HEC line. Mask it with redact_url_credentials() at all three
sites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Withhold InvalidURL details, mask malformed URLs, and cover httpx2 and DoH nameservers

Follow-up for GHSA-hjr3-rx4c-24cv (CWE-532):

- httpx.InvalidURL quotes the component it rejected, so a password with
  an unencoded "/", "?" or "#" (RFC 3986 3.2: the authority ends there)
  leaked as "Invalid port: 'p4ssw0rd'", and its chained ValueError repeats
  it. Loki, Splunk HEC, the webhook and the three download loaders now
  report a fixed "details withheld" message raised from None.
  InvalidURL is not an httpx.HTTPError, so a malformed psl_overrides_url
  crashed startup; the loaders now treat it as a download failure.
- redact_url_credentials() masks through the last "@" when the authority
  has no "@" but one follows it, so the --debug lines no longer print
  such passwords; the userinfo lookup is shared with the new nameserver
  masking.
- dnspython 2.9 DoH logs its request line on the httpx2 logger, which was
  forwarded but not filtered; the filter now covers httpx2 and
  httpx2.URL.
- [general] nameservers / --nameservers credentials are masked in the
  DNS pre-flight error, reverse DNS debug errors and per-report errors,
  and unusable https:// / tls:// ports are rejected before any query.
- Docs recommend token/username settings for [loki] and [splunk_hec];
  AGENTS.md gains rules for keeping credentials out of logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Sean Whalen <seanthegeek@users.noreply.github.com>
2026-10-31 17:14:00 -04:00

245 lines
9.1 KiB
Python

# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import socket
from typing import Any
from urllib.parse import urlparse
import httpx
from parsedmarc.constants import USER_AGENT
from parsedmarc.log import logger
from parsedmarc.utils import human_timestamp_to_unix_timestamp
# httpx.InvalidURL quotes part of the URL, which can be part of a password
# (one containing "/", "?" or "#" ends up as the "port"), so its text is
# replaced by this fixed message.
_INVALID_URL_MESSAGE = (
"Invalid Splunk HEC URL (details withheld because they may include credentials)"
)
class SplunkError(RuntimeError):
"""Raised when a Splunk API error occurs"""
class HECClient(object):
"""A client for a Splunk HTTP Events Collector (HEC)"""
# http://docs.splunk.com/Documentation/Splunk/latest/Data/AboutHEC
# http://docs.splunk.com/Documentation/Splunk/latest/RESTREF/RESTinput#services.2Fcollector
def __init__(
self,
url: str,
access_token: str,
index: str,
source: str = "parsedmarc",
verify=True,
timeout=60,
):
"""
Initializes the HECClient
Args:
url (str): The URL of the HEC
access_token (str): The HEC access token
index (str): The name of the index
source (str): The source name
verify (bool): Verify SSL certificates
timeout (float): Number of seconds to wait for the server to send
data before giving up
"""
parsed_url = urlparse(url)
self.url = (
f"{parsed_url.scheme}://{parsed_url.netloc}/services/collector/event/1.0"
)
self.access_token = access_token.lstrip("Splunk ")
self.index = index
self.host = socket.getfqdn()
self.source = source
self.timeout = timeout
self.verify = verify
self._common_data: dict[str, str | int | float | dict] = dict(
host=self.host, source=self.source, index=self.index
)
self.session = httpx.Client(
headers={
"User-Agent": USER_AGENT,
"Authorization": f"Splunk {self.access_token}",
},
verify=self.verify,
follow_redirects=True,
)
def save_aggregate_reports_to_splunk(
self,
aggregate_reports: list[dict[str, Any]] | dict[str, Any],
):
"""
Saves aggregate DMARC reports to Splunk
Args:
aggregate_reports: A list of aggregate report dictionaries
to save in Splunk
"""
logger.debug("Saving aggregate reports to Splunk")
if isinstance(aggregate_reports, dict):
aggregate_reports = [aggregate_reports]
if len(aggregate_reports) < 1:
return
data = self._common_data.copy()
json_lines: list[str] = []
for report in aggregate_reports:
for record in report["records"]:
new_report: dict[str, str | int | float | dict] = dict()
for metadata in report["report_metadata"]:
new_report[metadata] = report["report_metadata"][metadata]
new_report["interval_begin"] = record["interval_begin"]
new_report["interval_end"] = record["interval_end"]
new_report["normalized_timespan"] = record["normalized_timespan"]
new_report["published_policy"] = report["policy_published"]
new_report["source_ip_address"] = record["source"]["ip_address"]
new_report["source_country"] = record["source"]["country"]
new_report["source_reverse_dns"] = record["source"]["reverse_dns"]
new_report["source_base_domain"] = record["source"]["base_domain"]
new_report["source_type"] = record["source"]["type"]
new_report["source_name"] = record["source"]["name"]
new_report["source_asn"] = record["source"]["asn"]
new_report["source_as_name"] = record["source"]["as_name"]
new_report["source_as_domain"] = record["source"]["as_domain"]
new_report["message_count"] = record["count"]
new_report["disposition"] = record["policy_evaluated"]["disposition"]
new_report["spf_aligned"] = record["alignment"]["spf"]
new_report["dkim_aligned"] = record["alignment"]["dkim"]
new_report["passed_dmarc"] = record["alignment"]["dmarc"]
new_report["header_from"] = record["identifiers"]["header_from"]
new_report["envelope_from"] = record["identifiers"]["envelope_from"]
if "dkim" in record["auth_results"]:
new_report["dkim_results"] = record["auth_results"]["dkim"]
if "spf" in record["auth_results"]:
new_report["spf_results"] = record["auth_results"]["spf"]
data["sourcetype"] = "dmarc:aggregate"
# interval_begin is a UTC wall-clock string; assume_utc keeps
# it from being re-interpreted as local time on non-UTC hosts.
timestamp = human_timestamp_to_unix_timestamp(
new_report["interval_begin"], assume_utc=True
)
data["time"] = timestamp
data["event"] = new_report.copy()
json_lines.append(f"{json.dumps(data)}\n")
if not self.verify:
logger.debug("Skipping certificate verification for Splunk HEC")
try:
response = self.session.post(
self.url, content="".join(json_lines), timeout=self.timeout
)
response = response.json()
except httpx.InvalidURL:
raise SplunkError(_INVALID_URL_MESSAGE) from None
except Exception as e:
raise SplunkError(e.__str__())
if response["code"] != 0:
raise SplunkError(response["text"])
def save_failure_reports_to_splunk(
self,
failure_reports: list[dict[str, Any]] | dict[str, Any],
):
"""
Saves failure DMARC reports to Splunk
Args:
failure_reports (list): A list of failure report dictionaries
to save in Splunk
"""
logger.debug("Saving failure reports to Splunk")
if isinstance(failure_reports, dict):
failure_reports = [failure_reports]
if len(failure_reports) < 1:
return
json_lines: list[str] = []
for report in failure_reports:
data = self._common_data.copy()
data["sourcetype"] = "dmarc:failure"
# arrival_date_utc is a UTC wall-clock string; assume_utc keeps
# it from being re-interpreted as local time on non-UTC hosts.
timestamp = human_timestamp_to_unix_timestamp(
report["arrival_date_utc"], assume_utc=True
)
data["time"] = timestamp
data["event"] = report.copy()
json_lines.append(f"{json.dumps(data)}\n")
if not self.verify:
logger.debug("Skipping certificate verification for Splunk HEC")
try:
response = self.session.post(
self.url, content="".join(json_lines), timeout=self.timeout
)
response = response.json()
except httpx.InvalidURL:
raise SplunkError(_INVALID_URL_MESSAGE) from None
except Exception as e:
raise SplunkError(e.__str__())
if response["code"] != 0:
raise SplunkError(response["text"])
def save_smtp_tls_reports_to_splunk(
self, reports: list[dict[str, Any]] | dict[str, Any]
):
"""
Saves SMTP TLS reports to Splunk
Args:
reports: A list of SMTP TLS report dictionaries
to save in Splunk
"""
logger.debug("Saving SMTP TLS reports to Splunk")
if isinstance(reports, dict):
reports = [reports]
if len(reports) < 1:
return
data = self._common_data.copy()
json_lines: list[str] = []
for report in reports:
data["sourcetype"] = "smtp:tls"
timestamp = human_timestamp_to_unix_timestamp(report["begin_date"])
data["time"] = timestamp
data["event"] = report.copy()
json_lines.append(f"{json.dumps(data)}\n")
if not self.verify:
logger.debug("Skipping certificate verification for Splunk HEC")
try:
response = self.session.post(
self.url, content="".join(json_lines), timeout=self.timeout
)
response = response.json()
except httpx.InvalidURL:
raise SplunkError(_INVALID_URL_MESSAGE) from None
except Exception as e:
raise SplunkError(e.__str__())
if response["code"] != 0:
raise SplunkError(response["text"])
def close(self):
"""Close the underlying HTTP session."""
self.session.close()
# Backward-compatible alias
save_forensic_reports_to_splunk = save_failure_reports_to_splunk