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.
- ecdat/__init__.py +10 -0
- ecdat/__main__.py +5 -0
- ecdat/cli/__init__.py +203 -0
- ecdat/cli/commands/__init__.py +0 -0
- ecdat/cli/commands/about.py +130 -0
- ecdat/cli/commands/demo.py +116 -0
- ecdat/cli/commands/doctor.py +296 -0
- ecdat/cli/commands/help_cmd.py +205 -0
- ecdat/cli/commands/scan.py +228 -0
- ecdat/cli/commands/version_cmd.py +48 -0
- ecdat/cli/parser.py +87 -0
- ecdat/demo_project/auth/login.py +75 -0
- ecdat/demo_project/certs/cert_verify.go +81 -0
- ecdat/demo_project/keyexchange/channel.go +48 -0
- ecdat/demo_project/legacy/LegacyCrypto.java +78 -0
- ecdat/demo_project/payments/payment.py +64 -0
- ecdat/demo_project/quantum/pqc_utils.py +67 -0
- ecdat/demo_project/quantum/slh_signer.py +40 -0
- ecdat/demo_project/tokens/signing.js +54 -0
- ecdat/py.typed +0 -0
- ecdat/services/__init__.py +1 -0
- ecdat/services/crashlog.py +109 -0
- ecdat/services/demo.py +85 -0
- ecdat/services/paths.py +52 -0
- ecdat/services/scanner.py +248 -0
- ecdat/services/viewmodel.py +326 -0
- ecdat/ui/__init__.py +1 -0
- ecdat/ui/art3d.py +136 -0
- ecdat/ui/art_static.py +65 -0
- ecdat/ui/art_text.py +81 -0
- ecdat/ui/banner.py +148 -0
- ecdat/ui/console.py +119 -0
- ecdat/ui/motion.py +64 -0
- ecdat/ui/render.py +486 -0
- ecdat/ui/theme.py +173 -0
- ecdat-0.2.0.dist-info/METADATA +142 -0
- ecdat-0.2.0.dist-info/RECORD +51 -0
- ecdat-0.2.0.dist-info/WHEEL +5 -0
- ecdat-0.2.0.dist-info/entry_points.txt +2 -0
- ecdat-0.2.0.dist-info/licenses/LICENSE +21 -0
- ecdat-0.2.0.dist-info/top_level.txt +2 -0
- ecdat_core/__init__.py +6 -0
- ecdat_core/cbom_export.py +287 -0
- ecdat_core/cli.py +202 -0
- ecdat_core/detector.py +273 -0
- ecdat_core/ingestion.py +581 -0
- ecdat_core/models.py +145 -0
- ecdat_core/recommender.py +74 -0
- ecdat_core/risk_engine.py +264 -0
- ecdat_core/signature_loader.py +204 -0
- 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
|