Files
parsedmarc/parsedmarc/loki.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

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()