nac-analytics 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nac_analytics/__init__.py +5 -0
- nac_analytics/__main__.py +4 -0
- nac_analytics/cli.py +103 -0
- nac_analytics/core/__init__.py +5 -0
- nac_analytics/core/cli_args.py +32 -0
- nac_analytics/core/config.py +72 -0
- nac_analytics/core/exceptions.py +43 -0
- nac_analytics/core/log.py +38 -0
- nac_analytics/core/product.py +50 -0
- nac_analytics/core/progress.py +10 -0
- nac_analytics/core/redaction.py +119 -0
- nac_analytics/core/report.py +483 -0
- nac_analytics/products/__init__.py +21 -0
- nac_analytics/products/nexus_dashboard/__init__.py +23 -0
- nac_analytics/products/nexus_dashboard/apic.py +123 -0
- nac_analytics/products/nexus_dashboard/cli.py +1140 -0
- nac_analytics/products/nexus_dashboard/client.py +1173 -0
- nac_analytics/products/nexus_dashboard/compliance.py +121 -0
- nac_analytics/products/nexus_dashboard/delta.py +126 -0
- nac_analytics/products/nexus_dashboard/delta_format.py +239 -0
- nac_analytics/products/nexus_dashboard/pipeline.py +60 -0
- nac_analytics/products/nexus_dashboard/rn_mappings.py +132 -0
- nac_analytics/products/nexus_dashboard/settings.py +204 -0
- nac_analytics/products/nexus_dashboard/tf_plan.py +148 -0
- nac_analytics-0.1.0.dist-info/METADATA +154 -0
- nac_analytics-0.1.0.dist-info/RECORD +29 -0
- nac_analytics-0.1.0.dist-info/WHEEL +4 -0
- nac_analytics-0.1.0.dist-info/entry_points.txt +2 -0
- nac_analytics-0.1.0.dist-info/licenses/LICENSE +385 -0
nac_analytics/cli.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Root command line interface.
|
|
2
|
+
|
|
3
|
+
`nac-analytics` is organised as `nac-analytics <product> <verb>`: each Cisco
|
|
4
|
+
product is a command group (see ``nac_analytics.products``), plus global
|
|
5
|
+
commands like ``version``. This module mounts the registered product groups and
|
|
6
|
+
routes pre-Typer configuration loading to the selected product.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from dotenv import find_dotenv, load_dotenv
|
|
15
|
+
|
|
16
|
+
from nac_analytics import __version__
|
|
17
|
+
from nac_analytics.core.cli_args import strip_config_option
|
|
18
|
+
from nac_analytics.core.exceptions import InputError
|
|
19
|
+
from nac_analytics.core.product import Product
|
|
20
|
+
from nac_analytics.products import REGISTRY, resolve_product
|
|
21
|
+
|
|
22
|
+
ROOT_HELP = """\
|
|
23
|
+
Change analytics for Cisco products.
|
|
24
|
+
|
|
25
|
+
Run a product group followed by a command, for example:
|
|
26
|
+
|
|
27
|
+
nac-analytics nexus-dashboard doctor (alias: nd)
|
|
28
|
+
|
|
29
|
+
Each product carries its own commands and configuration; see
|
|
30
|
+
`nac-analytics <product> --help`. Products available today are listed below;
|
|
31
|
+
more Cisco products are planned."""
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
help=ROOT_HELP,
|
|
35
|
+
add_completion=False,
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Mount every registered product as `nac-analytics <cli_name> ...`, with any
|
|
40
|
+
# short aliases hidden from the help so the surface stays uncluttered.
|
|
41
|
+
for _product in REGISTRY:
|
|
42
|
+
app.add_typer(_product.app, name=_product.cli_name)
|
|
43
|
+
for _alias in _product.aliases:
|
|
44
|
+
app.add_typer(_product.app, name=_alias, hidden=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def version() -> None:
|
|
49
|
+
"""Print the version and exit."""
|
|
50
|
+
typer.echo(f"nac-analytics {__version__}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _active_product(args: list[str]) -> Product | None:
|
|
54
|
+
"""Return the product the invocation selects, ignoring a leading --config."""
|
|
55
|
+
_, rest = strip_config_option(args)
|
|
56
|
+
for token in rest:
|
|
57
|
+
if token.startswith("-"):
|
|
58
|
+
return None
|
|
59
|
+
return resolve_product(token)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _wants_help(args: list[str]) -> bool:
|
|
64
|
+
"""Return True when the invocation only resolves or prints help.
|
|
65
|
+
|
|
66
|
+
Config must not be loaded or validated in this case. ``--help`` is an
|
|
67
|
+
eager Click option that short-circuits (printing help and exiting) before
|
|
68
|
+
any command runs, so loading config first would let an invalid or
|
|
69
|
+
flat/unscoped ``nac-analytics.yaml`` break a plain ``--help``. A product
|
|
70
|
+
group invoked with no verb likewise falls back to printing its group help
|
|
71
|
+
(``no_args_is_help``), so a bare product token counts as a help request
|
|
72
|
+
too. Real command execution always carries a verb and no help flag, and
|
|
73
|
+
still triggers the strict config bootstrap below.
|
|
74
|
+
"""
|
|
75
|
+
_, rest = strip_config_option(args)
|
|
76
|
+
if "--help" in rest or "-h" in rest:
|
|
77
|
+
return True
|
|
78
|
+
non_flags = [token for token in rest if not token.startswith("-")]
|
|
79
|
+
return len(non_flags) == 1 and resolve_product(non_flags[0]) is not None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main() -> None:
|
|
83
|
+
args = sys.argv[1:]
|
|
84
|
+
product = _active_product(args)
|
|
85
|
+
load_config = (
|
|
86
|
+
product is not None and product.bootstrap is not None and not _wants_help(args)
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
if load_config:
|
|
90
|
+
assert product is not None and product.bootstrap is not None
|
|
91
|
+
remaining = product.bootstrap(args)
|
|
92
|
+
else:
|
|
93
|
+
_, remaining = strip_config_option(args)
|
|
94
|
+
except InputError as exc:
|
|
95
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
96
|
+
raise SystemExit(InputError.exit_code) from exc
|
|
97
|
+
sys.argv = [sys.argv[0], *remaining]
|
|
98
|
+
# `.env` is read after YAML so a real environment variable or CLI flag still
|
|
99
|
+
# wins. Values already in `os.environ` are left untouched.
|
|
100
|
+
load_dotenv(find_dotenv(usecwd=True))
|
|
101
|
+
if product is not None and product.apply_legacy_env is not None:
|
|
102
|
+
product.apply_legacy_env()
|
|
103
|
+
app()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Pre-Typer argv handling shared across products.
|
|
2
|
+
|
|
3
|
+
``--config`` is resolved before Typer runs (it selects the YAML file that seeds
|
|
4
|
+
the environment), so it is stripped from argv here rather than declared as a
|
|
5
|
+
Typer option.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
CONFIG_OPTION = "--config"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def strip_config_option(argv: list[str]) -> tuple[Path | None, list[str]]:
|
|
16
|
+
"""Remove a root-level ``--config`` option from ``argv`` before Typer runs."""
|
|
17
|
+
remaining: list[str] = []
|
|
18
|
+
config_path: Path | None = None
|
|
19
|
+
index = 0
|
|
20
|
+
while index < len(argv):
|
|
21
|
+
arg = argv[index]
|
|
22
|
+
if arg == CONFIG_OPTION and index + 1 < len(argv):
|
|
23
|
+
config_path = Path(argv[index + 1])
|
|
24
|
+
index += 2
|
|
25
|
+
continue
|
|
26
|
+
if arg.startswith(f"{CONFIG_OPTION}="):
|
|
27
|
+
config_path = Path(arg.split("=", 1)[1])
|
|
28
|
+
index += 1
|
|
29
|
+
continue
|
|
30
|
+
remaining.append(arg)
|
|
31
|
+
index += 1
|
|
32
|
+
return config_path, remaining
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Connection settings, sourced from CLI options with `.env` behind them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from nac_analytics.core.exceptions import InputError
|
|
8
|
+
|
|
9
|
+
# The login endpoint rejects a missing or empty `domain` with HTTP 500, so
|
|
10
|
+
# there is always a value here.
|
|
11
|
+
DEFAULT_DOMAIN = "DefaultAuth"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalise_host(host: str) -> str:
|
|
15
|
+
"""Strip any URL scheme and trailing slashes from a host.
|
|
16
|
+
|
|
17
|
+
A host carrying its own scheme would produce `https://https://...`.
|
|
18
|
+
"""
|
|
19
|
+
return host_scheme(host)[1]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def host_scheme(host: str) -> tuple[str, str]:
|
|
23
|
+
"""Return ``(scheme, host)`` where scheme is ``https`` or ``http``."""
|
|
24
|
+
value = host.strip()
|
|
25
|
+
lowered = value.lower()
|
|
26
|
+
for prefix, scheme in (("https://", "https"), ("http://", "http")):
|
|
27
|
+
if lowered.startswith(prefix):
|
|
28
|
+
return scheme, value[len(prefix) :].rstrip("/")
|
|
29
|
+
return "https", value.rstrip("/")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Config:
|
|
34
|
+
"""Everything needed to reach one Nexus Dashboard."""
|
|
35
|
+
|
|
36
|
+
host: str
|
|
37
|
+
username: str
|
|
38
|
+
# Kept out of the generated __repr__, which reaches debuggers, log lines
|
|
39
|
+
# and exception renderings.
|
|
40
|
+
password: str = field(repr=False)
|
|
41
|
+
domain: str = DEFAULT_DOMAIN
|
|
42
|
+
fabric: str = ""
|
|
43
|
+
verify_ssl: bool = True
|
|
44
|
+
ca_bundle: str | None = None
|
|
45
|
+
request_timeout_seconds: float = 60.0
|
|
46
|
+
poll_interval_seconds: int = 15
|
|
47
|
+
job_timeout_minutes: int = 30
|
|
48
|
+
scheme: str = field(init=False, default="https")
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
self.scheme, self.host = host_scheme(self.host)
|
|
52
|
+
if not self.host:
|
|
53
|
+
raise InputError("Nexus Dashboard host is required (--host or ND_HOST).")
|
|
54
|
+
if not self.username:
|
|
55
|
+
raise InputError("Username is required (--username or ND_USER).")
|
|
56
|
+
if not self.password:
|
|
57
|
+
raise InputError("Password is required (ND_PASSWORD).")
|
|
58
|
+
if not self.domain:
|
|
59
|
+
# The API answers an empty domain with HTTP 500, so it is caught
|
|
60
|
+
# here instead.
|
|
61
|
+
raise InputError(
|
|
62
|
+
"Login domain is required and cannot be empty "
|
|
63
|
+
f"(--domain or ND_DOMAIN; try '{DEFAULT_DOMAIN}')."
|
|
64
|
+
)
|
|
65
|
+
if self.poll_interval_seconds < 1:
|
|
66
|
+
raise InputError("--poll-interval must be at least 1 second.")
|
|
67
|
+
if self.job_timeout_minutes < 1:
|
|
68
|
+
raise InputError("--timeout must be at least 1 minute.")
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def base_url(self) -> str:
|
|
72
|
+
return f"{self.scheme}://{self.host}"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Typed errors, each carrying the process exit code it should produce.
|
|
2
|
+
|
|
3
|
+
Exit codes are a contract with CI, so they live on the exception rather than
|
|
4
|
+
being chosen at the call site.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NacNdError(Exception):
|
|
11
|
+
"""Base error. Anything unclassified exits 1."""
|
|
12
|
+
|
|
13
|
+
exit_code: int = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JobError(NacNdError):
|
|
17
|
+
"""An analysis job failed, stopped, vanished or timed out."""
|
|
18
|
+
|
|
19
|
+
exit_code = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AnomalyThresholdError(NacNdError):
|
|
23
|
+
"""New anomalies were found at a severity the caller chose to fail on."""
|
|
24
|
+
|
|
25
|
+
exit_code = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InputError(NacNdError):
|
|
29
|
+
"""Bad arguments, bad configuration or an unusable input file."""
|
|
30
|
+
|
|
31
|
+
exit_code = 4
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthError(NacNdError):
|
|
35
|
+
"""Authentication or authorisation against Nexus Dashboard failed."""
|
|
36
|
+
|
|
37
|
+
exit_code = 5
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ApiError(NacNdError):
|
|
41
|
+
"""Nexus Dashboard answered a request with an unexpected status."""
|
|
42
|
+
|
|
43
|
+
exit_code = 1
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Logging configuration shared by the CLI and client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from nac_analytics.core.redaction import install_redaction_filter
|
|
9
|
+
|
|
10
|
+
_verbose = False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_verbose() -> bool:
|
|
14
|
+
return _verbose
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def configure_logging(verbose: bool) -> None:
|
|
18
|
+
"""Configure process logging.
|
|
19
|
+
|
|
20
|
+
Default mode keeps stderr quiet except for explicit progress lines and
|
|
21
|
+
errors. Verbose mode logs each HTTP request and API call.
|
|
22
|
+
"""
|
|
23
|
+
global _verbose
|
|
24
|
+
_verbose = verbose
|
|
25
|
+
root = logging.getLogger()
|
|
26
|
+
if not root.handlers:
|
|
27
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
28
|
+
handler.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
|
|
29
|
+
root.addHandler(handler)
|
|
30
|
+
if verbose:
|
|
31
|
+
root.setLevel(logging.DEBUG)
|
|
32
|
+
logging.getLogger("httpx").setLevel(logging.DEBUG)
|
|
33
|
+
logging.getLogger("httpcore").setLevel(logging.DEBUG)
|
|
34
|
+
else:
|
|
35
|
+
root.setLevel(logging.WARNING)
|
|
36
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
37
|
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
38
|
+
install_redaction_filter(root)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The seam every product plugs into.
|
|
2
|
+
|
|
3
|
+
A :class:`Product` bundles a product's Typer sub-app with the small amount of
|
|
4
|
+
metadata the root CLI needs to mount it (`nac-analytics <product> <verb>`) and
|
|
5
|
+
to load its scoped configuration. Adding a Cisco product to nac-analytics means
|
|
6
|
+
building one of these and registering it in ``nac_analytics.products`` — no
|
|
7
|
+
changes to the root CLI.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Product:
|
|
20
|
+
"""One Cisco product exposed as a `nac-analytics` command group."""
|
|
21
|
+
|
|
22
|
+
key: str
|
|
23
|
+
"""Canonical identifier, also the YAML config section (e.g. ``nexus_dashboard``)."""
|
|
24
|
+
|
|
25
|
+
cli_name: str
|
|
26
|
+
"""Primary command token (e.g. ``nexus-dashboard``)."""
|
|
27
|
+
|
|
28
|
+
app: typer.Typer
|
|
29
|
+
"""The product's Typer sub-app, holding its verbs.
|
|
30
|
+
|
|
31
|
+
Its ``help`` first line becomes the product's short description in the root
|
|
32
|
+
``--help`` command list.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
aliases: tuple[str, ...] = ()
|
|
36
|
+
"""Extra command tokens that resolve to this product (e.g. ``nd``)."""
|
|
37
|
+
|
|
38
|
+
bootstrap: Callable[[list[str]], list[str]] | None = None
|
|
39
|
+
"""Load this product's scoped config into the environment; return remaining argv."""
|
|
40
|
+
|
|
41
|
+
apply_legacy_env: Callable[[], None] | None = None
|
|
42
|
+
"""Optional hook to map retired environment variable names, run after ``.env``."""
|
|
43
|
+
|
|
44
|
+
tokens: tuple[str, ...] = field(init=False)
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
object.__setattr__(self, "tokens", (self.cli_name, *self.aliases))
|
|
48
|
+
|
|
49
|
+
def matches(self, token: str) -> bool:
|
|
50
|
+
return token in self.tokens
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Credential redaction for everything this tool logs.
|
|
2
|
+
|
|
3
|
+
A `logging.Filter` on the root logger and its handlers covers every call site
|
|
4
|
+
without any of them knowing it exists.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
REDACTED = "***REDACTED***"
|
|
14
|
+
|
|
15
|
+
# Field, header and cookie names used by this codebase and the Nexus Dashboard
|
|
16
|
+
# API. Longest first, so an alternation match cannot stop short at a shorter
|
|
17
|
+
# name.
|
|
18
|
+
SENSITIVE_KEYS: tuple[str, ...] = (
|
|
19
|
+
"userpasswd",
|
|
20
|
+
"authcookie",
|
|
21
|
+
"jwttoken",
|
|
22
|
+
"password",
|
|
23
|
+
"passwd",
|
|
24
|
+
"secret",
|
|
25
|
+
"token",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_KEYS = "|".join(re.escape(key) for key in SENSITIVE_KEYS)
|
|
29
|
+
|
|
30
|
+
# `Authorization: Bearer <token>`. Matched by scheme so the scheme word
|
|
31
|
+
# survives and only the credential is replaced. Listing a bare `token ` here
|
|
32
|
+
# would redact the word after "returned no token".
|
|
33
|
+
_SCHEME_RE = re.compile(r"(?i)\b(bearer|basic)(\s+)([A-Za-z0-9\-._~+/]+=*)")
|
|
34
|
+
|
|
35
|
+
# A JWT, matched on its shape because the session token can be logged with no
|
|
36
|
+
# surrounding field name for the key patterns to key on.
|
|
37
|
+
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*")
|
|
38
|
+
|
|
39
|
+
# `"password": "s3cr3t"` -- a quoted value, kept quoted so the surrounding
|
|
40
|
+
# JSON or repr stays readable.
|
|
41
|
+
_QUOTED_RE = re.compile(
|
|
42
|
+
rf"""(?i)(["']?(?:{_KEYS})["']?\s*[:=]\s*)(["'])(?:\\.|(?!\2).)*\2"""
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# `AuthCookie=s3cr3t` -- an unquoted value, ending at the first character that
|
|
46
|
+
# cannot be part of one.
|
|
47
|
+
_BARE_RE = re.compile(rf"""(?i)((?:{_KEYS})["']?\s*[:=]\s*)([^\s,;&"'}}\)\]]+)""")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _quoted_replacement(match: re.Match[str]) -> str:
|
|
51
|
+
quote = match.group(2)
|
|
52
|
+
return f"{match.group(1)}{quote}{REDACTED}{quote}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def redact(text: str) -> str:
|
|
56
|
+
r"""Mask credentials in `text` and flatten it to a single line.
|
|
57
|
+
|
|
58
|
+
Newlines are escaped rather than dropped, so a forged `\r\n` cannot write
|
|
59
|
+
a second log entry. Flattening runs first so a newline inside a quoted
|
|
60
|
+
value cannot walk a pattern off the end of that value.
|
|
61
|
+
"""
|
|
62
|
+
text = text.replace("\r", "\\r").replace("\n", "\\n")
|
|
63
|
+
text = _JWT_RE.sub(REDACTED, text)
|
|
64
|
+
text = _SCHEME_RE.sub(rf"\1\2{REDACTED}", text)
|
|
65
|
+
text = _QUOTED_RE.sub(_quoted_replacement, text)
|
|
66
|
+
return _BARE_RE.sub(rf"\1{REDACTED}", text)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RedactingFilter(logging.Filter):
|
|
70
|
+
"""Scrubs credentials from a log record's message, arguments and traceback.
|
|
71
|
+
|
|
72
|
+
The record is interpolated here because a credential can arrive in
|
|
73
|
+
`record.args` as well as in a preformatted message.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
77
|
+
try:
|
|
78
|
+
message = record.getMessage()
|
|
79
|
+
except (TypeError, ValueError, KeyError, IndexError):
|
|
80
|
+
# `%`-interpolation of the record's args failed (arity, format or
|
|
81
|
+
# key mismatch). The uninterpolated args cannot be scanned for
|
|
82
|
+
# credentials, so they are withheld; the format string still
|
|
83
|
+
# identifies the call site.
|
|
84
|
+
message = f"{record.msg} [arguments withheld: interpolation failed]"
|
|
85
|
+
record.msg = redact(message)
|
|
86
|
+
# The message is already interpolated, so the arguments are cleared to
|
|
87
|
+
# stop the handler applying them again.
|
|
88
|
+
record.args = None
|
|
89
|
+
if record.exc_info is not None and not record.exc_text:
|
|
90
|
+
# Rendered here so the traceback is redacted too; the formatter
|
|
91
|
+
# reuses `exc_text`. Line structure is preserved.
|
|
92
|
+
exc_text = logging.Formatter().formatException(record.exc_info)
|
|
93
|
+
record.exc_text = "\n".join(redact(line) for line in exc_text.splitlines())
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def install_redaction_filter(logger: logging.Logger | None = None) -> RedactingFilter:
|
|
98
|
+
"""Install the filter on `logger` (the root logger by default) and its handlers.
|
|
99
|
+
|
|
100
|
+
Both are needed: a logger's filters apply only to records logged through
|
|
101
|
+
that logger, so a root filter alone misses records from child loggers.
|
|
102
|
+
Idempotent, so repeated calls cannot stack duplicate filters.
|
|
103
|
+
"""
|
|
104
|
+
target = logger if logger is not None else logging.getLogger()
|
|
105
|
+
existing = _find_filter(target)
|
|
106
|
+
if existing is None:
|
|
107
|
+
existing = RedactingFilter()
|
|
108
|
+
target.addFilter(existing)
|
|
109
|
+
for handler in target.handlers:
|
|
110
|
+
if _find_filter(handler) is None:
|
|
111
|
+
handler.addFilter(existing)
|
|
112
|
+
return existing
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _find_filter(target: Any) -> RedactingFilter | None:
|
|
116
|
+
for candidate in target.filters:
|
|
117
|
+
if isinstance(candidate, RedactingFilter):
|
|
118
|
+
return candidate
|
|
119
|
+
return None
|