pr-analyzer-cli 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,272 @@
1
+ import re
2
+ from collections import Counter
3
+ from collections.abc import Collection
4
+ from typing import Any
5
+
6
+ import structlog
7
+
8
+ from pr_analyzer.analyzer.ast_analyzer import CODE_EXTENSIONS, file_extension
9
+ from pr_analyzer.analyzer.line_lexer import split_line
10
+ from pr_analyzer.diff import iter_added_lines
11
+ from pr_analyzer.models import FileChange, Suggestion
12
+ from pr_analyzer.security.dep_auditor import DEPENDENCY_AUDIT_RULE, DepAuditScanner
13
+ from pr_analyzer.security.semgrep_runner import SEMGREP_RULE, SemgrepRunner
14
+
15
+ log = structlog.get_logger(__name__)
16
+
17
+ # Every pattern runs on one added line at a time, so a match can't span lines and its line
18
+ # number is exact. Secrets are checked in every file, comments included. Each "id" is the stable
19
+ # name .pr-analyzer.toml uses to switch the pattern off.
20
+ SECRET_PATTERNS: list[dict[str, Any]] = [
21
+ {
22
+ "id": "aws-access-key",
23
+ "regex": re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
24
+ "severity": "CRITICAL",
25
+ "category": "security",
26
+ "title": "AWS access key exposed",
27
+ "description": "An AWS access key was found hardcoded. Revoke it immediately and use environment variables.",
28
+ },
29
+ {
30
+ "id": "hardcoded-api-key",
31
+ "regex": re.compile(r"(?i)(api[_-]?key|apikey|api[_-]?secret)\s*[:=]\s*[\"']([A-Za-z0-9_\-]{16,})[\"']"),
32
+ "value_group": 2,
33
+ "severity": "CRITICAL",
34
+ "category": "security",
35
+ "title": "Hardcoded API key",
36
+ "description": "An API key was found hardcoded. Move it to environment variables.",
37
+ },
38
+ {
39
+ "id": "private-key",
40
+ "regex": re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----"),
41
+ "severity": "CRITICAL",
42
+ "category": "security",
43
+ "title": "Private key exposed",
44
+ "description": "A private PEM key block was found. Remove it immediately and rotate the key.",
45
+ },
46
+ {
47
+ "id": "github-token",
48
+ "regex": re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,})\b"),
49
+ "severity": "CRITICAL",
50
+ "category": "security",
51
+ "title": "GitHub personal access token exposed",
52
+ "description": "A GitHub token was found hardcoded. Revoke it immediately at github.com/settings/tokens.",
53
+ },
54
+ {
55
+ "id": "jwt-token",
56
+ "regex": re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
57
+ "severity": "HIGH",
58
+ "category": "security",
59
+ "title": "JWT token hardcoded",
60
+ "description": "A JWT token was found hardcoded. Tokens should never be committed to source control.",
61
+ },
62
+ {
63
+ "id": "hardcoded-password",
64
+ "regex": re.compile(r"(?i)(?:password|passwd|pwd)\s*[:=]\s*[\"']([^\"']{4,})[\"']"),
65
+ "value_group": 1,
66
+ "severity": "CRITICAL",
67
+ "category": "security",
68
+ "title": "Hardcoded password",
69
+ "description": "A password was found hardcoded. Move it to environment variables.",
70
+ },
71
+ {
72
+ "id": "db-connection-string",
73
+ "regex": re.compile(
74
+ r"(?i)\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?)://"
75
+ r"[^\s:/@\"']+:[^\s@/\"']+@"
76
+ ),
77
+ "severity": "CRITICAL",
78
+ "category": "security",
79
+ "title": "Database connection string with credentials",
80
+ "description": "A database URL with embedded credentials was found. Use environment variables instead.",
81
+ },
82
+ ]
83
+
84
+ # Checked only in source files, and only on code: a call named in a comment or written inside a
85
+ # string literal isn't a call, though one in an f-string's {...} or a template literal's ${...}
86
+ # is. String quotes stay visible (see line_lexer), so the SQL rule still sees a string being
87
+ # formatted into a query.
88
+ DANGEROUS_PATTERNS: list[dict[str, Any]] = [
89
+ {
90
+ "id": "sql-injection",
91
+ # an f-string, or a string literal followed by %, .format( or + ; parameterised calls such
92
+ # as execute("... %s", (value,)) are the safe form and are not flagged
93
+ "regex": re.compile(
94
+ r"\bexecute(?:many)?\s*\(\s*"
95
+ r"(?:[rRbBuU]?[fF][rR]?[\"']|[\"'][^\"']*[\"']\s*(?:%|\.format\s*\(|\+))"
96
+ ),
97
+ "severity": "HIGH",
98
+ "category": "security",
99
+ "title": "Possible SQL injection",
100
+ "description": "SQL query built with string formatting. Use parameterised queries instead.",
101
+ },
102
+ {
103
+ "id": "eval",
104
+ "regex": re.compile(r"(?<![\w.])eval\s*\("), # not model.eval() or my_eval()
105
+ "severity": "HIGH",
106
+ "category": "security",
107
+ "title": "eval() usage detected",
108
+ "description": "eval() executes arbitrary code. Never use it on user input.",
109
+ },
110
+ {
111
+ "id": "pickle-load",
112
+ "regex": re.compile(r"\bpickle\.loads?\s*\("),
113
+ "severity": "HIGH",
114
+ "category": "security",
115
+ "title": "pickle.load() usage detected",
116
+ "description": "pickle.load() can execute arbitrary code when loading untrusted data. Use JSON instead.",
117
+ },
118
+ {
119
+ "id": "yaml-load",
120
+ "regex": re.compile(
121
+ r"\byaml\.(?:unsafe_load\s*\(|load\s*\((?![^)]*Loader\s*=\s*(?:yaml\.)?C?SafeLoader\b))"
122
+ ),
123
+ "severity": "HIGH",
124
+ "category": "security",
125
+ "title": "yaml.load() without SafeLoader",
126
+ "description": "yaml.load() is unsafe. Use yaml.safe_load() or pass Loader=yaml.SafeLoader.",
127
+ },
128
+ {
129
+ "id": "subprocess-shell",
130
+ "regex": re.compile(r"\bsubprocess\.(?:run|call|check_call|check_output|Popen)\s*\(.*\bshell\s*=\s*True"),
131
+ "severity": "HIGH",
132
+ "category": "security",
133
+ "title": "subprocess with shell=True",
134
+ "description": "shell=True with user input allows command injection. Pass a list of arguments instead.",
135
+ },
136
+ {
137
+ "id": "insecure-random",
138
+ # only when the value looks security-related; random is fine for jitter or shuffling
139
+ "regex": re.compile(
140
+ r"(?i)\b\w*(?:token|passw|secret|otp|nonce|salt|session|api_?key)\w*\s*=.*"
141
+ r"\brandom\.(?:random|randint|choice|choices|randrange|getrandbits)\s*\("
142
+ ),
143
+ "severity": "MEDIUM",
144
+ "category": "security",
145
+ "title": "Non-cryptographic random used",
146
+ "description": "random module is not cryptographically secure. Use secrets module for tokens and passwords.",
147
+ },
148
+ {
149
+ "id": "debug-mode",
150
+ "regex": re.compile(r"(?i)\bdebug\s*=\s*True\b"),
151
+ "severity": "MEDIUM",
152
+ "category": "security",
153
+ "title": "Debug mode enabled",
154
+ "description": "debug=True should never be committed. It exposes stack traces and sensitive data in production.",
155
+ },
156
+ ]
157
+
158
+ # obvious placeholders in examples and docs are not leaked secrets
159
+ PLACEHOLDER = re.compile(
160
+ r"(?i)^(?:x+|\*+|\.+|<[^>]*>|\$\{[^}]*\}|\{\{?[^}]*\}\}?|%\([^)]*\)s|"
161
+ r".*(?:changeme|change_me|example|placeholder|dummy|redacted|"
162
+ r"your[_-]?(?:api[_-]?)?(?:key|token|password|secret)).*)$"
163
+ )
164
+
165
+ RULE_IDS = tuple(p["id"] for p in SECRET_PATTERNS + DANGEROUS_PATTERNS) + (
166
+ SEMGREP_RULE, DEPENDENCY_AUDIT_RULE,
167
+ )
168
+
169
+
170
+ def deduplicate(suggestions: list[Suggestion]) -> list[Suggestion]:
171
+ seen = set()
172
+ unique = []
173
+ for s in suggestions:
174
+ key = (s.filename, s.line, s.title, s.description)
175
+ if key not in seen:
176
+ seen.add(key)
177
+ unique.append(s)
178
+ return unique
179
+
180
+
181
+ class SecurityScanner:
182
+
183
+ def __init__(self, semgrep: SemgrepRunner | None = None, dep_auditor: DepAuditScanner | None = None):
184
+ self.semgrep = semgrep or SemgrepRunner()
185
+ self.dep_auditor = dep_auditor or DepAuditScanner()
186
+ self.warnings: list[str] = []
187
+
188
+ def scan(self, files: list[FileChange], disabled: Collection[str] = ()) -> list[Suggestion]:
189
+ """Check the files. `disabled` holds rule ids (see RULE_IDS) to skip."""
190
+ secrets = [p for p in SECRET_PATTERNS if p["id"] not in disabled]
191
+ dangerous = [p for p in DANGEROUS_PATTERNS if p["id"] not in disabled]
192
+ suggestions = []
193
+ for file in files:
194
+ if file.status == "removed" or not file.patch:
195
+ continue
196
+ # works from the patch alone, so files whose full content couldn't be fetched are
197
+ # still checked
198
+ suggestions.extend(self.scan_secrets(file, secrets))
199
+ suggestions.extend(self.scan_dangerous(file, dangerous))
200
+ suggestions.extend(self._run_tools(files, disabled))
201
+
202
+ unique = deduplicate(suggestions)
203
+ counts = Counter(s.severity for s in unique)
204
+ log.info(
205
+ "security scan complete",
206
+ total=len(unique),
207
+ critical=counts["CRITICAL"],
208
+ high=counts["HIGH"],
209
+ medium=counts["MEDIUM"],
210
+ low=counts["LOW"],
211
+ )
212
+ return unique
213
+
214
+ def _run_tools(self, files: list[FileChange], disabled: Collection[str]) -> list[Suggestion]:
215
+ # the external tools run once per PR, not once per file
216
+ suggestions: list[Suggestion] = []
217
+ self.warnings = []
218
+ if SEMGREP_RULE not in disabled:
219
+ suggestions.extend(self.semgrep.scan(files))
220
+ self.warnings += self.semgrep.warnings
221
+ if DEPENDENCY_AUDIT_RULE not in disabled:
222
+ suggestions.extend(self.dep_auditor.scan(files))
223
+ self.warnings += self.dep_auditor.warnings
224
+ return suggestions
225
+
226
+ def scan_secrets(
227
+ self, file: FileChange, patterns: list[dict[str, Any]] = SECRET_PATTERNS
228
+ ) -> list[Suggestion]:
229
+ if file.patch is None:
230
+ return []
231
+
232
+ suggestions = []
233
+ for line_no, text in iter_added_lines(file.patch):
234
+ for pattern in patterns:
235
+ match = pattern["regex"].search(text)
236
+ if match is None:
237
+ continue
238
+ group = pattern.get("value_group")
239
+ if group is not None and PLACEHOLDER.match(match.group(group)):
240
+ continue
241
+ suggestions.append(self._suggestion(file, pattern, line_no))
242
+
243
+ return suggestions
244
+
245
+ def scan_dangerous(
246
+ self, file: FileChange, patterns: list[dict[str, Any]] = DANGEROUS_PATTERNS
247
+ ) -> list[Suggestion]:
248
+ extension = file_extension(file.filename)
249
+ if file.patch is None or extension not in CODE_EXTENSIONS:
250
+ return []
251
+
252
+ suggestions = []
253
+ for line_no, text in iter_added_lines(file.patch):
254
+ code = split_line(text, extension).code
255
+ for pattern in patterns:
256
+ if pattern["regex"].search(code):
257
+ suggestions.append(self._suggestion(file, pattern, line_no))
258
+
259
+ return suggestions
260
+
261
+ @staticmethod
262
+ def _suggestion(file: FileChange, pattern: dict, line_no: int) -> Suggestion:
263
+ return Suggestion(
264
+ filename=file.filename,
265
+ severity=pattern["severity"],
266
+ category=pattern["category"],
267
+ title=pattern["title"],
268
+ description=pattern["description"],
269
+ line=line_no,
270
+ source="security",
271
+ rule_id=pattern["id"],
272
+ )
@@ -0,0 +1,190 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+ from urllib.parse import unquote
6
+
7
+ import structlog
8
+
9
+ from pr_analyzer.config import settings
10
+ from pr_analyzer.diff import iter_added_lines
11
+ from pr_analyzer.models import FileChange, Suggestion
12
+ from pr_analyzer.security import tools
13
+
14
+ log = structlog.get_logger(__name__)
15
+
16
+ # the stage's id in .pr-analyzer.toml; Semgrep's own rule id is in each finding's title
17
+ SEMGREP_RULE = "semgrep"
18
+
19
+
20
+ class SemgrepRunner:
21
+
22
+ def __init__(self, config: str | None = None, timeout: int | None = None):
23
+ self.config = config or settings.semgrep_config
24
+ self.timeout = timeout or settings.semgrep_timeout_seconds
25
+ self.warnings: list[str] = []
26
+
27
+ def scan(self, files: list[FileChange]) -> list[Suggestion]:
28
+ self.warnings = []
29
+ targets = [
30
+ f for f in files
31
+ if f.status != "removed" and f.raw_content is not None and f.patch
32
+ ]
33
+ if not targets:
34
+ return []
35
+
36
+ executable = tools.find_tool("semgrep")
37
+ if executable is None:
38
+ self._warn("Semgrep scan skipped: semgrep is not installed "
39
+ "(pip install 'pr-analyzer-cli[security]')")
40
+ return []
41
+
42
+ # one run over a copy of every changed file, laid out with its repo path so findings map
43
+ # straight back to the right file
44
+ with tempfile.TemporaryDirectory(prefix="pr-analyzer-semgrep-") as workdir:
45
+ files_by_path = {}
46
+ for file in targets:
47
+ relative = safe_relative_path(file.filename)
48
+ if relative is None:
49
+ log.warning("semgrep_skipped_path", filename=file.filename)
50
+ continue
51
+ destination = os.path.join(workdir, relative)
52
+ os.makedirs(os.path.dirname(destination), exist_ok=True)
53
+ with open(destination, "w", encoding="utf-8") as handle:
54
+ handle.write(file.raw_content or "")
55
+ files_by_path[relative] = file
56
+
57
+ if not files_by_path:
58
+ return []
59
+ sarif = self.run_semgrep(executable, workdir)
60
+ suggestions = self.parse_sarif(sarif, files_by_path, root=workdir)
61
+
62
+ log.info("semgrep scan complete", total_suggestions=len(suggestions))
63
+ return suggestions
64
+
65
+ def run_semgrep(self, executable: str, target_dir: str) -> dict:
66
+ command = [
67
+ executable, "scan",
68
+ "--config", self.config,
69
+ "--sarif", "--quiet",
70
+ "--metrics", "off",
71
+ "--disable-version-check",
72
+ ".",
73
+ ]
74
+ try:
75
+ result = subprocess.run(
76
+ command,
77
+ cwd=target_dir,
78
+ capture_output=True,
79
+ text=True,
80
+ timeout=self.timeout,
81
+ env=tools.scrubbed_env(),
82
+ )
83
+ except subprocess.TimeoutExpired:
84
+ self._warn(f"Semgrep scan skipped: it took longer than {self.timeout}s")
85
+ return {}
86
+ except OSError as e:
87
+ self._warn(f"Semgrep scan skipped: could not run semgrep ({e})")
88
+ return {}
89
+
90
+ if result.returncode not in (0, 1):
91
+ self._warn(f"Semgrep scan failed (exit {result.returncode}): "
92
+ f"{result.stderr.strip()[:200]}")
93
+ return {}
94
+ if not result.stdout.strip():
95
+ return {}
96
+ try:
97
+ return json.loads(result.stdout)
98
+ except json.JSONDecodeError:
99
+ self._warn("Semgrep scan failed: its output was not valid SARIF JSON")
100
+ return {}
101
+
102
+ def parse_sarif(
103
+ self, sarif: dict, files_by_path: dict[str, FileChange], root: str | None = None
104
+ ) -> list[Suggestion]:
105
+ runs = sarif.get("runs") or []
106
+ if not runs:
107
+ return []
108
+ run = runs[0]
109
+ levels = rule_levels(run)
110
+
111
+ added_lines: dict[str, set[int]] = {}
112
+ suggestions = []
113
+ for result in run.get("results") or []:
114
+ location = result_location(result)
115
+ if location is None:
116
+ continue
117
+ uri, start, end = location
118
+ file = files_by_path.get(sarif_path(uri, root))
119
+ if file is None:
120
+ continue
121
+
122
+ # report only what the PR added; issues already in the file aren't this PR's doing
123
+ if file.filename not in added_lines:
124
+ added_lines[file.filename] = {n for n, _ in iter_added_lines(file.patch or "")}
125
+ if not any(n in added_lines[file.filename] for n in range(start, end + 1)):
126
+ continue
127
+
128
+ rule_id = result.get("ruleId", "unknown")
129
+ level = result.get("level") or levels.get(rule_id, "warning")
130
+ message = (result.get("message") or {}).get("text", "")
131
+ suggestions.append(Suggestion(
132
+ filename=file.filename,
133
+ severity=self.map_severity(level),
134
+ category="security",
135
+ title=f"Semgrep: {rule_id.rsplit('.', 1)[-1]}",
136
+ description=f"{message} (rule: {rule_id})",
137
+ line=start,
138
+ source="security",
139
+ rule_id=SEMGREP_RULE,
140
+ ))
141
+ return suggestions
142
+
143
+ def map_severity(self, level: str) -> str:
144
+ mapping = {
145
+ "error": "HIGH",
146
+ "warning": "MEDIUM",
147
+ "note": "LOW",
148
+ "none": "LOW",
149
+ }
150
+ return mapping.get(level.lower(), "MEDIUM")
151
+
152
+ def _warn(self, message: str) -> None:
153
+ log.warning("semgrep", problem=message)
154
+ self.warnings.append(message)
155
+
156
+
157
+ def rule_levels(run: dict) -> dict[str, str]:
158
+ # semgrep puts the severity on the rule (defaultConfiguration.level), not on each result
159
+ rules = (run.get("tool") or {}).get("driver", {}).get("rules") or []
160
+ return {
161
+ rule.get("id"): (rule.get("defaultConfiguration") or {}).get("level", "warning")
162
+ for rule in rules
163
+ }
164
+
165
+
166
+ def result_location(result: dict) -> tuple[str, int, int] | None:
167
+ """(uri, first line, last line) of a SARIF result, or None if it names no line."""
168
+ location = ((result.get("locations") or [{}])[0]).get("physicalLocation") or {}
169
+ uri = (location.get("artifactLocation") or {}).get("uri", "")
170
+ region = location.get("region") or {}
171
+ start = region.get("startLine")
172
+ if not isinstance(start, int):
173
+ return None
174
+ end = region.get("endLine")
175
+ return uri, start, end if isinstance(end, int) else start
176
+
177
+
178
+ def safe_relative_path(filename: str) -> str | None:
179
+ """The file's repo path, or None if it could escape the scan directory."""
180
+ normalized = os.path.normpath(filename.replace("\\", "/"))
181
+ if os.path.isabs(normalized) or normalized in (".", "..") or normalized.startswith(".." + os.sep):
182
+ return None
183
+ return normalized
184
+
185
+
186
+ def sarif_path(uri: str, root: str | None) -> str:
187
+ path = unquote(uri[len("file://"):] if uri.startswith("file://") else uri)
188
+ if os.path.isabs(path) and root:
189
+ path = os.path.relpath(os.path.realpath(path), os.path.realpath(root))
190
+ return os.path.normpath(path)
@@ -0,0 +1,22 @@
1
+ """Finding and preparing the external scanners (semgrep, pip-audit)."""
2
+ import os
3
+ import shutil
4
+ import sys
5
+
6
+ # credentials that must never reach tools which process untrusted PR content
7
+ SENSITIVE_ENV_VARS = {"GITHUB_TOKEN", "GH_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"}
8
+
9
+
10
+ def find_tool(name: str) -> str | None:
11
+ """Look next to the running interpreter first, so tools installed in the same virtualenv
12
+ are found even when that virtualenv isn't activated, then on PATH."""
13
+ search_path = os.pathsep.join([os.path.dirname(sys.executable), os.environ.get("PATH", "")])
14
+ return shutil.which(name, path=search_path)
15
+
16
+
17
+ def scrubbed_env() -> dict[str, str]:
18
+ return {
19
+ key: value
20
+ for key, value in os.environ.items()
21
+ if key not in SENSITIVE_ENV_VARS and not key.endswith("_API_KEY")
22
+ }
@@ -0,0 +1,52 @@
1
+ """Inline suppression of findings.
2
+
3
+ Every finding on a line whose text contains `pr-analyzer: ignore` (usually in a comment) is
4
+ dropped, whichever check reported it. The pipeline names the ignored lines in a warning, so a
5
+ reviewer can see what a PR chose to silence:
6
+
7
+ subprocess.run(cmd, shell=True) # pr-analyzer: ignore - cmd is a fixed string
8
+ """
9
+ import re
10
+ from collections.abc import Iterable
11
+
12
+ from pr_analyzer.diff import iter_new_lines
13
+ from pr_analyzer.models import FileChange, Suggestion
14
+
15
+ IGNORE_MARKER = re.compile(r"\bpr-analyzer:\s*ignore\b")
16
+ # ignored lines named in the warning; any beyond this are only counted
17
+ MAX_LISTED_IGNORED = 10
18
+
19
+
20
+ def marked_lines(file: FileChange) -> set[int]:
21
+ """Line numbers in the new version of the file that carry the marker."""
22
+ numbered: Iterable[tuple[int, str]]
23
+ if file.raw_content is not None:
24
+ # split on "\n" only, so the numbering matches the diff's
25
+ numbered = enumerate(file.raw_content.split("\n"), start=1)
26
+ else:
27
+ # the full content isn't always available; the patch still holds every added line
28
+ numbered = ((line_no, text) for line_no, text, _ in iter_new_lines(file.patch or ""))
29
+ return {line_no for line_no, text in numbered if IGNORE_MARKER.search(text)}
30
+
31
+
32
+ def split_ignored(
33
+ suggestions: list[Suggestion], files: list[FileChange]
34
+ ) -> tuple[list[Suggestion], list[Suggestion]]:
35
+ """Return (kept, ignored)."""
36
+ marked = {f.filename: marked_lines(f) for f in files}
37
+ kept: list[Suggestion] = []
38
+ ignored: list[Suggestion] = []
39
+ for s in suggestions:
40
+ if s.line is not None and s.line in marked.get(s.filename, set()):
41
+ ignored.append(s)
42
+ else:
43
+ kept.append(s)
44
+ return kept, ignored
45
+
46
+
47
+ def ignored_warning(ignored: list[Suggestion]) -> str:
48
+ locations = sorted({(s.filename, s.line or 0) for s in ignored})
49
+ listed = ", ".join(f"{name}:{line}" for name, line in locations[:MAX_LISTED_IGNORED])
50
+ if len(locations) > MAX_LISTED_IGNORED:
51
+ listed += f" and {len(locations) - MAX_LISTED_IGNORED} more"
52
+ return f"Ignored {len(ignored)} finding(s) on lines marked 'pr-analyzer: ignore': {listed}"