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.
Files changed (47) hide show
  1. moss_pqc-0.1.0/.gitignore +9 -0
  2. moss_pqc-0.1.0/PKG-INFO +11 -0
  3. moss_pqc-0.1.0/moss_pqc/__init__.py +1 -0
  4. moss_pqc-0.1.0/moss_pqc/cli.py +128 -0
  5. moss_pqc-0.1.0/moss_pqc/engine.py +205 -0
  6. moss_pqc-0.1.0/moss_pqc/fix_runner.py +97 -0
  7. moss_pqc-0.1.0/moss_pqc/fixer.py +566 -0
  8. moss_pqc-0.1.0/moss_pqc/logging.py +66 -0
  9. moss_pqc-0.1.0/moss_pqc/models.py +48 -0
  10. moss_pqc-0.1.0/moss_pqc/report.py +358 -0
  11. moss_pqc-0.1.0/moss_pqc/rules/java/bouncycastle.yaml +40 -0
  12. moss_pqc-0.1.0/moss_pqc/rules/java/jwt.yaml +132 -0
  13. moss_pqc-0.1.0/moss_pqc/rules/java/keypairgenerator.yaml +68 -0
  14. moss_pqc-0.1.0/moss_pqc/rules/java/signature.yaml +69 -0
  15. moss_pqc-0.1.0/moss_pqc/rules/java/ssl-context.yaml +23 -0
  16. moss_pqc-0.1.0/moss_pqc/rules/javascript/jose.yaml +180 -0
  17. moss_pqc-0.1.0/moss_pqc/rules/javascript/jsonwebtoken.yaml +180 -0
  18. moss_pqc-0.1.0/moss_pqc/rules/javascript/node-crypto-keygen.yaml +44 -0
  19. moss_pqc-0.1.0/moss_pqc/rules/javascript/node-crypto-signing.yaml +46 -0
  20. moss_pqc-0.1.0/moss_pqc/rules/javascript/node-forge.yaml +23 -0
  21. moss_pqc-0.1.0/moss_pqc/rules/javascript/tls-config.yaml +24 -0
  22. moss_pqc-0.1.0/moss_pqc/rules/python/cryptography-asymmetric.yaml +20 -0
  23. moss_pqc-0.1.0/moss_pqc/rules/python/ecdsa-keygen.yaml +20 -0
  24. moss_pqc-0.1.0/moss_pqc/rules/python/ecdsa-signing.yaml +20 -0
  25. moss_pqc-0.1.0/moss_pqc/rules/python/paramiko.yaml +24 -0
  26. moss_pqc-0.1.0/moss_pqc/rules/python/pycrypto.yaml +44 -0
  27. moss_pqc-0.1.0/moss_pqc/rules/python/pyjwt.yaml +207 -0
  28. moss_pqc-0.1.0/moss_pqc/rules/python/rsa-keygen.yaml +20 -0
  29. moss_pqc-0.1.0/moss_pqc/rules/python/rsa-signing.yaml +22 -0
  30. moss_pqc-0.1.0/moss_pqc/rules/python/ssl-context.yaml +24 -0
  31. moss_pqc-0.1.0/moss_pqc/rules/python/weak-hash.yaml +40 -0
  32. moss_pqc-0.1.0/moss_pqc/sentry.py +32 -0
  33. moss_pqc-0.1.0/pyproject.toml +27 -0
  34. moss_pqc-0.1.0/tests/conftest.py +35 -0
  35. moss_pqc-0.1.0/tests/sarif-schema-2.1.0.json +3389 -0
  36. moss_pqc-0.1.0/tests/test_cli.py +346 -0
  37. moss_pqc-0.1.0/tests/test_cross_format.py +615 -0
  38. moss_pqc-0.1.0/tests/test_engine.py +70 -0
  39. moss_pqc-0.1.0/tests/test_fixer.py +921 -0
  40. moss_pqc-0.1.0/tests/test_logging.py +112 -0
  41. moss_pqc-0.1.0/tests/test_multilang_integration.py +482 -0
  42. moss_pqc-0.1.0/tests/test_observability.py +185 -0
  43. moss_pqc-0.1.0/tests/test_python_detection.py +173 -0
  44. moss_pqc-0.1.0/tests/test_report_gitlab.py +185 -0
  45. moss_pqc-0.1.0/tests/test_report_json.py +283 -0
  46. moss_pqc-0.1.0/tests/test_report_sarif.py +491 -0
  47. moss_pqc-0.1.0/tests/test_report_text.py +58 -0
@@ -0,0 +1,9 @@
1
+ # Python / scanner mission artifacts
2
+ scanner/.venv/
3
+ **/__pycache__/
4
+ *.egg-info/
5
+ .semgrep/
6
+ *.report.json
7
+ .pytest_cache/
8
+ build/
9
+ dist/
@@ -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
+ )