moss-pqc 0.1.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.
- moss_pqc-0.1.0/.gitignore +9 -0
- moss_pqc-0.1.0/PKG-INFO +11 -0
- moss_pqc-0.1.0/moss_pqc/__init__.py +1 -0
- moss_pqc-0.1.0/moss_pqc/cli.py +128 -0
- moss_pqc-0.1.0/moss_pqc/engine.py +205 -0
- moss_pqc-0.1.0/moss_pqc/fix_runner.py +97 -0
- moss_pqc-0.1.0/moss_pqc/fixer.py +566 -0
- moss_pqc-0.1.0/moss_pqc/logging.py +66 -0
- moss_pqc-0.1.0/moss_pqc/models.py +48 -0
- moss_pqc-0.1.0/moss_pqc/report.py +358 -0
- moss_pqc-0.1.0/moss_pqc/rules/java/bouncycastle.yaml +40 -0
- moss_pqc-0.1.0/moss_pqc/rules/java/jwt.yaml +132 -0
- moss_pqc-0.1.0/moss_pqc/rules/java/keypairgenerator.yaml +68 -0
- moss_pqc-0.1.0/moss_pqc/rules/java/signature.yaml +69 -0
- moss_pqc-0.1.0/moss_pqc/rules/java/ssl-context.yaml +23 -0
- moss_pqc-0.1.0/moss_pqc/rules/javascript/jose.yaml +180 -0
- moss_pqc-0.1.0/moss_pqc/rules/javascript/jsonwebtoken.yaml +180 -0
- moss_pqc-0.1.0/moss_pqc/rules/javascript/node-crypto-keygen.yaml +44 -0
- moss_pqc-0.1.0/moss_pqc/rules/javascript/node-crypto-signing.yaml +46 -0
- moss_pqc-0.1.0/moss_pqc/rules/javascript/node-forge.yaml +23 -0
- moss_pqc-0.1.0/moss_pqc/rules/javascript/tls-config.yaml +24 -0
- moss_pqc-0.1.0/moss_pqc/rules/python/cryptography-asymmetric.yaml +20 -0
- moss_pqc-0.1.0/moss_pqc/rules/python/ecdsa-keygen.yaml +20 -0
- moss_pqc-0.1.0/moss_pqc/rules/python/ecdsa-signing.yaml +20 -0
- moss_pqc-0.1.0/moss_pqc/rules/python/paramiko.yaml +24 -0
- moss_pqc-0.1.0/moss_pqc/rules/python/pycrypto.yaml +44 -0
- moss_pqc-0.1.0/moss_pqc/rules/python/pyjwt.yaml +207 -0
- moss_pqc-0.1.0/moss_pqc/rules/python/rsa-keygen.yaml +20 -0
- moss_pqc-0.1.0/moss_pqc/rules/python/rsa-signing.yaml +22 -0
- moss_pqc-0.1.0/moss_pqc/rules/python/ssl-context.yaml +24 -0
- moss_pqc-0.1.0/moss_pqc/rules/python/weak-hash.yaml +40 -0
- moss_pqc-0.1.0/moss_pqc/sentry.py +32 -0
- moss_pqc-0.1.0/pyproject.toml +27 -0
- moss_pqc-0.1.0/tests/conftest.py +35 -0
- moss_pqc-0.1.0/tests/sarif-schema-2.1.0.json +3389 -0
- moss_pqc-0.1.0/tests/test_cli.py +346 -0
- moss_pqc-0.1.0/tests/test_cross_format.py +615 -0
- moss_pqc-0.1.0/tests/test_engine.py +70 -0
- moss_pqc-0.1.0/tests/test_fixer.py +921 -0
- moss_pqc-0.1.0/tests/test_logging.py +112 -0
- moss_pqc-0.1.0/tests/test_multilang_integration.py +482 -0
- moss_pqc-0.1.0/tests/test_observability.py +185 -0
- moss_pqc-0.1.0/tests/test_python_detection.py +173 -0
- moss_pqc-0.1.0/tests/test_report_gitlab.py +185 -0
- moss_pqc-0.1.0/tests/test_report_json.py +283 -0
- moss_pqc-0.1.0/tests/test_report_sarif.py +491 -0
- moss_pqc-0.1.0/tests/test_report_text.py +58 -0
moss_pqc-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moss-pqc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detect legacy / quantum-vulnerable cryptography in source code (Semgrep-powered).
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: click
|
|
7
|
+
Requires-Dist: semgrep
|
|
8
|
+
Requires-Dist: sentry-sdk
|
|
9
|
+
Requires-Dist: structlog
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import sentry_sdk
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .engine import SUPPORTED_LANGUAGES, PQCScanner, ScanError
|
|
11
|
+
from .fix_runner import run_fixes
|
|
12
|
+
from .logging import setup_logging
|
|
13
|
+
from .models import SEVERITY_RANK, ScanResult
|
|
14
|
+
from .report import ReportGenerator
|
|
15
|
+
from .sentry import init_sentry
|
|
16
|
+
|
|
17
|
+
log = structlog.get_logger()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group()
|
|
21
|
+
@click.version_option(version=__version__, prog_name="moss-pqc")
|
|
22
|
+
def cli() -> None:
|
|
23
|
+
"""MOSS PQC: detect legacy / quantum-vulnerable cryptography in source code."""
|
|
24
|
+
setup_logging()
|
|
25
|
+
init_sentry()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@cli.command()
|
|
29
|
+
@click.argument("target", type=click.Path(exists=True))
|
|
30
|
+
@click.option("-o", "--output", type=click.Path(), default=None,
|
|
31
|
+
help="Write the report to a file instead of stdout.")
|
|
32
|
+
@click.option("-f", "--format", "fmt",
|
|
33
|
+
type=click.Choice(["json", "sarif", "text", "gitlab"]), default="text",
|
|
34
|
+
show_default=True, help="Output format.")
|
|
35
|
+
@click.option("-l", "--lang", default=None,
|
|
36
|
+
help="Comma-separated languages to scan (python,javascript,java).")
|
|
37
|
+
@click.option("-s", "--severity", type=click.Choice(["critical", "high", "medium", "low"]),
|
|
38
|
+
default="low", show_default=True, help="Minimum severity to include.")
|
|
39
|
+
@click.option("--fail-on", "fail_on",
|
|
40
|
+
type=click.Choice(["critical", "high", "medium", "low", "none"]),
|
|
41
|
+
default="none", show_default=True,
|
|
42
|
+
help="Exit with code 1 if any finding at or above this severity exists. "
|
|
43
|
+
"Independent of --severity (which only filters the report).")
|
|
44
|
+
def scan(target: str, output: str | None, fmt: str, lang: str | None,
|
|
45
|
+
severity: str, fail_on: str) -> None:
|
|
46
|
+
"""Scan TARGET (a file or directory) for quantum-vulnerable cryptography."""
|
|
47
|
+
languages = None
|
|
48
|
+
if lang:
|
|
49
|
+
languages = [part.strip().lower() for part in lang.split(",") if part.strip()]
|
|
50
|
+
invalid = [language for language in languages if language not in SUPPORTED_LANGUAGES]
|
|
51
|
+
if invalid:
|
|
52
|
+
raise click.UsageError(
|
|
53
|
+
f"unsupported --lang value(s): {', '.join(invalid)}. "
|
|
54
|
+
f"Supported: {', '.join(SUPPORTED_LANGUAGES)}."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
scanner = PQCScanner()
|
|
58
|
+
try:
|
|
59
|
+
result = scanner.scan(target, languages=languages)
|
|
60
|
+
except ScanError as exc:
|
|
61
|
+
sentry_sdk.capture_exception(exc)
|
|
62
|
+
click.echo(f"error: {exc}", err=True)
|
|
63
|
+
sys.exit(2)
|
|
64
|
+
|
|
65
|
+
filtered = _filter_by_severity(result, severity)
|
|
66
|
+
|
|
67
|
+
if fmt == "json":
|
|
68
|
+
rendered = ReportGenerator.to_json(filtered)
|
|
69
|
+
elif fmt == "sarif":
|
|
70
|
+
rendered = ReportGenerator.to_sarif(filtered)
|
|
71
|
+
elif fmt == "gitlab":
|
|
72
|
+
rendered = ReportGenerator.to_gitlab(filtered)
|
|
73
|
+
else:
|
|
74
|
+
rendered = ReportGenerator.to_summary(filtered)
|
|
75
|
+
|
|
76
|
+
if output:
|
|
77
|
+
with open(output, "w", encoding="utf-8") as fh:
|
|
78
|
+
fh.write(rendered + "\n")
|
|
79
|
+
else:
|
|
80
|
+
click.echo(rendered)
|
|
81
|
+
|
|
82
|
+
# --fail-on is independent of --severity: it inspects the full, unfiltered
|
|
83
|
+
# finding set so hidden (filtered-out) findings still affect the exit code.
|
|
84
|
+
if fail_on != "none":
|
|
85
|
+
threshold = SEVERITY_RANK[fail_on]
|
|
86
|
+
if any(SEVERITY_RANK.get(f.severity, 0) >= threshold for f in result.findings):
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@cli.command()
|
|
91
|
+
@click.argument("target", type=click.Path(exists=True, file_okay=False))
|
|
92
|
+
@click.option("-l", "--lang", default=None,
|
|
93
|
+
help="Comma-separated languages to scan (python,javascript,java).")
|
|
94
|
+
def fix(target: str, lang: str | None) -> None:
|
|
95
|
+
"""Scan TARGET (a directory) and apply auto-fixable migrations in place."""
|
|
96
|
+
languages = None
|
|
97
|
+
if lang:
|
|
98
|
+
languages = [part.strip().lower() for part in lang.split(",") if part.strip()]
|
|
99
|
+
invalid = [language for language in languages if language not in SUPPORTED_LANGUAGES]
|
|
100
|
+
if invalid:
|
|
101
|
+
raise click.UsageError(
|
|
102
|
+
f"unsupported --lang value(s): {', '.join(invalid)}. "
|
|
103
|
+
f"Supported: {', '.join(SUPPORTED_LANGUAGES)}."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
scanner = PQCScanner()
|
|
107
|
+
try:
|
|
108
|
+
result = scanner.scan(target, languages=languages)
|
|
109
|
+
except ScanError as exc:
|
|
110
|
+
sentry_sdk.capture_exception(exc)
|
|
111
|
+
click.echo(f"error: {exc}", err=True)
|
|
112
|
+
sys.exit(2)
|
|
113
|
+
|
|
114
|
+
summary = run_fixes(result.findings, target)
|
|
115
|
+
click.echo(
|
|
116
|
+
f"fixes: {summary.applied} applied, {summary.failed} failed "
|
|
117
|
+
f"across {len(summary.file_fixes)} file(s)."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _filter_by_severity(result: ScanResult, minimum: str) -> ScanResult:
|
|
122
|
+
threshold = SEVERITY_RANK[minimum]
|
|
123
|
+
kept = [f for f in result.findings if SEVERITY_RANK.get(f.severity, 0) >= threshold]
|
|
124
|
+
return ScanResult(target=result.target, findings=kept, scanned_at=result.scanned_at)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
cli()
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import traceback
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
from .models import Finding, MigrationInfo, ScanResult
|
|
18
|
+
|
|
19
|
+
SUPPORTED_LANGUAGES = ("python", "javascript", "java")
|
|
20
|
+
|
|
21
|
+
log = structlog.get_logger()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ScanError(Exception):
|
|
25
|
+
"""Raised when semgrep fails to run (config/parse error or crash)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PQCScanner:
|
|
29
|
+
def __init__(self, rules_path: str | None = None):
|
|
30
|
+
self.rules_path = rules_path
|
|
31
|
+
|
|
32
|
+
def _resolve_rules_dir(self) -> Path:
|
|
33
|
+
if self.rules_path:
|
|
34
|
+
path = Path(self.rules_path)
|
|
35
|
+
if not path.exists():
|
|
36
|
+
raise ScanError(f"rules path does not exist: {self.rules_path}")
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
packaged = resources.files("moss_pqc") / "rules"
|
|
41
|
+
packaged_path = Path(str(packaged))
|
|
42
|
+
if packaged_path.is_dir() and any(packaged_path.iterdir()):
|
|
43
|
+
return packaged_path
|
|
44
|
+
except (ModuleNotFoundError, FileNotFoundError, NotADirectoryError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
repo_rules = Path(__file__).resolve().parents[2] / "rules"
|
|
48
|
+
if repo_rules.is_dir():
|
|
49
|
+
return repo_rules
|
|
50
|
+
|
|
51
|
+
raise ScanError("could not resolve a rules directory (packaged or repo-relative)")
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _resolve_semgrep() -> str:
|
|
55
|
+
candidate = Path(sys.executable).parent / "semgrep"
|
|
56
|
+
if candidate.exists():
|
|
57
|
+
return str(candidate)
|
|
58
|
+
found = shutil.which("semgrep")
|
|
59
|
+
if found:
|
|
60
|
+
return found
|
|
61
|
+
raise ScanError("semgrep executable not found")
|
|
62
|
+
|
|
63
|
+
def _config_args(self, rules_dir: Path, languages: list[str] | None) -> list[str] | None:
|
|
64
|
+
if not languages:
|
|
65
|
+
return ["--config", str(rules_dir)]
|
|
66
|
+
args: list[str] = []
|
|
67
|
+
for lang in languages:
|
|
68
|
+
sub = rules_dir / lang
|
|
69
|
+
if sub.is_dir():
|
|
70
|
+
args += ["--config", str(sub)]
|
|
71
|
+
return args or None
|
|
72
|
+
|
|
73
|
+
def count_rules(self, languages: list[str] | None = None) -> int:
|
|
74
|
+
"""Count the semgrep rule files that would be loaded for ``languages``.
|
|
75
|
+
|
|
76
|
+
Observability helper only; does not affect scan behavior. Returns the
|
|
77
|
+
number of ``.yml`` / ``.yaml`` rule files under the resolved config
|
|
78
|
+
directories.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
rules_dir = self._resolve_rules_dir()
|
|
82
|
+
except ScanError:
|
|
83
|
+
return 0
|
|
84
|
+
config_args = self._config_args(rules_dir, languages)
|
|
85
|
+
if not config_args:
|
|
86
|
+
return 0
|
|
87
|
+
config_dirs = [Path(config_args[i + 1]) for i in range(0, len(config_args), 2)]
|
|
88
|
+
count = 0
|
|
89
|
+
for directory in config_dirs:
|
|
90
|
+
count += sum(1 for _ in directory.rglob("*.yml"))
|
|
91
|
+
count += sum(1 for _ in directory.rglob("*.yaml"))
|
|
92
|
+
return count
|
|
93
|
+
|
|
94
|
+
def scan(self, target: str, languages: list[str] | None = None) -> ScanResult:
|
|
95
|
+
start = time.perf_counter()
|
|
96
|
+
log.info("scan.start", target=target, languages=languages)
|
|
97
|
+
try:
|
|
98
|
+
abs_target = os.path.abspath(target)
|
|
99
|
+
if not os.path.exists(abs_target):
|
|
100
|
+
raise ScanError(f"target does not exist: {target}")
|
|
101
|
+
|
|
102
|
+
rules_dir = self._resolve_rules_dir()
|
|
103
|
+
config_args = self._config_args(rules_dir, languages)
|
|
104
|
+
scanned_at = datetime.now(timezone.utc)
|
|
105
|
+
|
|
106
|
+
log.info("scan.rules_loaded", rule_count=self.count_rules(languages))
|
|
107
|
+
|
|
108
|
+
if config_args is None:
|
|
109
|
+
result = ScanResult(target=target, findings=[], scanned_at=scanned_at)
|
|
110
|
+
else:
|
|
111
|
+
semgrep = self._resolve_semgrep()
|
|
112
|
+
cmd = [
|
|
113
|
+
semgrep, *config_args, "--json", "--quiet",
|
|
114
|
+
"--no-rewrite-rule-ids", abs_target,
|
|
115
|
+
]
|
|
116
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
117
|
+
if proc.returncode > 1:
|
|
118
|
+
raise ScanError(
|
|
119
|
+
f"semgrep failed (exit {proc.returncode}): "
|
|
120
|
+
f"{proc.stderr.strip() or proc.stdout.strip()}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
payload = json.loads(proc.stdout)
|
|
125
|
+
except json.JSONDecodeError as exc:
|
|
126
|
+
raise ScanError(f"could not parse semgrep output: {exc}") from exc
|
|
127
|
+
|
|
128
|
+
base = abs_target if os.path.isdir(abs_target) else os.path.dirname(abs_target)
|
|
129
|
+
findings = [self._to_finding(r, base) for r in payload.get("results", [])]
|
|
130
|
+
result = ScanResult(target=target, findings=findings, scanned_at=scanned_at)
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
log.error(
|
|
133
|
+
"scan.error",
|
|
134
|
+
target=target,
|
|
135
|
+
error=str(exc),
|
|
136
|
+
error_type=type(exc).__name__,
|
|
137
|
+
traceback=traceback.format_exc(),
|
|
138
|
+
)
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
duration_s = round(time.perf_counter() - start, 4)
|
|
142
|
+
log.info(
|
|
143
|
+
"scan.complete",
|
|
144
|
+
target=target,
|
|
145
|
+
findings_total=len(result.findings),
|
|
146
|
+
findings_by_severity=result.counts_by_severity(),
|
|
147
|
+
duration_s=duration_s,
|
|
148
|
+
)
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
def _to_finding(self, result: dict, base: str) -> Finding:
|
|
152
|
+
check_id = result.get("check_id", "")
|
|
153
|
+
pattern_id = check_id.rsplit(".", 1)[-1] if check_id else check_id
|
|
154
|
+
|
|
155
|
+
path = result.get("path", "")
|
|
156
|
+
start = result.get("start", {})
|
|
157
|
+
end = result.get("end", {})
|
|
158
|
+
line = int(start.get("line", 0) or 0)
|
|
159
|
+
column = int(start.get("col", 0) or 0)
|
|
160
|
+
end_line = int(end.get("line", line) or line)
|
|
161
|
+
|
|
162
|
+
display_file = os.path.relpath(path, base) if path else path
|
|
163
|
+
snippet = self._read_snippet(path, line, end_line)
|
|
164
|
+
|
|
165
|
+
extra = result.get("extra", {})
|
|
166
|
+
metadata = extra.get("metadata", {})
|
|
167
|
+
message = extra.get("message") or result.get("message", "")
|
|
168
|
+
|
|
169
|
+
migration = MigrationInfo(
|
|
170
|
+
recommendation=metadata.get("migration_recommendation", ""),
|
|
171
|
+
code_before=metadata.get("code_before"),
|
|
172
|
+
code_after=metadata.get("code_after"),
|
|
173
|
+
moss_sdk_link=metadata.get("moss_sdk_link"),
|
|
174
|
+
auto_fixable=bool(metadata.get("auto_fixable", False)),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return Finding(
|
|
178
|
+
id=str(uuid.uuid4()),
|
|
179
|
+
file=display_file,
|
|
180
|
+
line=line,
|
|
181
|
+
column=column,
|
|
182
|
+
code_snippet=snippet,
|
|
183
|
+
pattern_id=pattern_id,
|
|
184
|
+
severity=metadata.get("severity", "low"),
|
|
185
|
+
category=metadata.get("category", ""),
|
|
186
|
+
algorithm=metadata.get("algorithm", ""),
|
|
187
|
+
message=message,
|
|
188
|
+
migration=migration,
|
|
189
|
+
library=metadata.get("library", ""),
|
|
190
|
+
cwe=metadata.get("cwe", ""),
|
|
191
|
+
owasp=metadata.get("owasp", ""),
|
|
192
|
+
quantum_vulnerable=bool(metadata.get("quantum_vulnerable", True)),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _read_snippet(path: str, start_line: int, end_line: int) -> str:
|
|
197
|
+
if not path or start_line < 1:
|
|
198
|
+
return ""
|
|
199
|
+
try:
|
|
200
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
201
|
+
lines = fh.readlines()
|
|
202
|
+
except OSError:
|
|
203
|
+
return ""
|
|
204
|
+
snippet = "".join(lines[start_line - 1:end_line])
|
|
205
|
+
return snippet.strip("\n")
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Observability wrapper around the sealed fixer engine.
|
|
2
|
+
|
|
3
|
+
This module orchestrates :mod:`moss_pqc.fixer` to apply fixes while emitting
|
|
4
|
+
structured fix-lifecycle log events (``fix.attempted`` / ``fix.applied`` /
|
|
5
|
+
``fix.failed``) and forwarding :class:`~moss_pqc.fixer.FixError` to Sentry. It
|
|
6
|
+
does not change any fixer behavior; it only observes it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import traceback
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import sentry_sdk
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
from .fixer import FileFix, FixError, apply_fixes_to_repo, generate_fix
|
|
18
|
+
from .models import Finding
|
|
19
|
+
|
|
20
|
+
log = structlog.get_logger()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FixRunSummary:
|
|
25
|
+
"""Outcome of a fix run across a set of findings."""
|
|
26
|
+
|
|
27
|
+
attempted: int
|
|
28
|
+
applied: int
|
|
29
|
+
failed: int
|
|
30
|
+
file_fixes: list[FileFix]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _lines_changed(unified_diff: str) -> int:
|
|
34
|
+
changed = 0
|
|
35
|
+
for line in unified_diff.splitlines():
|
|
36
|
+
if line.startswith(("+++", "---")):
|
|
37
|
+
continue
|
|
38
|
+
if line.startswith(("+", "-")):
|
|
39
|
+
changed += 1
|
|
40
|
+
return changed
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_fixes(findings: list[Finding], repo_path: str) -> FixRunSummary:
|
|
44
|
+
"""Attempt to fix every finding, logging each lifecycle transition.
|
|
45
|
+
|
|
46
|
+
For each finding a ``fix.attempted`` event is emitted. A successfully
|
|
47
|
+
generated fix emits ``fix.applied``; a :class:`FixError` emits
|
|
48
|
+
``fix.failed`` (with a stack trace) and is captured by Sentry. Findings that
|
|
49
|
+
can be fixed are then written to disk via the sealed
|
|
50
|
+
:func:`~moss_pqc.fixer.apply_fixes_to_repo`.
|
|
51
|
+
"""
|
|
52
|
+
fixable: list[Finding] = []
|
|
53
|
+
failed = 0
|
|
54
|
+
|
|
55
|
+
for finding in findings:
|
|
56
|
+
log.info("fix.attempted", file=finding.file, pattern_id=finding.pattern_id)
|
|
57
|
+
try:
|
|
58
|
+
fix = generate_fix(finding, repo_path)
|
|
59
|
+
except FixError as exc:
|
|
60
|
+
failed += 1
|
|
61
|
+
log.error(
|
|
62
|
+
"fix.failed",
|
|
63
|
+
file=finding.file,
|
|
64
|
+
pattern_id=finding.pattern_id,
|
|
65
|
+
error=str(exc),
|
|
66
|
+
traceback=traceback.format_exc(),
|
|
67
|
+
)
|
|
68
|
+
sentry_sdk.capture_exception(exc)
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
log.info(
|
|
72
|
+
"fix.applied",
|
|
73
|
+
file=finding.file,
|
|
74
|
+
pattern_id=finding.pattern_id,
|
|
75
|
+
lines_changed=_lines_changed(fix.unified_diff),
|
|
76
|
+
)
|
|
77
|
+
fixable.append(finding)
|
|
78
|
+
|
|
79
|
+
file_fixes: list[FileFix] = []
|
|
80
|
+
if fixable:
|
|
81
|
+
try:
|
|
82
|
+
file_fixes = apply_fixes_to_repo(fixable, repo_path)
|
|
83
|
+
except FixError as exc:
|
|
84
|
+
log.error(
|
|
85
|
+
"fix.failed",
|
|
86
|
+
error=str(exc),
|
|
87
|
+
traceback=traceback.format_exc(),
|
|
88
|
+
)
|
|
89
|
+
sentry_sdk.capture_exception(exc)
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
return FixRunSummary(
|
|
93
|
+
attempted=len(findings),
|
|
94
|
+
applied=len(fixable),
|
|
95
|
+
failed=failed,
|
|
96
|
+
file_fixes=file_fixes,
|
|
97
|
+
)
|