mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-11-11 11:21:32 +00:00
* 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>
226 lines
8.8 KiB
Python
226 lines
8.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import socket
|
|
import time
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from parsedmarc import (
|
|
parsed_aggregate_reports_to_csv_rows,
|
|
parsed_failure_reports_to_csv_rows,
|
|
parsed_smtp_tls_reports_to_csv_rows,
|
|
)
|
|
from parsedmarc.constants import USER_AGENT
|
|
from parsedmarc.log import logger
|
|
from parsedmarc.types import AggregateReport, FailureReport, SMTPTLSReport
|
|
from parsedmarc.utils import human_timestamp_to_unix_timestamp, redact_url_credentials
|
|
|
|
PUSH_PATH = "/loki/api/v1/push"
|
|
TIMESTAMP_MODES = ("ingest", "report_begin")
|
|
DEFAULT_LABEL_FIELDS = ["report_type", "domain"]
|
|
|
|
_LABEL_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
|
|
# Per report type: the row field holding the domain the report is about,
|
|
# and the row field with its begin time (and whether that value is UTC
|
|
# without an offset; aggregate rows carry the record's interval_begin,
|
|
# which parsedmarc builds as naive UTC wall-clock time).
|
|
_REPORT_TYPES: dict[str, tuple[str, str, bool]] = {
|
|
"aggregate": ("domain", "begin_date", True),
|
|
"failure": ("reported_domain", "arrival_date_utc", True),
|
|
"smtp_tls": ("policy_domain", "begin_date", False),
|
|
}
|
|
|
|
|
|
class LokiError(RuntimeError):
|
|
"""Raised when pushing to Loki fails"""
|
|
|
|
|
|
class LokiClient(object):
|
|
"""A client for the Grafana Loki push API"""
|
|
|
|
def __init__(
|
|
self,
|
|
url: str,
|
|
*,
|
|
labels: dict[str, str] | None = None,
|
|
label_fields: list[str] | None = None,
|
|
timestamp: str = "ingest",
|
|
username: str | None = None,
|
|
password: str | None = None,
|
|
token: str | None = None,
|
|
tenant_id: str | None = None,
|
|
verify: bool = True,
|
|
timeout: float = 60,
|
|
batch_size: int = 1000,
|
|
):
|
|
"""
|
|
Initializes the LokiClient
|
|
|
|
Every report record becomes one JSON log line. Streams are labeled
|
|
with ``labels`` plus the record fields named in ``label_fields``;
|
|
keep those to fields with few distinct values.
|
|
|
|
Args:
|
|
url (str): Loki base URL (``/loki/api/v1/push`` is appended when
|
|
the URL has no path) or the full push URL
|
|
labels (dict): Static stream labels, merged over the defaults
|
|
``job=parsedmarc`` and ``host=<FQDN>``
|
|
label_fields (list): Record fields used as stream labels
|
|
(Default: ``report_type``, ``domain``)
|
|
timestamp (str): ``ingest`` (time of the push) or
|
|
``report_begin`` (begin of the reported period; Loki rejects
|
|
entries older than its ``reject_old_samples_max_age``)
|
|
username (str): Basic auth user
|
|
password (str): Basic auth password
|
|
token (str): Bearer token, instead of basic auth
|
|
tenant_id (str): Sent as ``X-Scope-OrgID``
|
|
verify (bool): Verify TLS certificates
|
|
timeout (float): Request timeout in seconds
|
|
batch_size (int): Maximum log lines per push request
|
|
"""
|
|
if timestamp not in TIMESTAMP_MODES:
|
|
raise ValueError(
|
|
f"Invalid Loki timestamp '{timestamp}'. "
|
|
f"Must be one of: {', '.join(TIMESTAMP_MODES)}"
|
|
)
|
|
if token and (username or password):
|
|
raise ValueError("Loki token and username/password are exclusive")
|
|
|
|
self.labels = {"job": "parsedmarc", "host": socket.getfqdn()}
|
|
self.labels.update(labels or {})
|
|
self.label_fields = list(
|
|
DEFAULT_LABEL_FIELDS if label_fields is None else label_fields
|
|
)
|
|
for name in list(self.labels) + self.label_fields:
|
|
if not _LABEL_NAME.match(name):
|
|
raise ValueError(f"Invalid Loki label name '{name}'")
|
|
|
|
try:
|
|
parsed = httpx.URL(url)
|
|
# copy_with(path=...) keeps any query string, userinfo and port intact
|
|
self.url = (
|
|
str(parsed.copy_with(path=PUSH_PATH))
|
|
if parsed.path in ("", "/")
|
|
else url
|
|
)
|
|
except httpx.InvalidURL:
|
|
# httpx quotes part of the URL (part of a password containing
|
|
# "/", "?" or "#" ends up as the "port"), and chains a ValueError
|
|
# that repeats it, so neither is kept.
|
|
raise ValueError(
|
|
"Invalid Loki URL (details withheld because they may include "
|
|
"credentials)"
|
|
) from None
|
|
self.timestamp = timestamp
|
|
self.timeout = timeout
|
|
self.batch_size = max(1, int(batch_size))
|
|
|
|
headers = {"User-Agent": USER_AGENT, "Content-Type": "application/json"}
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
if tenant_id:
|
|
headers["X-Scope-OrgID"] = tenant_id
|
|
auth = httpx.BasicAuth(username or "", password or "") if username else None
|
|
# Redirects are not followed: httpx re-sends a 301/302 POST as a GET,
|
|
# which the target can answer 200 without storing anything.
|
|
self.session = httpx.Client(
|
|
headers=headers, auth=auth, verify=verify, follow_redirects=False
|
|
)
|
|
|
|
def save_aggregate_report_to_loki(
|
|
self, aggregate_reports: AggregateReport | list[AggregateReport]
|
|
):
|
|
self._save("aggregate", parsed_aggregate_reports_to_csv_rows, aggregate_reports)
|
|
|
|
def save_failure_report_to_loki(
|
|
self, failure_reports: FailureReport | list[FailureReport]
|
|
):
|
|
self._save("failure", parsed_failure_reports_to_csv_rows, failure_reports)
|
|
|
|
def save_smtp_tls_report_to_loki(
|
|
self, smtp_tls_reports: SMTPTLSReport | list[SMTPTLSReport]
|
|
):
|
|
self._save("smtp_tls", parsed_smtp_tls_reports_to_csv_rows, smtp_tls_reports)
|
|
|
|
def _save(self, report_type: str, to_rows: Callable, reports: Any):
|
|
rows = to_rows(reports)
|
|
if not rows:
|
|
return
|
|
logger.debug(f"Saving {len(rows)} {report_type} records to Loki")
|
|
domain_field, begin_field, begin_is_utc = _REPORT_TYPES[report_type]
|
|
now = time.time_ns()
|
|
entries = []
|
|
for i, row in enumerate(rows):
|
|
line = dict(row)
|
|
line["report_type"] = report_type
|
|
line.setdefault("domain", row.get(domain_field))
|
|
ts = now
|
|
if self.timestamp == "report_begin":
|
|
try:
|
|
begin = human_timestamp_to_unix_timestamp(
|
|
str(row[begin_field]), assume_utc=begin_is_utc
|
|
)
|
|
ts = begin * 10**9
|
|
except Exception:
|
|
logger.debug(f"Unparsable {begin_field}, using ingest time")
|
|
entries.append((self._stream_labels(line), ts + i, line))
|
|
|
|
# Loki only accepts out-of-order entries within half of max_chunk_age
|
|
# (so 1h by default) of the newest line in a stream, else it rejects
|
|
# them with too_far_behind. Aggregate rows come source-major then per
|
|
# day, so sort the whole list (not per push, which would still go
|
|
# backwards across batches).
|
|
entries.sort(key=lambda entry: entry[1])
|
|
|
|
for start in range(0, len(entries), self.batch_size):
|
|
self._push(entries[start : start + self.batch_size])
|
|
|
|
def _stream_labels(self, line: dict[str, Any]) -> tuple[tuple[str, str], ...]:
|
|
labels = dict(self.labels)
|
|
for field in self.label_fields:
|
|
value = line.get(field)
|
|
if value is not None and value != "":
|
|
labels[field] = str(value)
|
|
return tuple(labels.items())
|
|
|
|
def _push(self, entries: list):
|
|
streams: dict[tuple, list[list[str]]] = {}
|
|
for labels, ts, line in entries:
|
|
streams.setdefault(labels, []).append(
|
|
[str(ts), json.dumps(line, ensure_ascii=False, default=str)]
|
|
)
|
|
body = {
|
|
"streams": [
|
|
{"stream": dict(labels), "values": values}
|
|
for labels, values in streams.items()
|
|
]
|
|
}
|
|
try:
|
|
response = self.session.post(
|
|
self.url, content=json.dumps(body), timeout=self.timeout
|
|
)
|
|
except httpx.HTTPError as error_:
|
|
raise LokiError(
|
|
f"Loki push to {redact_url_credentials(self.url)} failed: {error_}"
|
|
) from error_
|
|
# Loki answers 204 when it stored the push; 200 is also accepted.
|
|
# Other 2xx codes are not success: with block_ingestion_until set it
|
|
# returns a custom 260 and discards the logs
|
|
# (block_ingestion_status_code).
|
|
if response.status_code not in (200, 204):
|
|
raise LokiError(
|
|
f"Loki push to {redact_url_credentials(self.url)} failed with HTTP "
|
|
f"{response.status_code}: {response.text[:500]}"
|
|
)
|
|
|
|
def close(self):
|
|
"""Close the underlying HTTP session."""
|
|
self.session.close()
|