ecdat 0.2.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.
Files changed (51) hide show
  1. ecdat/__init__.py +10 -0
  2. ecdat/__main__.py +5 -0
  3. ecdat/cli/__init__.py +203 -0
  4. ecdat/cli/commands/__init__.py +0 -0
  5. ecdat/cli/commands/about.py +130 -0
  6. ecdat/cli/commands/demo.py +116 -0
  7. ecdat/cli/commands/doctor.py +296 -0
  8. ecdat/cli/commands/help_cmd.py +205 -0
  9. ecdat/cli/commands/scan.py +228 -0
  10. ecdat/cli/commands/version_cmd.py +48 -0
  11. ecdat/cli/parser.py +87 -0
  12. ecdat/demo_project/auth/login.py +75 -0
  13. ecdat/demo_project/certs/cert_verify.go +81 -0
  14. ecdat/demo_project/keyexchange/channel.go +48 -0
  15. ecdat/demo_project/legacy/LegacyCrypto.java +78 -0
  16. ecdat/demo_project/payments/payment.py +64 -0
  17. ecdat/demo_project/quantum/pqc_utils.py +67 -0
  18. ecdat/demo_project/quantum/slh_signer.py +40 -0
  19. ecdat/demo_project/tokens/signing.js +54 -0
  20. ecdat/py.typed +0 -0
  21. ecdat/services/__init__.py +1 -0
  22. ecdat/services/crashlog.py +109 -0
  23. ecdat/services/demo.py +85 -0
  24. ecdat/services/paths.py +52 -0
  25. ecdat/services/scanner.py +248 -0
  26. ecdat/services/viewmodel.py +326 -0
  27. ecdat/ui/__init__.py +1 -0
  28. ecdat/ui/art3d.py +136 -0
  29. ecdat/ui/art_static.py +65 -0
  30. ecdat/ui/art_text.py +81 -0
  31. ecdat/ui/banner.py +148 -0
  32. ecdat/ui/console.py +119 -0
  33. ecdat/ui/motion.py +64 -0
  34. ecdat/ui/render.py +486 -0
  35. ecdat/ui/theme.py +173 -0
  36. ecdat-0.2.0.dist-info/METADATA +142 -0
  37. ecdat-0.2.0.dist-info/RECORD +51 -0
  38. ecdat-0.2.0.dist-info/WHEEL +5 -0
  39. ecdat-0.2.0.dist-info/entry_points.txt +2 -0
  40. ecdat-0.2.0.dist-info/licenses/LICENSE +21 -0
  41. ecdat-0.2.0.dist-info/top_level.txt +2 -0
  42. ecdat_core/__init__.py +6 -0
  43. ecdat_core/cbom_export.py +287 -0
  44. ecdat_core/cli.py +202 -0
  45. ecdat_core/detector.py +273 -0
  46. ecdat_core/ingestion.py +581 -0
  47. ecdat_core/models.py +145 -0
  48. ecdat_core/recommender.py +74 -0
  49. ecdat_core/risk_engine.py +264 -0
  50. ecdat_core/signature_loader.py +204 -0
  51. ecdat_core/signatures.json +692 -0
ecdat/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """ECDAT — Enterprise Cryptographic Discovery & Analysis Tool."""
2
+
3
+ import importlib.metadata
4
+
5
+ DIST_NAME = "ecdat"
6
+
7
+ try:
8
+ __version__ = importlib.metadata.version(DIST_NAME)
9
+ except importlib.metadata.PackageNotFoundError:
10
+ __version__ = "0+unknown"
ecdat/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow `python -m ecdat` to run the CLI."""
2
+
3
+ from ecdat.cli import main
4
+
5
+ raise SystemExit(main())
ecdat/cli/__init__.py ADDED
@@ -0,0 +1,203 @@
1
+ """ECDAT CLI — entry point and command dispatch.
2
+
3
+ :func:`main` is the single guard rail for the whole CLI: it converts known
4
+ user errors (:class:`~ecdat.services.scanner.ScanError`), interrupts, and
5
+ broken pipes into clean exit codes, and turns genuinely unexpected exceptions
6
+ into a crash-log entry instead of a bare traceback.
7
+
8
+ Importing this module stays cheap — Rich, the scanner engine, and the crash
9
+ logger are all imported lazily. ``textual`` is never imported here (the TUI
10
+ is loaded by its own command, later).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ from typing import Optional
18
+
19
+ from ecdat.cli.parser import COMMANDS, build_parser
20
+
21
+ __all__ = ["main"]
22
+
23
+
24
+ def main(argv: Optional[list] = None) -> int:
25
+ """Run the ECDAT CLI and return a process exit code.
26
+
27
+ Args:
28
+ argv: Argument list; defaults to ``sys.argv[1:]``.
29
+
30
+ Returns:
31
+ ``0`` on success, ``1`` when ``--fail-on`` is tripped, ``2`` on
32
+ usage/validation errors, ``3`` on unexpected errors, ``130`` on
33
+ ``Ctrl-C``.
34
+ """
35
+ if argv is None:
36
+ argv = list(sys.argv[1:])
37
+
38
+ parser = build_parser()
39
+ try:
40
+ args = parser.parse_args(argv)
41
+ except SystemExit as exc:
42
+ # argparse handles --help (0) and usage errors (2) itself.
43
+ return _system_exit_code(exc)
44
+
45
+ try:
46
+ if getattr(args, "version", False):
47
+ _print_version()
48
+ return 0
49
+
50
+ command = getattr(args, "command", None)
51
+ if not command:
52
+ return _run_bare(args)
53
+
54
+ module = COMMANDS.get(command)
55
+ if module is None:
56
+ _print_error(args, f"Unknown command: {command}")
57
+ return 2
58
+ return module.run(args)
59
+ except KeyboardInterrupt:
60
+ return 130
61
+ except BrokenPipeError:
62
+ _silence_stdout()
63
+ return 0
64
+ except Exception as exc: # noqa: BLE001 - top-level guard rail
65
+ return _handle_exception(exc, argv, args)
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Exit-code / error handling
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ def _system_exit_code(exc: SystemExit) -> int:
74
+ """Map an argparse :class:`SystemExit` to a plain integer exit code."""
75
+ code = exc.code
76
+ if code is None:
77
+ return 0
78
+ if isinstance(code, int):
79
+ return code
80
+ return 2
81
+
82
+
83
+ def _handle_exception(exc: Exception, argv: list, args) -> int:
84
+ """Route an exception to the right handler; return the exit code."""
85
+ from ecdat.services.scanner import ScanError
86
+
87
+ if isinstance(exc, ScanError):
88
+ _print_scan_error(args, exc)
89
+ return exc.exit_code
90
+ return _handle_unexpected(exc, argv, args)
91
+
92
+
93
+ def _handle_unexpected(exc: Exception, argv: list, args) -> int:
94
+ """Record an unexpected crash and report a friendly message; return 3."""
95
+ from ecdat.services.crashlog import write_crash_log
96
+ from rich.text import Text
97
+
98
+ log_path = write_crash_log(exc, argv)
99
+ details = str(log_path) if log_path is not None else "(crash log unavailable)"
100
+
101
+ console = _console(stderr=True, args=args)
102
+ console.print(
103
+ Text(
104
+ f"ECDAT hit an unexpected error. Details: {details}. "
105
+ f"Re-run with --debug for the traceback and attach "
106
+ f"`ecdat doctor` output to an issue.",
107
+ style="bold red",
108
+ )
109
+ )
110
+
111
+ if _is_debug(args):
112
+ import traceback
113
+
114
+ traceback.print_exception(type(exc), exc, exc.__traceback__)
115
+
116
+ return 3
117
+
118
+
119
+ def _print_scan_error(args, exc) -> None:
120
+ """Print a known :class:`ScanError` (plus optional hint) to stderr."""
121
+ from rich.text import Text
122
+
123
+ console = _console(stderr=True, args=args)
124
+ console.print(Text(f"Error: {exc.user_message}", style="bold red"))
125
+ if exc.hint:
126
+ console.print(Text(f"Hint: {exc.hint}", style="dim"))
127
+
128
+
129
+ def _print_error(args, message: str) -> None:
130
+ """Print a plain error line to stderr."""
131
+ from rich.text import Text
132
+
133
+ _console(stderr=True, args=args).print(Text(f"Error: {message}", style="bold red"))
134
+
135
+
136
+ def _print_version() -> None:
137
+ """Write ``ecdat <version>`` to stdout."""
138
+ from ecdat import __version__
139
+
140
+ sys.stdout.write(f"ecdat {__version__}\n")
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Bare invocation
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ def _run_bare(args) -> int:
149
+ """Print the banner and example commands (no subcommand given)."""
150
+ from rich.text import Text
151
+
152
+ from ecdat import __version__
153
+ from ecdat.ui.banner import render_banner
154
+
155
+ console = _console(args=args)
156
+ console.print(render_banner(version=__version__))
157
+ console.print(Text(""))
158
+ console.print(Text("Try one of these:", style="bold"))
159
+ for example in ("ecdat demo", "ecdat scan .", "ecdat about", "ecdat help"):
160
+ console.print(Text(f" {example}"))
161
+ return 0
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Small utilities
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ def _is_debug(args) -> bool:
170
+ """Return ``True`` when ``--debug`` or ``ECDAT_DEBUG=1`` is set."""
171
+ if args is not None and getattr(args, "debug", False):
172
+ return True
173
+ return os.environ.get("ECDAT_DEBUG", "").strip() == "1"
174
+
175
+
176
+ def _console(*, stderr: bool = False, args=None):
177
+ """Create a themed Rich console via the sanctioned factory."""
178
+ from ecdat.ui.console import make_console
179
+
180
+ no_color = None
181
+ if args is not None and getattr(args, "no_color", False):
182
+ no_color = True
183
+ return make_console(stderr=stderr, no_color=no_color)
184
+
185
+
186
+ def _silence_stdout() -> None:
187
+ """Redirect the real stdout fd to ``/dev/null`` after a broken pipe.
188
+
189
+ Prevents Python's interpreter-shutdown flush from re-raising on a pipe
190
+ that a downstream reader (e.g. ``head``) already closed. Best-effort.
191
+ """
192
+ try:
193
+ devnull = os.open(os.devnull, os.O_WRONLY)
194
+ try:
195
+ os.dup2(devnull, sys.stdout.fileno())
196
+ finally:
197
+ os.close(devnull)
198
+ except Exception:
199
+ pass
200
+
201
+
202
+ if __name__ == "__main__": # pragma: no cover
203
+ sys.exit(main())
File without changes
@@ -0,0 +1,130 @@
1
+ """``ecdat about`` — credits, project links, and the animated globe.
2
+
3
+ The globe is decorative, so it is strictly opt-in: it only animates when the
4
+ output is an interactive terminal, motion is enabled
5
+ (:func:`ecdat.ui.motion.animations_enabled`), ``--no-anim`` is absent, and
6
+ ``--spin`` is positive. When any of those is false, exactly one static frame
7
+ is printed — no cursor movement, no escape codes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import time
14
+
15
+ NAME = "about"
16
+
17
+ _DESCRIPTION = (
18
+ "ECDAT discovers cryptographic artefacts in source code and dependency "
19
+ "manifests, scores their post-quantum risk with Mosca's inequality, and "
20
+ "exports a CycloneDX 1.6 Cryptography Bill of Materials."
21
+ )
22
+ _LICENSE_LINE = "MIT \u00b7 https://github.com/profm0r14rty/ecdat"
23
+ _BUILT_WITH = "Built with Rich"
24
+
25
+ _FPS = 14.0
26
+ _GLOBE_MAX_WIDTH = 56
27
+ _GLOBE_MAX_HEIGHT = 22
28
+
29
+
30
+ def register(subparsers: "argparse._SubParsersAction") -> None:
31
+ """Attach the ``about`` subcommand to *subparsers*."""
32
+ parser = subparsers.add_parser(
33
+ "about",
34
+ help="About ECDAT \u2014 credits and a spinning globe",
35
+ description=(
36
+ "Show the ECDAT banner, a short description, and (on an "
37
+ "interactive terminal) an animated 3D globe."
38
+ ),
39
+ )
40
+ parser.add_argument(
41
+ "--spin",
42
+ type=float,
43
+ default=3.0,
44
+ metavar="SECONDS",
45
+ help="Seconds to animate the globe (default: 3; 0 disables)",
46
+ )
47
+ parser.add_argument(
48
+ "--no-anim",
49
+ action="store_true",
50
+ help="Never animate; print a single static frame",
51
+ )
52
+
53
+
54
+ def run(args) -> int:
55
+ """Print credits and optionally animate the globe; return 0."""
56
+ from rich.style import Style
57
+ from rich.text import Text
58
+
59
+ from ecdat import __version__
60
+ from ecdat.ui.banner import render_banner
61
+ from ecdat.ui.console import make_console
62
+ from ecdat.ui.motion import animations_enabled
63
+ from ecdat.ui.theme import PALETTE
64
+
65
+ no_color = True if getattr(args, "no_color", False) else None
66
+ console = make_console(no_color=no_color)
67
+
68
+ console.print(render_banner(version=__version__))
69
+ console.print(Text(_DESCRIPTION))
70
+ console.print(Text(_LICENSE_LINE, style=Style(color=PALETTE.muted)))
71
+ console.print(Text(_BUILT_WITH, style=Style(color=PALETTE.muted)))
72
+ console.print(Text())
73
+
74
+ width, height = _globe_size(console)
75
+ spin = max(0.0, float(getattr(args, "spin", 0.0)))
76
+ animate = (
77
+ not getattr(args, "no_anim", False)
78
+ and spin > 0.0
79
+ and console.is_terminal
80
+ and animations_enabled()
81
+ )
82
+
83
+ if animate:
84
+ _animate(console, width, height, spin)
85
+ else:
86
+ console.print(_frame_text(0.0, width, height))
87
+ return 0
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Helpers
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ def _globe_size(console) -> tuple[int, int]:
96
+ """Return the globe frame size: terminal size capped at 56x22."""
97
+ size = console.size
98
+ return (
99
+ max(1, min(size.width, _GLOBE_MAX_WIDTH)),
100
+ max(1, min(size.height, _GLOBE_MAX_HEIGHT)),
101
+ )
102
+
103
+
104
+ def _frame_text(elapsed: float, width: int, height: int):
105
+ """Render one globe frame as gradient-coloured Rich Text."""
106
+ from ecdat.ui.art3d import render_globe
107
+ from ecdat.ui.art_text import frame_to_text
108
+
109
+ return frame_to_text(render_globe(elapsed, width, height))
110
+
111
+
112
+ def _animate(console, width: int, height: int, seconds: float) -> None:
113
+ """Animate the globe for *seconds* using real elapsed wall-clock time.
114
+
115
+ Uses :class:`rich.live.Live` with ``transient=False`` so the final frame
116
+ stays on screen. ``Ctrl-C`` propagates out (the Live context restores the
117
+ terminal, and the CLI maps it to exit code 130).
118
+ """
119
+ from rich.live import Live
120
+
121
+ interval = 1.0 / _FPS
122
+ start = time.monotonic()
123
+
124
+ with Live(console=console, refresh_per_second=int(_FPS), transient=False) as live:
125
+ elapsed = 0.0
126
+ while elapsed < seconds:
127
+ elapsed = time.monotonic() - start
128
+ live.update(_frame_text(elapsed, width, height))
129
+ time.sleep(min(interval, max(0.0, seconds - elapsed)))
130
+ live.update(_frame_text(seconds, width, height))
@@ -0,0 +1,116 @@
1
+ """``ecdat demo`` — scan the bundled sample project.
2
+
3
+ A zero-setup "try it now" command: copies the demo project shipped inside the
4
+ package to a temporary directory, scans it through the same
5
+ :func:`~ecdat.services.scanner.perform_scan` path as ``ecdat scan``, and
6
+ renders the usual report. ``--path`` instead materialises a persistent copy
7
+ under ``ECDAT_HOME/demo`` and prints its location.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+
16
+ NAME = "demo"
17
+
18
+ _FORMATS = ("pretty", "json", "summary")
19
+
20
+ _PRETTY_NOTE = "Bundled sample code \u2014 intentionally insecure, never run it."
21
+ _LABEL = "demo project (bundled sample)"
22
+
23
+
24
+ def register(subparsers: "argparse._SubParsersAction") -> None:
25
+ """Attach the ``demo`` subcommand to *subparsers*."""
26
+ parser = subparsers.add_parser(
27
+ "demo",
28
+ help="Scan the bundled sample project (zero setup)",
29
+ description=(
30
+ "Scan ECDAT's bundled, deliberately-insecure sample project. "
31
+ "With --path, materialise a persistent copy under ECDAT_HOME/demo "
32
+ "instead of scanning."
33
+ ),
34
+ )
35
+ parser.add_argument(
36
+ "-f",
37
+ "--format",
38
+ choices=_FORMATS,
39
+ default="pretty",
40
+ help="Output format (default: pretty)",
41
+ )
42
+ parser.add_argument(
43
+ "--limit",
44
+ type=int,
45
+ default=15,
46
+ metavar="N",
47
+ help="Maximum findings shown in the pretty table (default: 15)",
48
+ )
49
+ parser.add_argument(
50
+ "--path",
51
+ action="store_true",
52
+ help="Copy the sample under ECDAT_HOME/demo and print its path",
53
+ )
54
+
55
+
56
+ def run(args) -> int:
57
+ """Run the demo scan (or materialise it); return the process exit code."""
58
+ from rich.style import Style
59
+ from rich.text import Text
60
+
61
+ from ecdat.services.paths import home_dir
62
+ from ecdat.ui.console import make_console
63
+ from ecdat.ui.theme import PALETTE
64
+
65
+ no_color = True if getattr(args, "no_color", False) else None
66
+ out_console = make_console(no_color=no_color)
67
+ err_console = make_console(stderr=True, no_color=no_color)
68
+
69
+ if getattr(args, "path", False):
70
+ from ecdat.services.demo import materialize_demo
71
+
72
+ destination = materialize_demo(home_dir() / "demo")
73
+ sys.stdout.write(str(destination) + "\n")
74
+ return 0
75
+
76
+ from ecdat.services.demo import demo_project
77
+ from ecdat.services.scanner import classify_target, perform_scan
78
+
79
+ with demo_project() as path:
80
+ target = classify_target(str(path))
81
+ outcome = perform_scan(target, label=_LABEL)
82
+ result = outcome.result
83
+ vm = outcome.vm
84
+
85
+ output_format = getattr(args, "format", "pretty")
86
+ if output_format == "json":
87
+ _emit_json(result.model_dump(mode="json"))
88
+ elif output_format == "summary":
89
+ from ecdat_core.cbom_export import export_summary
90
+
91
+ _emit_json(export_summary(result))
92
+ else:
93
+ _render_pretty(out_console, vm, limit=getattr(args, "limit", 15))
94
+
95
+ err_console.print(Text(_PRETTY_NOTE, style=Style(color=PALETTE.muted, dim=True)))
96
+ return 0
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Output helpers
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ def _emit_json(payload: dict) -> None:
105
+ """Write *payload* as pretty JSON to stdout — the ONLY thing on stdout."""
106
+ sys.stdout.write(json.dumps(payload, indent=2) + "\n")
107
+
108
+
109
+ def _render_pretty(console, vm, *, limit: int) -> None:
110
+ """Render the banner plus the standard scan report to stdout."""
111
+ from ecdat import __version__
112
+ from ecdat.ui.banner import render_banner
113
+ from ecdat.ui.render import scan_report
114
+
115
+ console.print(render_banner(version=__version__))
116
+ console.print(scan_report(vm, limit=limit))