ecdat 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {ecdat-0.2.0/ecdat.egg-info → ecdat-0.3.0}/PKG-INFO +11 -5
- {ecdat-0.2.0 → ecdat-0.3.0}/PYPI_README.md +6 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/README.md +7 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/__init__.py +2 -2
- ecdat-0.3.0/ecdat/cli/__init__.py +14 -0
- ecdat-0.3.0/ecdat/cli/commands/__init__.py +1 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/cli/commands/demo.py +10 -0
- ecdat-0.3.0/ecdat/cli/commands/explain.py +204 -0
- ecdat-0.3.0/ecdat/cli/commands/history_cmd.py +51 -0
- ecdat-0.3.0/ecdat/cli/commands/mosca.py +222 -0
- ecdat-0.3.0/ecdat/cli/commands/report.py +67 -0
- ecdat-0.3.0/ecdat/cli/commands/rules.py +175 -0
- ecdat-0.3.0/ecdat/cli/commands/tui_cmd.py +61 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/cli/commands/version_cmd.py +6 -0
- ecdat-0.2.0/ecdat/cli/__init__.py → ecdat-0.3.0/ecdat/cli/main.py +3 -3
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/cli/parser.py +40 -1
- ecdat-0.3.0/ecdat/services/__init__.py +1 -0
- ecdat-0.3.0/ecdat/services/exporters.py +439 -0
- ecdat-0.3.0/ecdat/services/filtering.py +151 -0
- ecdat-0.3.0/ecdat/services/history.py +297 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/services/paths.py +46 -2
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/services/scanner.py +134 -35
- ecdat-0.3.0/ecdat/services/settings.py +178 -0
- ecdat-0.3.0/ecdat/tests/__init__.py +1 -0
- ecdat-0.3.0/ecdat/tests/test_app_cli_mosca.py +243 -0
- ecdat-0.3.0/ecdat/tests/test_app_cli_report.py +174 -0
- ecdat-0.3.0/ecdat/tests/test_app_cli_rules.py +250 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/test_app_crashlog.py +2 -0
- ecdat-0.3.0/ecdat/tests/test_app_e2e_tui.py +160 -0
- ecdat-0.3.0/ecdat/tests/test_app_exporters.py +304 -0
- ecdat-0.3.0/ecdat/tests/test_app_filtering.py +222 -0
- ecdat-0.3.0/ecdat/tests/test_app_history.py +176 -0
- ecdat-0.3.0/ecdat/tests/test_app_installers.py +143 -0
- ecdat-0.3.0/ecdat/tests/test_app_motion.py +174 -0
- ecdat-0.3.0/ecdat/tests/test_app_offline.py +247 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/test_app_render.py +160 -2
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/test_app_scanner.py +4 -2
- ecdat-0.3.0/ecdat/tests/test_app_settings.py +90 -0
- ecdat-0.3.0/ecdat/tests/test_app_tui_art_view.py +82 -0
- ecdat-0.3.0/ecdat/tests/test_app_tui_export_history.py +421 -0
- ecdat-0.3.0/ecdat/tests/test_app_tui_findings.py +545 -0
- ecdat-0.3.0/ecdat/tests/test_app_tui_foundation.py +256 -0
- ecdat-0.3.0/ecdat/tests/test_app_tui_home.py +209 -0
- ecdat-0.3.0/ecdat/tests/test_app_tui_polish.py +293 -0
- ecdat-0.3.0/ecdat/tests/test_app_tui_results.py +270 -0
- ecdat-0.3.0/ecdat/tests/test_app_tui_safe_header.py +126 -0
- ecdat-0.3.0/ecdat/tests/test_app_tui_scan.py +264 -0
- ecdat-0.3.0/ecdat/tests/test_app_tui_splash.py +243 -0
- ecdat-0.3.0/ecdat/tests/test_app_tui_startup.py +153 -0
- ecdat-0.3.0/ecdat/tests/test_app_tui_tabs.py +448 -0
- ecdat-0.3.0/ecdat/tests/test_app_untrusted.py +612 -0
- ecdat-0.3.0/ecdat/tests/tui_helpers.py +61 -0
- ecdat-0.3.0/ecdat/tests/vm_factory.py +285 -0
- ecdat-0.3.0/ecdat/tui/__init__.py +48 -0
- ecdat-0.3.0/ecdat/tui/app.py +453 -0
- ecdat-0.3.0/ecdat/tui/ecdat.tcss +587 -0
- ecdat-0.3.0/ecdat/tui/screens/__init__.py +1 -0
- ecdat-0.3.0/ecdat/tui/screens/export.py +221 -0
- ecdat-0.3.0/ecdat/tui/screens/help.py +119 -0
- ecdat-0.3.0/ecdat/tui/screens/history.py +249 -0
- ecdat-0.3.0/ecdat/tui/screens/home.py +292 -0
- ecdat-0.3.0/ecdat/tui/screens/pick_dir.py +77 -0
- ecdat-0.3.0/ecdat/tui/screens/results.py +301 -0
- ecdat-0.3.0/ecdat/tui/screens/scan.py +408 -0
- ecdat-0.3.0/ecdat/tui/screens/splash.py +116 -0
- ecdat-0.3.0/ecdat/tui/theme.py +97 -0
- ecdat-0.3.0/ecdat/tui/widgets/__init__.py +1 -0
- ecdat-0.3.0/ecdat/tui/widgets/art_view.py +150 -0
- ecdat-0.3.0/ecdat/tui/widgets/banner.py +58 -0
- ecdat-0.3.0/ecdat/tui/widgets/file_tree.py +299 -0
- ecdat-0.3.0/ecdat/tui/widgets/finding_detail.py +266 -0
- ecdat-0.3.0/ecdat/tui/widgets/findings_table.py +433 -0
- ecdat-0.3.0/ecdat/tui/widgets/header.py +60 -0
- ecdat-0.3.0/ecdat/tui/widgets/mosca_timeline.py +186 -0
- ecdat-0.3.0/ecdat/tui/widgets/priority_list.py +136 -0
- ecdat-0.3.0/ecdat/tui/widgets/recommendations.py +222 -0
- ecdat-0.3.0/ecdat/tui/widgets/risk_chart.py +197 -0
- ecdat-0.3.0/ecdat/tui/widgets/stat_card.py +101 -0
- ecdat-0.3.0/ecdat/ui/__init__.py +16 -0
- ecdat-0.3.0/ecdat/ui/motion.py +223 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/ui/render.py +214 -6
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/ui/theme.py +131 -9
- {ecdat-0.2.0 → ecdat-0.3.0/ecdat.egg-info}/PKG-INFO +11 -5
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat.egg-info/SOURCES.txt +65 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat.egg-info/requires.txt +2 -0
- ecdat-0.3.0/ecdat_core/cli.py +271 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/ingestion.py +116 -17
- ecdat-0.3.0/ecdat_core/progress.py +90 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/risk_engine.py +17 -3
- ecdat-0.3.0/ecdat_core/tests/test_ingestion_realworld.py +185 -0
- ecdat-0.3.0/ecdat_core/tests/test_progress.py +111 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/pyproject.toml +7 -5
- ecdat-0.2.0/ecdat/cli/commands/__init__.py +0 -0
- ecdat-0.2.0/ecdat/services/__init__.py +0 -1
- ecdat-0.2.0/ecdat/tests/__init__.py +0 -0
- ecdat-0.2.0/ecdat/ui/__init__.py +0 -1
- ecdat-0.2.0/ecdat/ui/motion.py +0 -64
- ecdat-0.2.0/ecdat_core/cli.py +0 -202
- {ecdat-0.2.0 → ecdat-0.3.0}/CHANGELOG.md +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/LICENSE +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/MANIFEST.in +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/__main__.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/cli/commands/about.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/cli/commands/doctor.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/cli/commands/help_cmd.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/cli/commands/scan.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/demo_project/auth/login.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/demo_project/certs/cert_verify.go +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/demo_project/keyexchange/channel.go +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/demo_project/legacy/LegacyCrypto.java +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/demo_project/payments/payment.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/demo_project/quantum/pqc_utils.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/demo_project/quantum/slh_signer.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/demo_project/tokens/signing.js +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/py.typed +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/services/crashlog.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/services/demo.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/services/viewmodel.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/conftest.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/test_app_art.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/test_app_cli_meta.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/test_app_cli_scan.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/test_app_demo.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/test_app_e2e_cli.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/test_app_packaging.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/test_app_skeleton.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/test_app_ui.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/tests/test_app_viewmodel.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/ui/art3d.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/ui/art_static.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/ui/art_text.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/ui/banner.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat/ui/console.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat.egg-info/dependency_links.txt +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat.egg-info/entry_points.txt +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat.egg-info/top_level.txt +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/__init__.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/cbom_export.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/detector.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/models.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/recommender.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/signature_loader.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/signatures.json +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/cyclonedx/bom-1.6.schema.json +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/cyclonedx/jsf-0.82.schema.json +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/cyclonedx/spdx.schema.json +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/demo_repo/auth/login.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/demo_repo/legacy/cipher.java +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/demo_repo/modern/crypto.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/demo_repo/requirements.txt +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/demo_repo/utils/hasher.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/showcase_repo/auth/login.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/showcase_repo/certs/cert_verify.go +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/showcase_repo/keyexchange/channel.go +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/showcase_repo/legacy/LegacyCrypto.java +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/showcase_repo/payments/payment.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/showcase_repo/quantum/pqc_utils.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/showcase_repo/quantum/slh_signer.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/fixtures/showcase_repo/tokens/signing.js +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/test_cbom_export.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/test_cbom_schema.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/test_detector.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/test_end_to_end.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/test_ingestion.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/test_models.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/test_recommender.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/test_risk_engine.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/test_run_scan_sandbox.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/ecdat_core/tests/test_signature_loader.py +0 -0
- {ecdat-0.2.0 → ecdat-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ecdat
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Cryptographic discovery and post-quantum readiness scanner — CLI with a gradient ASCII banner and an animated 3D globe (CBOM, CycloneDX 1.6)
|
|
5
5
|
Author: ECDAT Team
|
|
6
6
|
License-Expression: MIT
|
|
@@ -8,36 +8,42 @@ Project-URL: Homepage, https://github.com/profm0r14rty/ecdat
|
|
|
8
8
|
Project-URL: Repository, https://github.com/profm0r14rty/ecdat
|
|
9
9
|
Project-URL: Issues, https://github.com/profm0r14rty/ecdat/issues
|
|
10
10
|
Project-URL: Changelog, https://github.com/profm0r14rty/ecdat/blob/main/CHANGELOG.md
|
|
11
|
-
Keywords: cbom,cli,cryptography,cyclonedx,post-quantum,pqc,quantum,sbom,security
|
|
11
|
+
Keywords: cbom,cli,cryptography,cyclonedx,post-quantum,pqc,quantum,sbom,security,tui
|
|
12
12
|
Classifier: Development Status :: 3 - Alpha
|
|
13
13
|
Classifier: Environment :: Console
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: Intended Audience :: Information Technology
|
|
16
16
|
Classifier: Operating System :: OS Independent
|
|
17
17
|
Classifier: Programming Language :: Python :: 3
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
20
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
22
20
|
Classifier: Programming Language :: Python :: 3.14
|
|
23
21
|
Classifier: Topic :: Security
|
|
24
22
|
Classifier: Topic :: Security :: Cryptography
|
|
25
23
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
26
|
-
Requires-Python: >=3.
|
|
24
|
+
Requires-Python: >=3.12
|
|
27
25
|
Description-Content-Type: text/markdown
|
|
28
26
|
License-File: LICENSE
|
|
29
27
|
Requires-Dist: pydantic>=2
|
|
30
28
|
Requires-Dist: rich>=13.7
|
|
29
|
+
Requires-Dist: textual<9,>=8.2
|
|
31
30
|
Provides-Extra: dev
|
|
32
31
|
Requires-Dist: pytest; extra == "dev"
|
|
33
32
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
34
33
|
Requires-Dist: jsonschema; extra == "dev"
|
|
35
34
|
Requires-Dist: build; extra == "dev"
|
|
36
35
|
Requires-Dist: twine; extra == "dev"
|
|
36
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
37
37
|
Dynamic: license-file
|
|
38
38
|
|
|
39
39
|
# ecdat
|
|
40
40
|
|
|
41
|
+
[](https://pypi.org/project/ecdat/)
|
|
42
|
+
[](https://pypi.org/project/ecdat/)
|
|
43
|
+
[](https://pypi.org/project/ecdat/)
|
|
44
|
+
[](https://github.com/profm0r14rty/ECDAT/blob/main/LICENSE)
|
|
45
|
+
[](https://github.com/profm0r14rty/ECDAT)
|
|
46
|
+
|
|
41
47
|
**ECDAT** is a [CycloneDX](https://cyclonedx.org/) **Cryptography Bill of
|
|
42
48
|
Materials (CBOM) scanner for post-quantum readiness**. One command finds the
|
|
43
49
|
cryptographic artefacts in a codebase — RSA, ECC, DH, DSA, MD5, SHA-1, DES,
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# ecdat
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/ecdat/)
|
|
4
|
+
[](https://pypi.org/project/ecdat/)
|
|
5
|
+
[](https://pypi.org/project/ecdat/)
|
|
6
|
+
[](https://github.com/profm0r14rty/ECDAT/blob/main/LICENSE)
|
|
7
|
+
[](https://github.com/profm0r14rty/ECDAT)
|
|
8
|
+
|
|
3
9
|
**ECDAT** is a [CycloneDX](https://cyclonedx.org/) **Cryptography Bill of
|
|
4
10
|
Materials (CBOM) scanner for post-quantum readiness**. One command finds the
|
|
5
11
|
cryptographic artefacts in a codebase — RSA, ECC, DH, DSA, MD5, SHA-1, DES,
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# ECDAT - Enterprise Cryptographic Discovery & Analysis Tool
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/ecdat/)
|
|
4
|
+
[](https://pypi.org/project/ecdat/)
|
|
5
|
+
[](https://pypi.org/project/ecdat/)
|
|
6
|
+
[](https://github.com/profm0r14rty/ECDAT/blob/main/LICENSE)
|
|
7
|
+
[](https://github.com/profm0r14rty/ECDAT/actions/workflows/ecdat-ci.yml)
|
|
8
|
+
[](https://github.com/profm0r14rty/ECDAT)
|
|
9
|
+
|
|
3
10
|
ECDAT is a Cryptography Bill of Materials (CBOM) scanner for post-quantum readiness assessment. It discovers cryptographic artefacts in source code, assesses their quantum-computing risk using Mosca's algorithm, and recommends NIST post-quantum replacements, outputting a CycloneDX 1.6 CBOM report.
|
|
4
11
|
|
|
5
12
|
## Install the CLI
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""ECDAT — Enterprise Cryptographic Discovery & Analysis Tool."""
|
|
1
|
+
"""ECDAT — Enterprise Cryptographic Discovery & Analysis Tool (app layer)."""
|
|
2
2
|
|
|
3
3
|
import importlib.metadata
|
|
4
4
|
|
|
@@ -7,4 +7,4 @@ DIST_NAME = "ecdat"
|
|
|
7
7
|
try:
|
|
8
8
|
__version__ = importlib.metadata.version(DIST_NAME)
|
|
9
9
|
except importlib.metadata.PackageNotFoundError:
|
|
10
|
-
__version__ = "0+unknown"
|
|
10
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""ECDAT command-line interface.
|
|
2
|
+
|
|
3
|
+
The :func:`main` entry point (wired as the ``ecdat`` console script and used by
|
|
4
|
+
``python -m ecdat``) lives in :mod:`ecdat.cli.main`; it is re-exported here so
|
|
5
|
+
both ``from ecdat.cli import main`` and ``from ecdat.cli.main import main``
|
|
6
|
+
resolve to the same callable. Importing this package stays cheap — Rich, the
|
|
7
|
+
scanner engine, and Textual are all imported lazily by the command modules.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ecdat.cli.main import main
|
|
13
|
+
|
|
14
|
+
__all__ = ["main"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ECDAT CLI subcommands."""
|
|
@@ -51,6 +51,11 @@ def register(subparsers: "argparse._SubParsersAction") -> None:
|
|
|
51
51
|
action="store_true",
|
|
52
52
|
help="Copy the sample under ECDAT_HOME/demo and print its path",
|
|
53
53
|
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--tui",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Open the animated TUI straight into the demo scan",
|
|
58
|
+
)
|
|
54
59
|
|
|
55
60
|
|
|
56
61
|
def run(args) -> int:
|
|
@@ -66,6 +71,11 @@ def run(args) -> int:
|
|
|
66
71
|
out_console = make_console(no_color=no_color)
|
|
67
72
|
err_console = make_console(stderr=True, no_color=no_color)
|
|
68
73
|
|
|
74
|
+
if getattr(args, "tui", False):
|
|
75
|
+
from ecdat.tui import run_tui
|
|
76
|
+
|
|
77
|
+
return run_tui(auto_start=True, demo=True)
|
|
78
|
+
|
|
69
79
|
if getattr(args, "path", False):
|
|
70
80
|
from ecdat.services.demo import materialize_demo
|
|
71
81
|
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""``ecdat explain`` — explain an algorithm in plain English.
|
|
2
|
+
|
|
3
|
+
Looks up a signature entry by name (or family) and renders a panel describing
|
|
4
|
+
what it is, its quantum status, whether it is classically broken, how ECDAT
|
|
5
|
+
scores it with Mosca's inequality, the recommended NIST replacement, and the
|
|
6
|
+
languages ECDAT detects it in.
|
|
7
|
+
|
|
8
|
+
The knowledge base is read through the public
|
|
9
|
+
:mod:`ecdat_core.signature_loader` API. Every dynamic value is wrapped in
|
|
10
|
+
:class:`rich.text.Text` so knowledge-base text is never parsed as markup.
|
|
11
|
+
|
|
12
|
+
Note:
|
|
13
|
+
:class:`~ecdat_core.signature_loader.SignatureEntry` has no ``aliases``
|
|
14
|
+
field, so the lookup covers ``name`` and ``family`` — the fields the real
|
|
15
|
+
model actually exposes.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import difflib
|
|
22
|
+
from typing import List
|
|
23
|
+
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
from ecdat.ui.theme import PALETTE, make_console
|
|
28
|
+
from ecdat_core.signature_loader import SignatureEntry, get_all_signatures
|
|
29
|
+
|
|
30
|
+
NAME = "explain"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register(subparsers: "argparse._SubParsersAction") -> None:
|
|
34
|
+
"""Attach the ``explain`` subcommand to *subparsers*."""
|
|
35
|
+
parser = subparsers.add_parser(
|
|
36
|
+
"explain",
|
|
37
|
+
help="Explain an algorithm in plain English",
|
|
38
|
+
description=(
|
|
39
|
+
"Explain a signature by name or family: what it is, its quantum "
|
|
40
|
+
"status, how Mosca's inequality scores it, and what replaces it."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"name",
|
|
45
|
+
help="Signature name or family to explain (case-insensitive)",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# One-paragraph, static explanation of the scoring model. Kept as a module
|
|
50
|
+
# constant so the wording is consistent across every explained algorithm.
|
|
51
|
+
_MOSCA_PARAGRAPH = (
|
|
52
|
+
"ECDAT scores this with Mosca's inequality, X + Y > Z: X is the time to "
|
|
53
|
+
"migrate away from the algorithm, Y is how long the protected data must "
|
|
54
|
+
"stay secret, and Z is the estimated arrival of a cryptographically "
|
|
55
|
+
"relevant quantum computer. When X + Y exceeds Z the artefact is already "
|
|
56
|
+
"exposed, and the urgency ratio (X + Y) / Z classifies it from low through "
|
|
57
|
+
"critical."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _find_matches(name: str) -> List[SignatureEntry]:
|
|
62
|
+
"""Return entries whose name or family matches *name* case-insensitively."""
|
|
63
|
+
needle = name.strip().lower()
|
|
64
|
+
return [
|
|
65
|
+
entry
|
|
66
|
+
for entry in get_all_signatures()
|
|
67
|
+
if entry.name.lower() == needle or entry.family.lower() == needle
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _quantum_status(entry: SignatureEntry) -> str:
|
|
72
|
+
"""Return the plain-English quantum status for *entry*.
|
|
73
|
+
|
|
74
|
+
- Symmetric families with a ``min_quantum_safe_key_bits`` threshold are
|
|
75
|
+
described as weakened by Grover's algorithm up to that key size.
|
|
76
|
+
- Otherwise quantum-vulnerable entries are broken by Shor's algorithm.
|
|
77
|
+
- Everything else is not affected.
|
|
78
|
+
"""
|
|
79
|
+
if entry.min_quantum_safe_key_bits is not None:
|
|
80
|
+
return (
|
|
81
|
+
"weakened by Grover's algorithm \u2014 needs at least "
|
|
82
|
+
f"{entry.min_quantum_safe_key_bits}-bit keys"
|
|
83
|
+
)
|
|
84
|
+
if entry.quantum_vulnerable:
|
|
85
|
+
return "broken by Shor's algorithm"
|
|
86
|
+
return "not affected by quantum algorithms (quantum-safe)"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _build_body(entry: SignatureEntry) -> Text:
|
|
90
|
+
"""Build the panel body as a :class:`~rich.text.Text` (markup-safe).
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
entry: The matched signature entry.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
A styled :class:`~rich.text.Text` ready to place inside a panel.
|
|
97
|
+
"""
|
|
98
|
+
body = Text()
|
|
99
|
+
|
|
100
|
+
def section(title: str) -> None:
|
|
101
|
+
body.append(f"{title}\n", style=f"bold {PALETTE.accent}")
|
|
102
|
+
|
|
103
|
+
def field(label: str, value: str) -> None:
|
|
104
|
+
body.append(f"{label}: ", style=f"bold {PALETTE.text}")
|
|
105
|
+
body.append(f"{value}\n")
|
|
106
|
+
|
|
107
|
+
# What it is.
|
|
108
|
+
section("What it is")
|
|
109
|
+
field("Family", entry.family)
|
|
110
|
+
body.append("\n")
|
|
111
|
+
|
|
112
|
+
# Quantum status.
|
|
113
|
+
section("Quantum status")
|
|
114
|
+
field("Status", _quantum_status(entry))
|
|
115
|
+
if entry.classically_broken:
|
|
116
|
+
body.append("Warning: ", style=f"bold {PALETTE.critical}")
|
|
117
|
+
body.append(
|
|
118
|
+
"already broken today by classical attacks, independent of "
|
|
119
|
+
"quantum computing \u2014 do not treat it as quantum-safe.\n",
|
|
120
|
+
style=PALETTE.critical,
|
|
121
|
+
)
|
|
122
|
+
body.append("\n")
|
|
123
|
+
|
|
124
|
+
# How ECDAT scores it.
|
|
125
|
+
section("How ECDAT scores it")
|
|
126
|
+
body.append(_MOSCA_PARAGRAPH + "\n")
|
|
127
|
+
body.append("\n")
|
|
128
|
+
|
|
129
|
+
# Recommended replacement.
|
|
130
|
+
section("Recommended replacement")
|
|
131
|
+
field("Algorithm", entry.pqc_recommendation.algorithm)
|
|
132
|
+
field("FIPS reference", entry.pqc_recommendation.fips_reference)
|
|
133
|
+
body.append("\n")
|
|
134
|
+
|
|
135
|
+
# Detection coverage.
|
|
136
|
+
section("Detected in languages")
|
|
137
|
+
body.append(", ".join(sorted(entry.patterns)) + "\n")
|
|
138
|
+
|
|
139
|
+
return body
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _render_panel(entry: SignatureEntry) -> Panel:
|
|
143
|
+
"""Render the explanation panel for a single matched entry."""
|
|
144
|
+
return Panel(
|
|
145
|
+
_build_body(entry),
|
|
146
|
+
title=Text(f"ecdat explain \u00b7 {entry.name}"),
|
|
147
|
+
title_align="left",
|
|
148
|
+
border_style=PALETTE.border,
|
|
149
|
+
padding=(1, 2),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run(args: object) -> int:
|
|
154
|
+
"""Execute the ``explain`` subcommand.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
args: Parsed arguments with attribute ``name``.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Exit code: 0 when exactly one entry matched; 2 when several or none
|
|
161
|
+
matched (several → the candidates are listed; none → close matches are
|
|
162
|
+
suggested).
|
|
163
|
+
"""
|
|
164
|
+
name: str = getattr(args, "name", "")
|
|
165
|
+
out = make_console()
|
|
166
|
+
|
|
167
|
+
matches = _find_matches(name)
|
|
168
|
+
|
|
169
|
+
if len(matches) == 1:
|
|
170
|
+
out.print(_render_panel(matches[0]))
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
if len(matches) > 1:
|
|
174
|
+
out.print(
|
|
175
|
+
Text(
|
|
176
|
+
f"{len(matches)} signatures match {name!r}; "
|
|
177
|
+
f"be more specific:"
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
for entry in sorted(matches, key=lambda e: e.name):
|
|
181
|
+
out.print(Text(f" {entry.name} \u2014 {entry.family}"))
|
|
182
|
+
return 2
|
|
183
|
+
|
|
184
|
+
# No match — offer close suggestions from the real knowledge base.
|
|
185
|
+
names = [entry.name for entry in get_all_signatures()]
|
|
186
|
+
suggestions = difflib.get_close_matches(
|
|
187
|
+
name.strip().lower(), [n.lower() for n in names], n=3
|
|
188
|
+
)
|
|
189
|
+
# Map lowercased suggestions back to their canonical names.
|
|
190
|
+
by_lower = {n.lower(): n for n in names}
|
|
191
|
+
canonical = [by_lower[s] for s in suggestions]
|
|
192
|
+
|
|
193
|
+
if canonical:
|
|
194
|
+
out.print(Text(f"No signature named {name!r}. Did you mean:"))
|
|
195
|
+
for suggestion in canonical:
|
|
196
|
+
out.print(Text(f" {suggestion}"))
|
|
197
|
+
else:
|
|
198
|
+
out.print(
|
|
199
|
+
Text(
|
|
200
|
+
f"No signature named {name!r} "
|
|
201
|
+
f"(0 of {len(names)} signatures)."
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
return 2
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""``ecdat history`` — list saved scan records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ecdat.services.history import load_history
|
|
9
|
+
|
|
10
|
+
NAME = "history"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(subparsers: "argparse._SubParsersAction") -> None:
|
|
14
|
+
"""Attach the ``history`` subcommand to *subparsers*."""
|
|
15
|
+
subparsers.add_parser(
|
|
16
|
+
"history",
|
|
17
|
+
help="Show scan history",
|
|
18
|
+
description="List the scans saved to the local ECDAT history store.",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(args: object) -> int:
|
|
23
|
+
"""Execute the ``history`` subcommand.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
args: Parsed arguments (unused for history — no options).
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Exit code (always 0).
|
|
30
|
+
"""
|
|
31
|
+
records = load_history()
|
|
32
|
+
|
|
33
|
+
if not records:
|
|
34
|
+
print("No scan history found.", file=sys.stderr)
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
# Header
|
|
38
|
+
header = f"{'#':<4} {'Scan ID':<38} {'Target':<36} {'Detections':>10} {'Scanned at'}"
|
|
39
|
+
print(header)
|
|
40
|
+
print("-" * len(header))
|
|
41
|
+
|
|
42
|
+
for i, rec in enumerate(records, start=1):
|
|
43
|
+
# Truncate long fields.
|
|
44
|
+
scan_id = rec.scan_id[:36]
|
|
45
|
+
target = rec.target[:34] + ".." if len(rec.target) > 36 else rec.target
|
|
46
|
+
detections = str(len(rec.detections))
|
|
47
|
+
scanned = rec.scanned_at[:19] # ISO datetime (no TZ offset detail)
|
|
48
|
+
|
|
49
|
+
print(f"{i:<4} {scan_id:<38} {target:<36} {detections:>10} {scanned}")
|
|
50
|
+
|
|
51
|
+
return 0
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""``ecdat mosca`` — Mosca's-inequality calculator with a visual timeline.
|
|
2
|
+
|
|
3
|
+
Given X (data lifetime), Y (migration time) and Z (quantum arrival horizon),
|
|
4
|
+
this computes the urgency ratio ``(X + Y) / Z``, classifies it with the engine's
|
|
5
|
+
own :func:`ecdat_core.risk_engine.classify_urgency` (the exact thresholds the
|
|
6
|
+
scanner uses — no duplicated constants), and draws the timeline from
|
|
7
|
+
:func:`ecdat.ui.render.mosca_timeline`.
|
|
8
|
+
|
|
9
|
+
Variable naming follows the Phase 77 spec: ``-x``/``--shelf-life`` is the data
|
|
10
|
+
lifetime, ``-y``/``--migration`` is the migration time, ``-z``/``--threat`` is
|
|
11
|
+
the quantum arrival horizon.
|
|
12
|
+
|
|
13
|
+
Exit codes:
|
|
14
|
+
- 0 — computed successfully.
|
|
15
|
+
- 2 — usage/validation error (missing/invalid arguments).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.prompt import Prompt
|
|
26
|
+
from rich.text import Text
|
|
27
|
+
|
|
28
|
+
from ecdat.ui.render import mosca_timeline
|
|
29
|
+
from ecdat.ui.theme import PALETTE, RISK_COLORS, RISK_LABELS, make_console
|
|
30
|
+
from ecdat_core.risk_engine import classify_urgency
|
|
31
|
+
|
|
32
|
+
NAME = "mosca"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register(subparsers: "argparse._SubParsersAction") -> None:
|
|
36
|
+
"""Attach the ``mosca`` subcommand to *subparsers*."""
|
|
37
|
+
parser = subparsers.add_parser(
|
|
38
|
+
"mosca",
|
|
39
|
+
help="Mosca's-inequality calculator with a visual timeline",
|
|
40
|
+
description=(
|
|
41
|
+
"Compute the Mosca urgency ratio (X + Y) / Z from a data "
|
|
42
|
+
"lifetime (X), migration time (Y) and quantum horizon (Z) and "
|
|
43
|
+
"draw the exposure timeline."
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"-x",
|
|
48
|
+
"--shelf-life",
|
|
49
|
+
type=float,
|
|
50
|
+
default=None,
|
|
51
|
+
dest="x",
|
|
52
|
+
help="X — data lifetime in years (data must stay secret)",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"-y",
|
|
56
|
+
"--migration",
|
|
57
|
+
type=float,
|
|
58
|
+
default=None,
|
|
59
|
+
dest="y",
|
|
60
|
+
help="Y — migration time in years",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"-z",
|
|
64
|
+
"--threat",
|
|
65
|
+
type=float,
|
|
66
|
+
default=None,
|
|
67
|
+
dest="z",
|
|
68
|
+
help="Z — quantum threat horizon in years",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_interactive() -> bool:
|
|
73
|
+
"""Return whether we may prompt the user for missing values.
|
|
74
|
+
|
|
75
|
+
Interactive means both stdin and stdout are TTYs — prompting into a pipe or
|
|
76
|
+
a redirected stream would either hang or corrupt the payload.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
return bool(sys.stdin.isatty() and sys.stdout.isatty())
|
|
80
|
+
except (AttributeError, ValueError):
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _fmt(value: float) -> str:
|
|
85
|
+
"""Format a year value without a trailing ``.0`` for whole numbers."""
|
|
86
|
+
return str(int(value)) if float(value).is_integer() else f"{value:g}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _risk_chip(level: str) -> Text:
|
|
90
|
+
"""Return a coloured uppercase risk chip for *level*."""
|
|
91
|
+
color = RISK_COLORS.get(level, PALETTE.text)
|
|
92
|
+
label = RISK_LABELS.get(level, level.upper())
|
|
93
|
+
return Text(label, style=f"bold {color}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _resolve(
|
|
97
|
+
raw: Optional[float],
|
|
98
|
+
*,
|
|
99
|
+
label: str,
|
|
100
|
+
flag: str,
|
|
101
|
+
out,
|
|
102
|
+
) -> Optional[float]:
|
|
103
|
+
"""Resolve a possibly-missing value, prompting only when interactive.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
raw: The parsed value, or ``None`` when the flag was omitted.
|
|
107
|
+
label: Human-readable name used in the prompt ("data lifetime").
|
|
108
|
+
flag: The CLI flag spelling used in the prompt (``-x/--shelf-life``).
|
|
109
|
+
out: The console used for the interactive prompt.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The resolved float value, or ``None`` when a value is required but the
|
|
113
|
+
session is non-interactive (the caller then returns a usage error).
|
|
114
|
+
"""
|
|
115
|
+
if raw is not None:
|
|
116
|
+
return raw
|
|
117
|
+
if not _is_interactive():
|
|
118
|
+
return None
|
|
119
|
+
entered = Prompt.ask(
|
|
120
|
+
Text(f"{label} ({flag})", style=PALETTE.accent),
|
|
121
|
+
console=out,
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
return float(entered)
|
|
125
|
+
except (TypeError, ValueError):
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run(args: object) -> int:
|
|
130
|
+
"""Execute the ``mosca`` subcommand.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
args: Parsed arguments with attributes ``x`` (data lifetime),
|
|
134
|
+
``y`` (migration time) and ``z`` (threat horizon), any of which may
|
|
135
|
+
be ``None``.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Exit code (0 on success, 2 on usage/validation error).
|
|
139
|
+
"""
|
|
140
|
+
out = make_console()
|
|
141
|
+
|
|
142
|
+
raw_x: Optional[float] = getattr(args, "x", None)
|
|
143
|
+
raw_y: Optional[float] = getattr(args, "y", None)
|
|
144
|
+
raw_z: Optional[float] = getattr(args, "z", None)
|
|
145
|
+
|
|
146
|
+
x = _resolve(raw_x, label="Data lifetime", flag="-x/--shelf-life", out=out)
|
|
147
|
+
if x is None:
|
|
148
|
+
print(
|
|
149
|
+
"Error: data lifetime (-x/--shelf-life) is required and must be a "
|
|
150
|
+
"number.",
|
|
151
|
+
file=sys.stderr,
|
|
152
|
+
)
|
|
153
|
+
return 2
|
|
154
|
+
|
|
155
|
+
y = _resolve(raw_y, label="Migration time", flag="-y/--migration", out=out)
|
|
156
|
+
if y is None:
|
|
157
|
+
print(
|
|
158
|
+
"Error: migration time (-y/--migration) is required and must be a "
|
|
159
|
+
"number.",
|
|
160
|
+
file=sys.stderr,
|
|
161
|
+
)
|
|
162
|
+
return 2
|
|
163
|
+
|
|
164
|
+
z = _resolve(raw_z, label="Quantum arrival", flag="-z/--threat", out=out)
|
|
165
|
+
if z is None:
|
|
166
|
+
print(
|
|
167
|
+
"Error: quantum arrival (-z/--threat) is required and must be a "
|
|
168
|
+
"number.",
|
|
169
|
+
file=sys.stderr,
|
|
170
|
+
)
|
|
171
|
+
return 2
|
|
172
|
+
|
|
173
|
+
if x < 0 or y < 0:
|
|
174
|
+
print(
|
|
175
|
+
"Error: data lifetime (X) and migration time (Y) must be >= 0.",
|
|
176
|
+
file=sys.stderr,
|
|
177
|
+
)
|
|
178
|
+
return 2
|
|
179
|
+
if z <= 0:
|
|
180
|
+
print("Error: quantum arrival (Z) must be > 0.", file=sys.stderr)
|
|
181
|
+
return 2
|
|
182
|
+
|
|
183
|
+
total = x + y
|
|
184
|
+
urgency = total / z
|
|
185
|
+
level = classify_urgency(urgency)
|
|
186
|
+
violation = total > z
|
|
187
|
+
|
|
188
|
+
# --- headline numbers + risk chip -------------------------------------
|
|
189
|
+
line = Text()
|
|
190
|
+
line.append(f"X + Y = {_fmt(total)} vs Z = {_fmt(z)}")
|
|
191
|
+
line.append(" urgency ratio ")
|
|
192
|
+
line.append(_fmt(round(urgency, 2)))
|
|
193
|
+
line.append(" ")
|
|
194
|
+
line.append(_risk_chip(level))
|
|
195
|
+
|
|
196
|
+
# --- plain-English verdict -------------------------------------------
|
|
197
|
+
if violation:
|
|
198
|
+
verdict = Text(
|
|
199
|
+
f"You would be exposed for {_fmt(total - z)} years.",
|
|
200
|
+
style=f"bold {PALETTE.critical}",
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
verdict = Text(
|
|
204
|
+
f"You have a {_fmt(z - total)}-year safety margin.",
|
|
205
|
+
style=PALETTE.safe,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
out.print(
|
|
209
|
+
Panel(
|
|
210
|
+
Text.assemble(
|
|
211
|
+
line,
|
|
212
|
+
"\n\n",
|
|
213
|
+
verdict,
|
|
214
|
+
),
|
|
215
|
+
title="ecdat mosca",
|
|
216
|
+
title_align="left",
|
|
217
|
+
border_style=PALETTE.border,
|
|
218
|
+
padding=(1, 2),
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
out.print(mosca_timeline(x, y, z))
|
|
222
|
+
return 0
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""``ecdat report`` — show a Markdown or HTML report for a scan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ecdat.services.exporters import export_html, export_markdown
|
|
9
|
+
from ecdat.services.history import get_by_id, get_latest
|
|
10
|
+
|
|
11
|
+
NAME = "report"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(subparsers: "argparse._SubParsersAction") -> None:
|
|
15
|
+
"""Attach the ``report`` subcommand to *subparsers*."""
|
|
16
|
+
parser = subparsers.add_parser(
|
|
17
|
+
"report",
|
|
18
|
+
help="Show a report for a scan",
|
|
19
|
+
description="Re-render a saved scan as Markdown or HTML.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"scan_ref",
|
|
23
|
+
help="'latest' or a scan ID",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"-f",
|
|
27
|
+
"--format",
|
|
28
|
+
choices=["markdown", "html"],
|
|
29
|
+
default="markdown",
|
|
30
|
+
dest="format",
|
|
31
|
+
help="Output format (default: markdown)",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run(args: object) -> int:
|
|
36
|
+
"""Execute the ``report`` subcommand.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
args: Parsed arguments with attributes ``scan_ref`` and ``format``.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Exit code (0 on success, 1 if scan not found, 2 on usage error).
|
|
43
|
+
"""
|
|
44
|
+
scan_ref: str = args.scan_ref # type: ignore[union-attr]
|
|
45
|
+
fmt: str = args.format # type: ignore[union-attr]
|
|
46
|
+
|
|
47
|
+
if scan_ref == "latest":
|
|
48
|
+
result = get_latest()
|
|
49
|
+
else:
|
|
50
|
+
result = get_by_id(scan_ref)
|
|
51
|
+
|
|
52
|
+
if result is None:
|
|
53
|
+
if scan_ref == "latest":
|
|
54
|
+
print("Error: no scan history found", file=sys.stderr)
|
|
55
|
+
else:
|
|
56
|
+
print(f"Error: scan {scan_ref!r} not found in history", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
if fmt == "markdown":
|
|
60
|
+
sys.stdout.write(export_markdown(result))
|
|
61
|
+
elif fmt == "html":
|
|
62
|
+
sys.stdout.write(export_html(result))
|
|
63
|
+
else:
|
|
64
|
+
print(f"Error: unknown format {fmt!r}", file=sys.stderr)
|
|
65
|
+
return 2
|
|
66
|
+
|
|
67
|
+
return 0
|