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.
@@ -0,0 +1,5 @@
1
+ """Change analysis for Cisco Nexus Dashboard 4.2.1+."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version(__name__)
@@ -0,0 +1,4 @@
1
+ from nac_analytics.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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,5 @@
1
+ """Product-agnostic building blocks shared by every product package.
2
+
3
+ Nothing in ``core`` may import from ``nac_analytics.products``; the dependency
4
+ only ever points the other way (products build on core).
5
+ """
@@ -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,10 @@
1
+ """Simple progress lines for interactive CLI use."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def note(message: str) -> None:
9
+ """Write a short progress update to stderr."""
10
+ print(message, file=sys.stderr, flush=True)
@@ -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