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_core/cli.py ADDED
@@ -0,0 +1,202 @@
1
+ """Command-line interface and scan orchestrator for ECDAT.
2
+
3
+ Public API:
4
+ - :func:`run_scan` — orchestrate a full scan (ingest -> detect -> assess
5
+ -> recommend -> assemble) and return a :class:`ScanResult`.
6
+ - :func:`main` — console entrypoint for both the ``ecdat`` command
7
+ (registered via ``[project.scripts]`` in pyproject.toml as
8
+ ``ecdat = "ecdat_core.cli:main"``) and the ``python -m ecdat_core.cli``
9
+ module invocation. Argparse resolves ``argv=None`` to ``sys.argv[1:]``,
10
+ so the setuptools-generated wrapper calling ``main()`` with no arguments
11
+ behaves identically to the module form.
12
+
13
+ The ``scan`` subcommand runs :func:`run_scan` over a local directory or git
14
+ URL, writes ``cbom.json`` and ``summary.json`` to the current working
15
+ directory, and prints a human-readable summary table to stdout.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from uuid import uuid4
26
+
27
+ from ecdat_core.cbom_export import export_cbom, export_summary
28
+ from ecdat_core.detector import scan_file_content
29
+ from ecdat_core.ingestion import ingest_git_url, ingest_local_directory
30
+ from ecdat_core.models import Detection, ScanResult
31
+ from ecdat_core.recommender import recommend
32
+ from ecdat_core.risk_engine import assess_risk
33
+ from ecdat_core.signature_loader import SignatureEntry, get_all_signatures
34
+
35
+ _RISK_ORDER = ("critical", "high", "medium", "low", "quantum-safe")
36
+
37
+
38
+ def run_scan(
39
+ target: str,
40
+ is_git_url: bool = False,
41
+ *,
42
+ sandboxed: bool = True,
43
+ ) -> ScanResult:
44
+ """Orchestrate a full ECDAT scan and return the assembled result.
45
+
46
+ Args:
47
+ target: A local directory path, or a Git repository URL when
48
+ ``is_git_url`` is ``True``.
49
+ is_git_url: When ``True``, treat *target* as a Git URL to shallow
50
+ clone before scanning.
51
+ sandboxed: When ``True`` (the default), a user-supplied local path is
52
+ enforced inside ``SCAN_WORKSPACE_ROOT`` by
53
+ :func:`~ecdat_core.ingestion.validate_local_path` — this is what
54
+ the FastAPI backend relies on, so the default must stay ``True``.
55
+ The pip-installed ``ecdat`` CLI passes ``sandboxed=False`` because
56
+ there the invoking user is the trust boundary. Has no effect on
57
+ git-URL scans: the directory :func:`~ecdat_core.ingestion.ingest_git_url`
58
+ creates is always scanner-owned and therefore never sandboxed.
59
+
60
+ Returns:
61
+ A fully populated :class:`ScanResult` with detections, risk
62
+ assessments, and recommendations for every cryptographic artefact
63
+ discovered, plus a ``files_scanned`` count.
64
+ """
65
+ if is_git_url:
66
+ scan_target = target
67
+ # The cloned directory below is scanner-created (tempfile.mkdtemp,
68
+ # mode 0700, ephemeral) — not a user-supplied path — so scanning it is
69
+ # exempt from the local-path sandbox. The user-controlled git URL
70
+ # itself has already passed validate_git_url inside ingest_git_url.
71
+ local_path = ingest_git_url(target)
72
+ ingest_sandboxed = False
73
+ else:
74
+ scan_target = str(Path(target).resolve())
75
+ local_path = target
76
+ # User-supplied path: honour the caller's sandbox policy. Defaults to
77
+ # sandboxed so existing callers (including the backend) are unchanged.
78
+ ingest_sandboxed = sandboxed
79
+
80
+ signatures = _signature_lookup()
81
+
82
+ detections: list[Detection] = []
83
+ files_scanned = 0
84
+
85
+ for file_path, content, language in ingest_local_directory(
86
+ local_path, sandboxed=ingest_sandboxed
87
+ ):
88
+ files_scanned += 1
89
+ detections.extend(scan_file_content(file_path, content, language))
90
+
91
+ return _assemble(scan_target, detections, signatures, files_scanned)
92
+
93
+
94
+ def _signature_lookup() -> dict[str, SignatureEntry]:
95
+ """Return a map of signature name -> entry for fast lookups."""
96
+ return {entry.name: entry for entry in get_all_signatures()}
97
+
98
+
99
+ def _assemble(
100
+ target: str,
101
+ detections: list[Detection],
102
+ signatures: dict[str, SignatureEntry],
103
+ files_scanned: int,
104
+ ) -> ScanResult:
105
+ """Assess risk, recommend, and package everything into a ScanResult."""
106
+ risk_assessments = []
107
+ recommendations = []
108
+
109
+ for detection in detections:
110
+ entry = signatures.get(detection.algorithm_family)
111
+ if entry is None:
112
+ continue
113
+ risk_assessments.append(assess_risk(detection, entry))
114
+ recommendations.append(recommend(detection, entry))
115
+
116
+ return ScanResult(
117
+ scan_id=str(uuid4()),
118
+ target=target,
119
+ detections=detections,
120
+ risk_assessments=risk_assessments,
121
+ recommendations=recommendations,
122
+ scanned_at=datetime.now(timezone.utc).isoformat(),
123
+ files_scanned=files_scanned,
124
+ )
125
+
126
+
127
+ def main(argv: list[str] | None = None) -> int:
128
+ """Run the ECDAT CLI; return a process exit code.
129
+
130
+ Args:
131
+ argv: Argument list; defaults to ``sys.argv[1:]``.
132
+
133
+ Returns:
134
+ ``0`` on success, ``2`` on argument misuse, ``1`` on scan failure.
135
+ """
136
+ parser = argparse.ArgumentParser(
137
+ prog="ecdat",
138
+ description="Cryptographic Discovery & Analysis Tool",
139
+ )
140
+ subparsers = parser.add_subparsers(dest="command", required=True)
141
+
142
+ scan_parser = subparsers.add_parser(
143
+ "scan", help="Scan a local directory or git repository"
144
+ )
145
+ scan_parser.add_argument(
146
+ "path", help="Local directory path, or git URL with --git-url"
147
+ )
148
+ scan_parser.add_argument(
149
+ "--git-url",
150
+ action="store_true",
151
+ help="Treat PATH as a git repository URL to clone",
152
+ )
153
+
154
+ args = parser.parse_args(argv)
155
+ if args.command != "scan":
156
+ parser.error("unknown command")
157
+
158
+ try:
159
+ result = run_scan(args.path, is_git_url=args.git_url)
160
+ except (OSError, RuntimeError, ValueError) as exc:
161
+ print(f"Error: {exc}", file=sys.stderr)
162
+ return 1
163
+
164
+ _write_outputs(result)
165
+ _print_summary(result)
166
+ return 0
167
+
168
+
169
+ def _write_outputs(result: ScanResult) -> None:
170
+ """Write cbom.json and summary.json into the current directory."""
171
+ _write_json("cbom.json", export_cbom(result))
172
+ _write_json("summary.json", export_summary(result))
173
+
174
+
175
+ def _write_json(filename: str, payload: dict) -> None:
176
+ """Write a dict as pretty-printed JSON to *filename* in the cwd."""
177
+ with Path(filename).open("w", encoding="utf-8") as handle:
178
+ json.dump(payload, handle, indent=2, sort_keys=True)
179
+ handle.write("\n")
180
+ print(f"wrote {filename}")
181
+
182
+
183
+ def _print_summary(result: ScanResult) -> None:
184
+ """Print a human-readable scan summary table to stdout."""
185
+ counts = {level: 0 for level in _RISK_ORDER}
186
+ for assessment in result.risk_assessments:
187
+ counts[assessment.risk_level] = counts.get(assessment.risk_level, 0) + 1
188
+
189
+ print()
190
+ print(f"Scan target : {result.target}")
191
+ print(f"Files scanned: {result.files_scanned}")
192
+ print(f"Detections : {len(result.detections)}")
193
+ print("-" * 32)
194
+ print(f"{'Risk level':<16}{'Count':>6}")
195
+ print("-" * 32)
196
+ for level in _RISK_ORDER:
197
+ print(f"{level:<16}{counts[level]:>6}")
198
+ print("-" * 32)
199
+
200
+
201
+ if __name__ == "__main__":
202
+ sys.exit(main())
ecdat_core/detector.py ADDED
@@ -0,0 +1,273 @@
1
+ """Regex-based detection engine for ECDAT.
2
+
3
+ Scans source file content line-by-line against the cryptographic signature
4
+ knowledge base and emits :class:`Detection` records for every regex match.
5
+
6
+ Public API:
7
+ - :func:`detect_language` -> map a file path's extension to an ECDAT
8
+ language key, or ``None`` if the file type is unsupported.
9
+ - :func:`scan_file_content` -> run all language signatures against content
10
+ and return the resulting detections.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from ecdat_core.models import Detection
18
+ from ecdat_core.signature_loader import SignatureEntry, get_signatures_for_language
19
+
20
+ # Extensions -> ECDAT language key. Case is normalised before lookup.
21
+ _EXTENSION_TO_LANGUAGE: dict[str, str] = {
22
+ ".py": "python",
23
+ ".js": "javascript",
24
+ ".ts": "javascript",
25
+ ".java": "java",
26
+ ".go": "go",
27
+ ".c": "c_cpp",
28
+ ".cpp": "c_cpp",
29
+ ".h": "c_cpp",
30
+ }
31
+
32
+ # Maximum length of the stored matched_text substring.
33
+ MAX_MATCHED_TEXT_LENGTH = 200
34
+
35
+ # Deterministic confidence values: an exact API call is a very strong signal,
36
+ # while a looser import-only or bare-keyword match is weaker.
37
+ CONFIDENCE_API_CALL = 1.0
38
+ CONFIDENCE_IMPORT_KEYWORD = 0.6
39
+
40
+ # Matches a call-like invocation in matched text, e.g. ``RSA.generate(`` or
41
+ # ``hashlib.md5(`` — an identifier immediately followed by an opening paren.
42
+ _API_CALL_RE = re.compile(r"\w+\s*\(")
43
+
44
+ # Families whose asset type is literally an algorithm. The current knowledge
45
+ # base only defines these families; certificate/protocol families are mapped
46
+ # defensively in case they are added later.
47
+ _ALGORITHM_FAMILIES = frozenset(
48
+ {
49
+ "asymmetric-encryption",
50
+ "signature",
51
+ "hash",
52
+ "symmetric-encryption",
53
+ "pqc-kem",
54
+ "pqc-signature",
55
+ }
56
+ )
57
+
58
+
59
+ def detect_language(file_path: str) -> str | None:
60
+ """Map a file path's extension to an ECDAT language key.
61
+
62
+ Args:
63
+ file_path: Path to the source file (only the extension is inspected).
64
+
65
+ Returns:
66
+ The ECDAT language key (e.g. "python", "javascript", "c_cpp") for a
67
+ supported extension, or ``None`` for an unsupported / missing one so
68
+ callers can skip such files.
69
+ """
70
+ suffix = _extension_of(file_path)
71
+ return _EXTENSION_TO_LANGUAGE.get(suffix)
72
+
73
+
74
+ def _extension_of(file_path: str) -> str:
75
+ """Return the lowercased file extension including the dot.
76
+
77
+ Returns an empty string when the path has no extension.
78
+ """
79
+ name = file_path.rsplit("/", 1)[-1]
80
+ dot = name.rfind(".")
81
+ if dot <= 0: # dot at index 0 means a hidden file such as ".gitignore"
82
+ return ""
83
+ return name[dot:].lower()
84
+
85
+
86
+ def scan_file_content(
87
+ file_path: str, content: str, language: str
88
+ ) -> list[Detection]:
89
+ """Run the language's signatures against content and collect detections.
90
+
91
+ Args:
92
+ file_path: Path to the file being scanned (stored on each detection).
93
+ content: Full source file content as a string.
94
+ language: An ECDAT language key, e.g. "python" or "java".
95
+
96
+ Returns:
97
+ list[Detection]: One detection per regex match found, in a
98
+ deterministic order derived from line / pattern ordering.
99
+ """
100
+ detections: list[Detection] = []
101
+ lines = content.split("\n")
102
+
103
+ for signature in get_signatures_for_language(language):
104
+ patterns = signature.patterns.get(language, [])
105
+ asset_type = _asset_type_for_family(signature.family)
106
+
107
+ # Collect every raw regex match for this signature, then collapse the
108
+ # ones that resolved to the same physical line. A signature entry often
109
+ # carries several pattern strings (import-style, call-site, bare
110
+ # keyword) that can legitimately co-occur on a single line — e.g. a
111
+ # one-line usage that matches both an import pattern and a call pattern,
112
+ # or `\bAES\b` matching inside `AES-256`. Those describe ONE real-world
113
+ # cryptographic usage, so they must yield exactly one Detection.
114
+ raw_matches: list[Detection] = []
115
+ for pattern in patterns:
116
+ compiled = re.compile(pattern)
117
+ for line_index, line in enumerate(lines):
118
+ line_number = line_index + 1
119
+ key_size_bits = _extract_key_size(
120
+ signature.key_size_pattern, line
121
+ )
122
+ for match in compiled.finditer(line):
123
+ raw_matches.append(
124
+ Detection(
125
+ file_path=file_path,
126
+ line_number=line_number,
127
+ matched_text=_trim_match(match.group(0)),
128
+ asset_type=asset_type,
129
+ algorithm_family=signature.name,
130
+ key_size_bits=key_size_bits,
131
+ quantum_vulnerable=_resolve_quantum_vulnerable(
132
+ signature, key_size_bits
133
+ ),
134
+ classically_broken=signature.classically_broken,
135
+ confidence=_compute_confidence(match.group(0)),
136
+ language=language,
137
+ detection_method="regex",
138
+ )
139
+ )
140
+
141
+ detections.extend(_dedupe_detections(raw_matches))
142
+ return detections
143
+
144
+
145
+ def _dedupe_detections(detections: list[Detection]) -> list[Detection]:
146
+ """Collapse same-line duplicates within a single signature's matches.
147
+
148
+ When several pattern strings of the same signature entry match the same
149
+ physical line (e.g. an import-style pattern and a call-site pattern both
150
+ firing on a one-line usage, or the ``\\bAES\\b`` bare-keyword pattern
151
+ matching inside ``AES-256``), they all describe the same real-world
152
+ cryptographic usage and must be collapsed into a single :class:`Detection`.
153
+
154
+ The caller passes matches from a *single* signature for a *single* file, so
155
+ keying on ``line_number`` here is equivalent to the (signature, file_path,
156
+ line_number) identity that defines one cryptographic usage.
157
+
158
+ Tie-break rule, applied deterministically per line:
159
+ 1. Prefer the highest-confidence match — an exact API call scores 1.0
160
+ vs a looser import/bare-keyword match at 0.6.
161
+ 2. If confidences tie, prefer the most specific pattern, i.e. the one
162
+ whose matched text is longest (``hashlib.md5(`` over ``md5(``, or
163
+ ``AES-256`` over a bare ``AES``). A longer match captures a more
164
+ precisely specified construct and better represents the real usage.
165
+
166
+ Each non-collapsed line keeps its first-seen position so the overall output
167
+ order stays stable and deterministic.
168
+
169
+ Args:
170
+ detections: Raw matches produced by one signature against one file.
171
+
172
+ Returns:
173
+ A deduplicated list with at most one :class:`Detection` per line.
174
+ """
175
+ best_by_line: dict[int, Detection] = {}
176
+ first_seen: list[int] = []
177
+
178
+ for detection in detections:
179
+ line = detection.line_number
180
+ current = best_by_line.get(line)
181
+ if current is None:
182
+ best_by_line[line] = detection
183
+ first_seen.append(line)
184
+ elif _is_better_match(detection, current):
185
+ best_by_line[line] = detection
186
+
187
+ return [best_by_line[line] for line in first_seen]
188
+
189
+
190
+ def _is_better_match(candidate: Detection, current: Detection) -> bool:
191
+ """Report whether *candidate* should replace *current* on the same line.
192
+
193
+ Higher confidence wins; on a confidence tie, the more specific pattern's
194
+ longer matched text wins (see :func:`_dedupe_detections`).
195
+ """
196
+ if candidate.confidence != current.confidence:
197
+ return candidate.confidence > current.confidence
198
+ return len(candidate.matched_text) > len(current.matched_text)
199
+
200
+
201
+ def _asset_type_for_family(family: str) -> str:
202
+ """Map a signature family to a CycloneDX asset type.
203
+
204
+ Algorithm families map to ``algorithm``; certificate and protocol families
205
+ map to their corresponding asset types.
206
+ """
207
+ if family in _ALGORITHM_FAMILIES or "algorithm" in family:
208
+ return "algorithm"
209
+ if "certificate" in family:
210
+ return "certificate"
211
+ if "protocol" in family:
212
+ return "protocol"
213
+ return "related-crypto-material"
214
+
215
+
216
+ def _trim_match(matched_text: str) -> str:
217
+ """Trim matched text to at most :data:`MAX_MATCHED_TEXT_LENGTH` chars."""
218
+ if len(matched_text) <= MAX_MATCHED_TEXT_LENGTH:
219
+ return matched_text
220
+ return matched_text[:MAX_MATCHED_TEXT_LENGTH]
221
+
222
+
223
+ def _extract_key_size(
224
+ key_size_pattern: str | None, line: str
225
+ ) -> int | None:
226
+ """Extract a key size in bits from a line, or None when not determinable."""
227
+ if not key_size_pattern:
228
+ return None
229
+ match = re.search(key_size_pattern, line)
230
+ if not match:
231
+ return None
232
+ return _digits_from_groups(match)
233
+
234
+
235
+ def _digits_from_groups(match: re.Match[str]) -> int | None:
236
+ """Return the first all-digit capture group as an int, else None."""
237
+ for group in match.groups():
238
+ if group is not None and group.isdigit():
239
+ return int(group)
240
+ # Fall back to the whole match if no group holds a pure number.
241
+ digits = "".join(ch for ch in match.group(0) if ch.isdigit())
242
+ return int(digits) if digits else None
243
+
244
+
245
+ def _compute_confidence(matched_text: str) -> float:
246
+ """Return a deterministic confidence for a matched substring.
247
+
248
+ An exact API-call match (an identifier immediately followed by an opening
249
+ parenthesis, e.g. ``RSA.generate(``) is scored 1.0. Looser import-only or
250
+ bare-keyword matches (e.g. ``from ... import RSA`` or a lone ``\bMD5\b``)
251
+ are scored 0.6.
252
+ """
253
+ if _API_CALL_RE.search(matched_text):
254
+ return CONFIDENCE_API_CALL
255
+ return CONFIDENCE_IMPORT_KEYWORD
256
+
257
+
258
+ def _resolve_quantum_vulnerable(
259
+ signature: SignatureEntry, key_size_bits: int | None
260
+ ) -> bool:
261
+ """Resolve quantum-vulnerability for a single detection.
262
+
263
+ Symmetric ciphers with a ``min_quantum_safe_key_bits`` threshold are
264
+ evaluated dynamically per-detection: a key size at or above the threshold
265
+ (e.g. AES-192/256) is quantum-safe, while a smaller extracted size is
266
+ flagged. When no key size could be extracted the signature's static
267
+ ``quantum_vulnerable`` applies unchanged — for AES that is the conservative
268
+ ``True`` default.
269
+ """
270
+ threshold = signature.min_quantum_safe_key_bits
271
+ if threshold is not None and key_size_bits is not None:
272
+ return key_size_bits < threshold
273
+ return signature.quantum_vulnerable