quantumsafe-scan 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,8 @@
1
+ """QuantumSafe — scan codebases for quantum-vulnerable cryptography.
2
+
3
+ The importable package is ``quantumsafe`` (mapped to this ``cli/`` directory in
4
+ pyproject.toml) so both the standalone CLI and the Flask backend share one copy
5
+ of the detection logic.
6
+ """
7
+
8
+ __version__ = "0.1.0"
quantumsafe/cli.py ADDED
@@ -0,0 +1,203 @@
1
+ """quantumsafe command-line interface.
2
+
3
+ Commands:
4
+ quantumsafe scan --path ./project [--output report.json|report.html]
5
+ quantumsafe scan --repo https://github.com/org/app
6
+ quantumsafe version
7
+ quantumsafe auth --key <api-key>
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import sys
16
+
17
+ from . import __version__
18
+ from . import reporter
19
+ from .scanner import scan_path, scan_repo
20
+
21
+ CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".quantumsafe")
22
+ CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
23
+
24
+
25
+ # --------------------------------------------------------------------------- #
26
+ # auth — store API key linking the CLI to a dashboard account
27
+ # --------------------------------------------------------------------------- #
28
+
29
+
30
+ def _save_config(data: dict) -> None:
31
+ os.makedirs(CONFIG_DIR, exist_ok=True)
32
+ with open(CONFIG_PATH, "w", encoding="utf-8") as fh:
33
+ json.dump(data, fh, indent=2)
34
+ try:
35
+ os.chmod(CONFIG_PATH, 0o600) # best-effort; no-op on some platforms
36
+ except OSError:
37
+ pass
38
+
39
+
40
+ def load_config() -> dict:
41
+ try:
42
+ with open(CONFIG_PATH, "r", encoding="utf-8") as fh:
43
+ return json.load(fh)
44
+ except (OSError, ValueError):
45
+ return {}
46
+
47
+
48
+ def cmd_auth(args: argparse.Namespace) -> int:
49
+ key = args.key.strip()
50
+ if not key:
51
+ print("Error: --key must not be empty.", file=sys.stderr)
52
+ return 2
53
+ cfg = load_config()
54
+ cfg["api_key"] = key
55
+ if args.api_url:
56
+ cfg["api_url"] = args.api_url.rstrip("/")
57
+ _save_config(cfg)
58
+ print(f"API key saved to {CONFIG_PATH}")
59
+ print("The CLI is now linked to your QuantumSafe dashboard account.")
60
+ return 0
61
+
62
+
63
+ # --------------------------------------------------------------------------- #
64
+ # scan
65
+ # --------------------------------------------------------------------------- #
66
+
67
+
68
+ def cmd_scan(args: argparse.Namespace) -> int:
69
+ if bool(args.path) == bool(args.repo):
70
+ print("Error: provide exactly one of --path or --repo.", file=sys.stderr)
71
+ return 2
72
+
73
+ exclude = args.exclude or None
74
+ try:
75
+ if args.repo:
76
+ target = args.repo
77
+ findings = scan_repo(args.repo, exclude=exclude)
78
+ else:
79
+ target = os.path.abspath(args.path)
80
+ findings = scan_path(args.path, exclude=exclude)
81
+ except (FileNotFoundError, ValueError, RuntimeError) as exc:
82
+ print(f"Error: {exc}", file=sys.stderr)
83
+ return 1
84
+
85
+ report = reporter.build_report(findings, target)
86
+
87
+ if args.output:
88
+ name = args.output.lower()
89
+ ext = os.path.splitext(name)[1]
90
+ if name.endswith(".cbom.json") or name.endswith(".cdx.json"):
91
+ content = reporter.to_cbom(report)
92
+ elif ext == ".json":
93
+ content = reporter.to_json(report)
94
+ elif ext in (".html", ".htm"):
95
+ content = reporter.to_html(report)
96
+ elif ext == ".sarif":
97
+ content = reporter.to_sarif(report)
98
+ elif ext == ".svg":
99
+ content = reporter.to_badge_svg(report)
100
+ else:
101
+ print("Error: --output must end in .json, .cbom.json, .html, .sarif, or .svg",
102
+ file=sys.stderr)
103
+ return 2
104
+ with open(args.output, "w", encoding="utf-8") as fh:
105
+ fh.write(content)
106
+ print(f"Report written to {args.output}")
107
+ # Also show the summary in the terminal for convenience.
108
+ reporter.print_terminal(report)
109
+ else:
110
+ reporter.print_terminal(report)
111
+
112
+ _maybe_sync(report, args.no_sync)
113
+
114
+ # Non-zero exit on HIGH findings so the CLI is CI-friendly.
115
+ return 1 if report["summary"]["high"] > 0 and args.fail_on_high else 0
116
+
117
+
118
+ def _maybe_sync(report: dict, no_sync: bool) -> None:
119
+ """Upload the report to the linked dashboard (if `auth` was run with an API URL)."""
120
+ if no_sync:
121
+ return
122
+ cfg = load_config()
123
+ key, url = cfg.get("api_key"), cfg.get("api_url")
124
+ if not key or not url:
125
+ return # not linked to a dashboard — nothing to do
126
+
127
+ import json
128
+ import urllib.error
129
+ import urllib.request
130
+
131
+ body = json.dumps({"report": report}).encode("utf-8")
132
+ req = urllib.request.Request(
133
+ url.rstrip("/") + "/api/v1/scan/import",
134
+ data=body,
135
+ headers={"Content-Type": "application/json", "X-API-Key": key},
136
+ method="POST",
137
+ )
138
+ try:
139
+ with urllib.request.urlopen(req, timeout=30) as resp:
140
+ res = json.load(resp)
141
+ print(f"Synced to dashboard: scan #{res.get('scan_id')} ({url})")
142
+ except urllib.error.HTTPError as exc:
143
+ print(f"Dashboard sync failed (HTTP {exc.code}). Re-run 'quantumsafe auth' "
144
+ f"to reconnect.", file=sys.stderr)
145
+ except Exception as exc:
146
+ print(f"Dashboard sync skipped ({exc}).", file=sys.stderr)
147
+
148
+
149
+ # --------------------------------------------------------------------------- #
150
+ # version
151
+ # --------------------------------------------------------------------------- #
152
+
153
+
154
+ def cmd_version(_args: argparse.Namespace) -> int:
155
+ print(f"quantumsafe {__version__}")
156
+ return 0
157
+
158
+
159
+ # --------------------------------------------------------------------------- #
160
+ # parser
161
+ # --------------------------------------------------------------------------- #
162
+
163
+
164
+ def build_parser() -> argparse.ArgumentParser:
165
+ parser = argparse.ArgumentParser(
166
+ prog="quantumsafe",
167
+ description="Scan codebases for quantum-vulnerable cryptography.",
168
+ )
169
+ sub = parser.add_subparsers(dest="command", required=True)
170
+
171
+ p_scan = sub.add_parser("scan", help="Scan a local path or GitHub repo.")
172
+ p_scan.add_argument("--path", help="Local directory or file to scan.")
173
+ p_scan.add_argument("--repo", help="Public GitHub repository URL to scan.")
174
+ p_scan.add_argument("--output",
175
+ help="Write report to this file: .json, .cbom.json (CycloneDX CBOM), "
176
+ ".html, .sarif, or .svg (risk badge).")
177
+ p_scan.add_argument("--exclude", action="append", metavar="GLOB",
178
+ help="Glob of paths to skip (repeatable), e.g. --exclude 'tests/*'.")
179
+ p_scan.add_argument("--fail-on-high", action="store_true",
180
+ help="Exit with code 1 if any HIGH-risk finding is present (for CI).")
181
+ p_scan.add_argument("--no-sync", action="store_true",
182
+ help="Don't upload results to your linked dashboard account.")
183
+ p_scan.set_defaults(func=cmd_scan)
184
+
185
+ p_version = sub.add_parser("version", help="Print the version.")
186
+ p_version.set_defaults(func=cmd_version)
187
+
188
+ p_auth = sub.add_parser("auth", help="Link the CLI to a dashboard account.")
189
+ p_auth.add_argument("--key", required=True, help="API key from your dashboard Settings page.")
190
+ p_auth.add_argument("--api-url", help="Override the dashboard API base URL.")
191
+ p_auth.set_defaults(func=cmd_auth)
192
+
193
+ return parser
194
+
195
+
196
+ def main(argv: list[str] | None = None) -> int:
197
+ parser = build_parser()
198
+ args = parser.parse_args(argv)
199
+ return args.func(args)
200
+
201
+
202
+ if __name__ == "__main__":
203
+ sys.exit(main())
@@ -0,0 +1,150 @@
1
+ """NIST post-quantum migration recommendations.
2
+
3
+ Each cryptographic "family" detected by the scanner maps to a concrete,
4
+ NIST-aligned replacement, the relevant FIPS standard, and an estimated
5
+ migration complexity. This is referenced by both the CLI reporter and the
6
+ backend's Migration Plan endpoint, so the advice stays consistent everywhere.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Recommendation:
16
+ replacement: str
17
+ nist_reference: str
18
+ complexity: str # "Low" | "Medium" | "High"
19
+ detail: str
20
+
21
+
22
+ # Keyed by the rule "family" used in scanner.py.
23
+ RECOMMENDATIONS: dict[str, Recommendation] = {
24
+ "rsa": Recommendation(
25
+ replacement="CRYSTALS-Kyber (ML-KEM) for key exchange / CRYSTALS-Dilithium (ML-DSA) for signatures",
26
+ nist_reference="FIPS 203 (ML-KEM), FIPS 204 (ML-DSA)",
27
+ complexity="High",
28
+ detail=(
29
+ "RSA is broken by Shor's algorithm on a sufficiently large quantum "
30
+ "computer regardless of key size. Use ML-KEM (Kyber) where RSA is "
31
+ "used for key transport/encryption, and ML-DSA (Dilithium) where RSA "
32
+ "is used for digital signatures."
33
+ ),
34
+ ),
35
+ "ecc": Recommendation(
36
+ replacement="CRYSTALS-Kyber (ML-KEM) for ECDH / CRYSTALS-Dilithium (ML-DSA) for ECDSA",
37
+ nist_reference="FIPS 203 (ML-KEM), FIPS 204 (ML-DSA)",
38
+ complexity="High",
39
+ detail=(
40
+ "Elliptic-curve cryptography (ECDSA/ECDH/ECC) is broken by Shor's "
41
+ "algorithm. Replace ECDH key agreement with ML-KEM (Kyber) and ECDSA "
42
+ "signatures with ML-DSA (Dilithium)."
43
+ ),
44
+ ),
45
+ "dsa": Recommendation(
46
+ replacement="CRYSTALS-Dilithium (ML-DSA)",
47
+ nist_reference="FIPS 204 (ML-DSA), FIPS 205 (SLH-DSA / SPHINCS+)",
48
+ complexity="High",
49
+ detail=(
50
+ "DSA signatures are broken by Shor's algorithm. Migrate to ML-DSA "
51
+ "(Dilithium), or SLH-DSA (SPHINCS+) where a hash-based, "
52
+ "conservative-assumption signature is preferred."
53
+ ),
54
+ ),
55
+ "dh": Recommendation(
56
+ replacement="CRYSTALS-Kyber (ML-KEM)",
57
+ nist_reference="FIPS 203 (ML-KEM)",
58
+ complexity="High",
59
+ detail=(
60
+ "Classic Diffie-Hellman key exchange is broken by Shor's algorithm. "
61
+ "Replace with ML-KEM (Kyber), optionally in a hybrid construction "
62
+ "with an existing classical KEX during transition."
63
+ ),
64
+ ),
65
+ "md5": Recommendation(
66
+ replacement="SHA-3 (SHA3-256) or SHA-256",
67
+ nist_reference="FIPS 202 (SHA-3), FIPS 180-4 (SHA-2)",
68
+ complexity="Low",
69
+ detail=(
70
+ "MD5 is cryptographically broken (practical collisions) and is "
71
+ "further weakened by Grover's algorithm. Replace with SHA-3 or SHA-256."
72
+ ),
73
+ ),
74
+ "sha1": Recommendation(
75
+ replacement="SHA-3 (SHA3-256) or SHA-256",
76
+ nist_reference="FIPS 202 (SHA-3), FIPS 180-4 (SHA-2)",
77
+ complexity="Low",
78
+ detail=(
79
+ "SHA-1 is broken (practical collisions, e.g. SHAttered) and weakened "
80
+ "by Grover's algorithm. Replace with SHA-3 or SHA-256."
81
+ ),
82
+ ),
83
+ "tls_old": Recommendation(
84
+ replacement="TLS 1.3",
85
+ nist_reference="NIST SP 800-52 Rev. 2",
86
+ complexity="Low",
87
+ detail=(
88
+ "TLS 1.0/1.1 are deprecated and use quantum-vulnerable key exchange. "
89
+ "Upgrade to TLS 1.3 and plan for hybrid PQC key exchange."
90
+ ),
91
+ ),
92
+ "3des": Recommendation(
93
+ replacement="AES-256",
94
+ nist_reference="FIPS 197 (AES), NIST SP 800-131A Rev. 2",
95
+ complexity="Low",
96
+ detail=(
97
+ "3DES/Triple-DES is deprecated and its effective security is halved "
98
+ "by Grover's algorithm. Replace with AES-256."
99
+ ),
100
+ ),
101
+ "rc4": Recommendation(
102
+ replacement="AES-256 (GCM)",
103
+ nist_reference="FIPS 197 (AES), NIST SP 800-131A Rev. 2",
104
+ complexity="Low",
105
+ detail=(
106
+ "RC4 is insecure and prohibited for TLS. Replace with AES-256 in an "
107
+ "AEAD mode such as GCM."
108
+ ),
109
+ ),
110
+ "sha256": Recommendation(
111
+ replacement="SHA-256 (acceptable) - consider SHA-384/512 or SHA3 for long-term",
112
+ nist_reference="FIPS 180-4 (SHA-2), FIPS 202 (SHA-3)",
113
+ complexity="Low",
114
+ detail=(
115
+ "Grover's algorithm reduces SHA-256's preimage resistance to ~128 "
116
+ "bits, which remains secure. Monitor; for long-lived data consider "
117
+ "SHA-384/512 or SHA-3."
118
+ ),
119
+ ),
120
+ "aes128": Recommendation(
121
+ replacement="AES-256",
122
+ nist_reference="FIPS 197 (AES)",
123
+ complexity="Low",
124
+ detail=(
125
+ "Grover's algorithm halves AES-128's effective security to ~64 bits. "
126
+ "Move to AES-256 for quantum-resistant symmetric encryption."
127
+ ),
128
+ ),
129
+ "tls12": Recommendation(
130
+ replacement="TLS 1.3",
131
+ nist_reference="NIST SP 800-52 Rev. 2",
132
+ complexity="Low",
133
+ detail=(
134
+ "TLS 1.2 is acceptable today but still relies on classical key "
135
+ "exchange. Plan migration to TLS 1.3 with hybrid PQC key exchange."
136
+ ),
137
+ ),
138
+ }
139
+
140
+ _FALLBACK = Recommendation(
141
+ replacement="Review against NIST PQC guidance",
142
+ nist_reference="NIST IR 8547 (PQC transition)",
143
+ complexity="Medium",
144
+ detail="Manually review this usage against current NIST post-quantum guidance.",
145
+ )
146
+
147
+
148
+ def recommend(family: str) -> Recommendation:
149
+ """Return the migration recommendation for a detection family."""
150
+ return RECOMMENDATIONS.get(family, _FALLBACK)