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.
- pr_analyzer/__init__.py +0 -0
- pr_analyzer/analyzer/__init__.py +0 -0
- pr_analyzer/analyzer/ast_analyzer.py +467 -0
- pr_analyzer/analyzer/line_lexer.py +249 -0
- pr_analyzer/analyzer/scorer.py +132 -0
- pr_analyzer/cache.py +373 -0
- pr_analyzer/cli.py +171 -0
- pr_analyzer/config.py +50 -0
- pr_analyzer/diff.py +74 -0
- pr_analyzer/exceptions.py +41 -0
- pr_analyzer/github/__init__.py +0 -0
- pr_analyzer/github/client.py +398 -0
- pr_analyzer/github/markdown.py +199 -0
- pr_analyzer/github/ratelimit.py +277 -0
- pr_analyzer/llm/__init__.py +0 -0
- pr_analyzer/llm/chunking.py +149 -0
- pr_analyzer/llm/engine.py +336 -0
- pr_analyzer/llm/prompts.py +109 -0
- pr_analyzer/llm/review.py +141 -0
- pr_analyzer/logging_config.py +21 -0
- pr_analyzer/models.py +65 -0
- pr_analyzer/pipeline.py +273 -0
- pr_analyzer/project_config.py +266 -0
- pr_analyzer/reporting/__init__.py +0 -0
- pr_analyzer/reporting/rich_reporter.py +244 -0
- pr_analyzer/security/__init__.py +0 -0
- pr_analyzer/security/dep_auditor.py +179 -0
- pr_analyzer/security/scanner.py +272 -0
- pr_analyzer/security/semgrep_runner.py +190 -0
- pr_analyzer/security/tools.py +22 -0
- pr_analyzer/suppression.py +52 -0
- pr_analyzer_cli-0.1.0.dist-info/METADATA +598 -0
- pr_analyzer_cli-0.1.0.dist-info/RECORD +36 -0
- pr_analyzer_cli-0.1.0.dist-info/WHEEL +4 -0
- pr_analyzer_cli-0.1.0.dist-info/entry_points.txt +3 -0
- pr_analyzer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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}"
|