inconnu 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.
- inconnu-0.1.0/.bitbucket/scripts/auto_fix.py +657 -0
- inconnu-0.1.0/.github/workflows/publish.yml +117 -0
- inconnu-0.1.0/.gitignore +16 -0
- inconnu-0.1.0/.python-version +1 -0
- inconnu-0.1.0/LICENSE +21 -0
- inconnu-0.1.0/MANIFEST.in +16 -0
- inconnu-0.1.0/Makefile +39 -0
- inconnu-0.1.0/PKG-INFO +524 -0
- inconnu-0.1.0/README.md +488 -0
- inconnu-0.1.0/bitbucket-pipelines.yml +54 -0
- inconnu-0.1.0/examples.ipynb +203 -0
- inconnu-0.1.0/inconnu/__init__.py +235 -0
- inconnu-0.1.0/inconnu/config.py +7 -0
- inconnu-0.1.0/inconnu/exceptions.py +48 -0
- inconnu-0.1.0/inconnu/model_installer.py +200 -0
- inconnu-0.1.0/inconnu/nlp/entity_redactor.py +229 -0
- inconnu-0.1.0/inconnu/nlp/interfaces.py +23 -0
- inconnu-0.1.0/inconnu/nlp/patterns.py +144 -0
- inconnu-0.1.0/inconnu/nlp/utils.py +97 -0
- inconnu-0.1.0/issues.json +234 -0
- inconnu-0.1.0/pyproject.toml +67 -0
- inconnu-0.1.0/tests/conftest.py +33 -0
- inconnu-0.1.0/tests/mocks/de_prompt.txt +91 -0
- inconnu-0.1.0/tests/mocks/en_prompt.txt +91 -0
- inconnu-0.1.0/tests/mocks/it_prompt.txt +91 -0
- inconnu-0.1.0/tests/test_inconnu.py +386 -0
- inconnu-0.1.0/uv.lock +1785 -0
@@ -0,0 +1,657 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
# ruff: noqa: S603
|
3
|
+
"""
|
4
|
+
Create tasks on a Bitbucket pull request based on Corgea security scan results.
|
5
|
+
|
6
|
+
Usage (inside Bitbucket Pipelines step):
|
7
|
+
python .bitbucket/scripts/auto_fix.py corgea_issues/
|
8
|
+
|
9
|
+
Environment variables used (all provided by Pipelines):
|
10
|
+
BITBUCKET_PR_ID – current PR id (empty when not in PR context)
|
11
|
+
BITBUCKET_REPO_SLUG – repo slug, e.g. "inconnu"
|
12
|
+
BITBUCKET_WORKSPACE – workspace id, e.g. "0xjgv"
|
13
|
+
BITBUCKET_ACCESS_TOKEN – access token for authentication
|
14
|
+
|
15
|
+
The script will create tasks on the PR for security issues found:
|
16
|
+
- Individual detailed tasks for each security issue with fix suggestions
|
17
|
+
- A summary task with an overview of all security issues found
|
18
|
+
"""
|
19
|
+
|
20
|
+
import json
|
21
|
+
import os
|
22
|
+
import pathlib
|
23
|
+
import sys
|
24
|
+
|
25
|
+
import requests
|
26
|
+
|
27
|
+
__all__ = ["main"]
|
28
|
+
|
29
|
+
BB_API_ROOT = "https://api.bitbucket.org/2.0"
|
30
|
+
|
31
|
+
|
32
|
+
class ConfigError(RuntimeError):
|
33
|
+
"""Raised when required environment variables are missing."""
|
34
|
+
|
35
|
+
|
36
|
+
def _env(key: str) -> str:
|
37
|
+
try:
|
38
|
+
return os.environ[key]
|
39
|
+
except KeyError as exc:
|
40
|
+
raise ConfigError(f"Missing required env-var: {key}") from exc
|
41
|
+
|
42
|
+
|
43
|
+
def _create_summary_task(pr_id: str, message: str) -> bool:
|
44
|
+
"""Create a summary task on a pull request for all security issues.
|
45
|
+
|
46
|
+
Returns True if successful, False otherwise.
|
47
|
+
"""
|
48
|
+
access_token = os.environ.get("BITBUCKET_ACCESS_TOKEN")
|
49
|
+
if not access_token:
|
50
|
+
sys.stderr.write(
|
51
|
+
"Error: BITBUCKET_ACCESS_TOKEN environment variable not set.\n"
|
52
|
+
)
|
53
|
+
return False
|
54
|
+
|
55
|
+
url = (
|
56
|
+
f"{BB_API_ROOT}/repositories/"
|
57
|
+
f"{_env('BITBUCKET_WORKSPACE')}/"
|
58
|
+
f"{_env('BITBUCKET_REPO_SLUG')}/pullrequests/{pr_id}/tasks"
|
59
|
+
)
|
60
|
+
|
61
|
+
payload = {"content": {"raw": message, "markup": "markdown"}, "pending": True}
|
62
|
+
|
63
|
+
headers = {
|
64
|
+
"Authorization": f"Bearer {access_token}",
|
65
|
+
"Content-Type": "application/json",
|
66
|
+
}
|
67
|
+
|
68
|
+
try:
|
69
|
+
resp = requests.post(url, json=payload, headers=headers, timeout=10)
|
70
|
+
resp.raise_for_status()
|
71
|
+
return True
|
72
|
+
except requests.HTTPError as err:
|
73
|
+
sys.stderr.write(f"Failed to create summary task on PR {pr_id}: {err}\n")
|
74
|
+
if hasattr(resp, "text"):
|
75
|
+
sys.stderr.write(f"Response: {resp.text}\n")
|
76
|
+
return False
|
77
|
+
except Exception as exc:
|
78
|
+
sys.stderr.write(f"Error creating summary task: {exc}\n")
|
79
|
+
return False
|
80
|
+
|
81
|
+
|
82
|
+
def _create_inline_comment(
|
83
|
+
pr_id: str, content: str, file_path: str, line_number: int
|
84
|
+
) -> int | None:
|
85
|
+
"""Create an inline comment on a pull request.
|
86
|
+
|
87
|
+
Args:
|
88
|
+
pr_id: Pull request ID
|
89
|
+
content: Comment content
|
90
|
+
file_path: File path for the inline comment
|
91
|
+
line_number: Line number for the inline comment
|
92
|
+
|
93
|
+
Returns comment ID if successful, None otherwise.
|
94
|
+
"""
|
95
|
+
access_token = os.environ.get("BITBUCKET_ACCESS_TOKEN")
|
96
|
+
if not access_token:
|
97
|
+
sys.stderr.write(
|
98
|
+
"Error: BITBUCKET_ACCESS_TOKEN environment variable not set.\n"
|
99
|
+
)
|
100
|
+
return None
|
101
|
+
|
102
|
+
url = (
|
103
|
+
f"{BB_API_ROOT}/repositories/"
|
104
|
+
f"{_env('BITBUCKET_WORKSPACE')}/"
|
105
|
+
f"{_env('BITBUCKET_REPO_SLUG')}/pullrequests/{pr_id}/comments"
|
106
|
+
)
|
107
|
+
|
108
|
+
payload = {
|
109
|
+
"content": {"raw": content},
|
110
|
+
"inline": {"from": line_number, "to": line_number, "path": file_path},
|
111
|
+
}
|
112
|
+
|
113
|
+
headers = {
|
114
|
+
"Authorization": f"Bearer {access_token}",
|
115
|
+
"Content-Type": "application/json",
|
116
|
+
}
|
117
|
+
|
118
|
+
try:
|
119
|
+
resp = requests.post(url, json=payload, headers=headers, timeout=10)
|
120
|
+
resp.raise_for_status()
|
121
|
+
comment_data = resp.json()
|
122
|
+
return comment_data.get("id")
|
123
|
+
except requests.HTTPError as err:
|
124
|
+
sys.stderr.write(f"Failed to create inline comment on PR {pr_id}: {err}\n")
|
125
|
+
if hasattr(resp, "text"):
|
126
|
+
sys.stderr.write(f"Response: {resp.text}\n")
|
127
|
+
return None
|
128
|
+
except Exception as exc:
|
129
|
+
sys.stderr.write(f"Error creating inline comment: {exc}\n")
|
130
|
+
return None
|
131
|
+
|
132
|
+
|
133
|
+
def _create_task(pr_id: str, content: str, comment_id: int | None = None) -> bool:
|
134
|
+
"""Create a task on a pull request, optionally linked to a comment.
|
135
|
+
|
136
|
+
Args:
|
137
|
+
pr_id: Pull request ID
|
138
|
+
content: Task content
|
139
|
+
comment_id: Optional comment ID to link the task to
|
140
|
+
|
141
|
+
Returns True if successful, False otherwise.
|
142
|
+
"""
|
143
|
+
access_token = os.environ.get("BITBUCKET_ACCESS_TOKEN")
|
144
|
+
if not access_token:
|
145
|
+
sys.stderr.write(
|
146
|
+
"Error: BITBUCKET_ACCESS_TOKEN environment variable not set.\n"
|
147
|
+
)
|
148
|
+
return False
|
149
|
+
|
150
|
+
url = (
|
151
|
+
f"{BB_API_ROOT}/repositories/"
|
152
|
+
f"{_env('BITBUCKET_WORKSPACE')}/"
|
153
|
+
f"{_env('BITBUCKET_REPO_SLUG')}/pullrequests/{pr_id}/tasks"
|
154
|
+
)
|
155
|
+
|
156
|
+
payload = {"content": {"raw": content, "markup": "markdown"}, "pending": True}
|
157
|
+
|
158
|
+
# Link to comment if provided
|
159
|
+
if comment_id:
|
160
|
+
payload["comment"] = {"id": comment_id}
|
161
|
+
|
162
|
+
headers = {
|
163
|
+
"Authorization": f"Bearer {access_token}",
|
164
|
+
"Content-Type": "application/json",
|
165
|
+
}
|
166
|
+
|
167
|
+
try:
|
168
|
+
resp = requests.post(url, json=payload, headers=headers, timeout=10)
|
169
|
+
resp.raise_for_status()
|
170
|
+
return True
|
171
|
+
except requests.HTTPError as err:
|
172
|
+
sys.stderr.write(f"Failed to create task on PR {pr_id}: {err}\n")
|
173
|
+
if hasattr(resp, "text"):
|
174
|
+
sys.stderr.write(f"Response: {resp.text}\n")
|
175
|
+
return False
|
176
|
+
except Exception as exc:
|
177
|
+
sys.stderr.write(f"Error creating task: {exc}\n")
|
178
|
+
return False
|
179
|
+
|
180
|
+
|
181
|
+
def _create_comment_and_task(pr_id: str, issue: dict) -> bool:
|
182
|
+
"""Create an inline comment and linked task for a security issue.
|
183
|
+
|
184
|
+
Args:
|
185
|
+
pr_id: Pull request ID
|
186
|
+
issue: Dict containing comprehensive issue information
|
187
|
+
|
188
|
+
Returns True if both comment and task created successfully, False otherwise.
|
189
|
+
"""
|
190
|
+
# Step 1: Create inline comment with concise issue details
|
191
|
+
comment_content = _build_inline_comment_content(issue)
|
192
|
+
comment_id = _create_inline_comment(
|
193
|
+
pr_id, comment_content, issue["file"], issue["line"]
|
194
|
+
)
|
195
|
+
|
196
|
+
if not comment_id:
|
197
|
+
# Fallback: create standalone task if comment creation fails
|
198
|
+
sys.stderr.write(
|
199
|
+
f"Failed to create inline comment for issue {issue['issue_id']}, creating standalone task\n"
|
200
|
+
)
|
201
|
+
task_content = _build_task_content(issue)
|
202
|
+
return _create_task(pr_id, task_content)
|
203
|
+
|
204
|
+
# Step 2: Create detailed task linked to the comment
|
205
|
+
task_content = _build_task_content(issue)
|
206
|
+
return _create_task(pr_id, task_content, comment_id)
|
207
|
+
|
208
|
+
|
209
|
+
def _extract_issue_summary(issue: dict) -> dict:
|
210
|
+
"""Extract key information from a security issue.
|
211
|
+
|
212
|
+
Returns a dict with file, line, title, and urgency.
|
213
|
+
"""
|
214
|
+
# Extract location information
|
215
|
+
location = issue.get("location", {})
|
216
|
+
file_info = location.get("file", {})
|
217
|
+
file_path = (
|
218
|
+
file_info.get("path") or location.get("file") or issue.get("file") or "unknown"
|
219
|
+
)
|
220
|
+
line_no = (
|
221
|
+
location.get("line_number")
|
222
|
+
or location.get("line")
|
223
|
+
or location.get("endLine")
|
224
|
+
or 0
|
225
|
+
)
|
226
|
+
|
227
|
+
# Extract classification details
|
228
|
+
classification = issue.get("classification", {})
|
229
|
+
title = (
|
230
|
+
classification.get("name")
|
231
|
+
or issue.get("title")
|
232
|
+
or issue.get("ruleId", "Security Issue")
|
233
|
+
)
|
234
|
+
|
235
|
+
# Extract urgency
|
236
|
+
urgency = issue.get("urgency", "UNKNOWN")
|
237
|
+
|
238
|
+
return {"file": file_path, "line": line_no, "title": title, "urgency": urgency}
|
239
|
+
|
240
|
+
|
241
|
+
def _extract_full_issue(issue: dict) -> dict:
|
242
|
+
"""Extract comprehensive information from a security issue for task creation.
|
243
|
+
|
244
|
+
Returns a dict with all relevant issue information including auto-fix suggestions.
|
245
|
+
"""
|
246
|
+
# Get the basic summary first
|
247
|
+
summary = _extract_issue_summary(issue)
|
248
|
+
|
249
|
+
# Extract additional detailed information
|
250
|
+
classification = issue.get("classification", {})
|
251
|
+
details = issue.get("details", {})
|
252
|
+
auto_fix = issue.get("auto_fix_suggestion", {})
|
253
|
+
location = issue.get("location", {})
|
254
|
+
file_info = location.get("file", {})
|
255
|
+
project_info = location.get("project", {})
|
256
|
+
auto_triage = issue.get("auto_triage", {})
|
257
|
+
false_positive = auto_triage.get("false_positive_detection", {})
|
258
|
+
|
259
|
+
# Build comprehensive issue data
|
260
|
+
full_issue = {
|
261
|
+
**summary,
|
262
|
+
"issue_id": issue.get("id", "unknown"),
|
263
|
+
"scan_id": issue.get("scan_id", ""),
|
264
|
+
"status": issue.get("status", ""),
|
265
|
+
"created_at": issue.get("created_at", ""),
|
266
|
+
"description": classification.get("description", ""),
|
267
|
+
"explanation": details.get("explanation", ""),
|
268
|
+
"cwe_id": classification.get("id", ""),
|
269
|
+
"file_name": file_info.get("name", ""),
|
270
|
+
"file_language": file_info.get("language", ""),
|
271
|
+
"project_name": project_info.get("name", ""),
|
272
|
+
"project_branch": project_info.get("branch", ""),
|
273
|
+
"git_sha": project_info.get("git_sha", ""),
|
274
|
+
"triage_status": false_positive.get("status", ""),
|
275
|
+
"triage_reasoning": false_positive.get("reasoning", ""),
|
276
|
+
"has_auto_fix": auto_fix.get("status") == "fix_available",
|
277
|
+
}
|
278
|
+
|
279
|
+
# Add auto-fix information if available
|
280
|
+
if full_issue["has_auto_fix"]:
|
281
|
+
patch = auto_fix.get("patch", {})
|
282
|
+
full_issue.update(
|
283
|
+
{
|
284
|
+
"fix_id": auto_fix.get("id", ""),
|
285
|
+
"fix_explanation": patch.get("explanation", ""),
|
286
|
+
"fix_diff": patch.get("diff", ""),
|
287
|
+
}
|
288
|
+
)
|
289
|
+
|
290
|
+
return full_issue
|
291
|
+
|
292
|
+
|
293
|
+
def _build_inline_comment_content(issue: dict) -> str:
|
294
|
+
"""Build concise inline comment content for diff view.
|
295
|
+
|
296
|
+
Args:
|
297
|
+
issue: Dict containing comprehensive issue information from _extract_full_issue
|
298
|
+
|
299
|
+
Returns:
|
300
|
+
Formatted inline comment content string
|
301
|
+
"""
|
302
|
+
urgency_labels = {"CR": "🔴", "HI": "🟠", "ME": "🟡", "LO": "🟢"}
|
303
|
+
urgency_emoji = urgency_labels.get(issue["urgency"], "⚪")
|
304
|
+
|
305
|
+
parts = [
|
306
|
+
f"## {urgency_emoji} Security Issue: {issue['title']}",
|
307
|
+
"",
|
308
|
+
f"**{issue['cwe_id']}** - {issue.get('description', 'Security vulnerability detected')}",
|
309
|
+
"",
|
310
|
+
]
|
311
|
+
|
312
|
+
# Add collapsible detailed explanation
|
313
|
+
if issue.get("explanation"):
|
314
|
+
# Clean up HTML for better markdown display
|
315
|
+
explanation = (
|
316
|
+
issue["explanation"].replace("<br><br>", "\n\n").replace("<br>", "\n")
|
317
|
+
)
|
318
|
+
explanation = explanation.replace("<code>", "`").replace("</code>", "`")
|
319
|
+
|
320
|
+
parts.extend(
|
321
|
+
[
|
322
|
+
"---",
|
323
|
+
"",
|
324
|
+
"### 📋 Detailed Explanation",
|
325
|
+
"",
|
326
|
+
explanation,
|
327
|
+
"",
|
328
|
+
]
|
329
|
+
)
|
330
|
+
|
331
|
+
# Add fix if available
|
332
|
+
if (
|
333
|
+
issue.get("has_auto_fix")
|
334
|
+
and issue.get("fix_diff")
|
335
|
+
and issue["fix_diff"].strip()
|
336
|
+
):
|
337
|
+
# Clean up fix explanation HTML
|
338
|
+
fix_explanation = issue.get("fix_explanation", "Auto-fix available")
|
339
|
+
fix_explanation = fix_explanation.replace("<br><br>", "\n\n").replace(
|
340
|
+
"<br>", "\n"
|
341
|
+
)
|
342
|
+
fix_explanation = fix_explanation.replace("<code>", "`").replace("</code>", "`")
|
343
|
+
# Handle list items - add newline before each item except the first
|
344
|
+
fix_explanation = fix_explanation.replace("</li><li>", "\n- ")
|
345
|
+
fix_explanation = fix_explanation.replace("<li>", "- ").replace("</li>", "")
|
346
|
+
fix_explanation = fix_explanation.replace("<ul>", "").replace("</ul>", "")
|
347
|
+
fix_explanation = fix_explanation.replace("<ol>", "").replace("</ol>", "")
|
348
|
+
# Strip leading/trailing whitespace and ensure proper line breaks
|
349
|
+
fix_explanation = fix_explanation.strip()
|
350
|
+
|
351
|
+
parts.extend(
|
352
|
+
[
|
353
|
+
"---",
|
354
|
+
"",
|
355
|
+
"### 🔧 Suggested Fix",
|
356
|
+
"",
|
357
|
+
"**Fix explanation:**",
|
358
|
+
"",
|
359
|
+
fix_explanation,
|
360
|
+
"\n",
|
361
|
+
"**📊 Changes (diff view):**",
|
362
|
+
"```diff",
|
363
|
+
issue["fix_diff"].strip(),
|
364
|
+
"```",
|
365
|
+
"",
|
366
|
+
"**📥 Apply this fix:**",
|
367
|
+
"\n",
|
368
|
+
f"1. [Download patch file](https://www.corgea.app/issue/fix-diff/{issue['issue_id']}?download=true)",
|
369
|
+
"\n",
|
370
|
+
"2. Apply it: `git apply corgea-fix-{}.patch`".format(
|
371
|
+
issue["issue_id"][:8]
|
372
|
+
),
|
373
|
+
"\n",
|
374
|
+
]
|
375
|
+
)
|
376
|
+
|
377
|
+
parts.extend(
|
378
|
+
[f"🔗 **[View on Corgea](https://www.corgea.app/issue/{issue['issue_id']}/)**"]
|
379
|
+
)
|
380
|
+
|
381
|
+
return "\n".join(filter(None, parts))
|
382
|
+
|
383
|
+
|
384
|
+
def _build_task_content(issue: dict) -> str:
|
385
|
+
"""Build task content from a detailed security issue.
|
386
|
+
|
387
|
+
Args:
|
388
|
+
issue: Dict containing comprehensive issue information from _extract_full_issue
|
389
|
+
|
390
|
+
Returns:
|
391
|
+
Formatted task content string
|
392
|
+
"""
|
393
|
+
urgency_labels = {
|
394
|
+
"CR": "🔴 Critical",
|
395
|
+
"HI": "🟠 High",
|
396
|
+
"ME": "🟡 Medium",
|
397
|
+
"LO": "🟢 Low",
|
398
|
+
}
|
399
|
+
urgency_label = urgency_labels.get(issue["urgency"], f"⚪ {issue['urgency']}")
|
400
|
+
|
401
|
+
content_parts = [
|
402
|
+
f"## {urgency_label}: {issue['title']}",
|
403
|
+
"",
|
404
|
+
f"**File:** `{issue['file']}` (line {issue['line']})",
|
405
|
+
f"**CWE:** {issue['cwe_id']}" if issue["cwe_id"] else "",
|
406
|
+
f"**Language:** {issue['file_language']}" if issue.get("file_language") else "",
|
407
|
+
f"**Issue ID:** `{issue['issue_id']}`",
|
408
|
+
f"**Scan ID:** `{issue['scan_id']}`" if issue.get("scan_id") else "",
|
409
|
+
"",
|
410
|
+
]
|
411
|
+
|
412
|
+
# Add project context if available
|
413
|
+
project_info = []
|
414
|
+
if issue.get("project_name"):
|
415
|
+
project_info.append(f"**Project:** {issue['project_name']}")
|
416
|
+
if issue.get("project_branch"):
|
417
|
+
project_info.append(f"**Branch:** {issue['project_branch']}")
|
418
|
+
if issue.get("git_sha"):
|
419
|
+
project_info.append(f"**Commit:** `{issue['git_sha'][:8]}...`")
|
420
|
+
if issue.get("created_at"):
|
421
|
+
project_info.append(f"**Detected:** {issue['created_at']}")
|
422
|
+
|
423
|
+
if project_info:
|
424
|
+
content_parts.extend(project_info)
|
425
|
+
content_parts.append("")
|
426
|
+
|
427
|
+
# Add triage information if available
|
428
|
+
if issue.get("triage_status"):
|
429
|
+
triage_emoji = "✅" if issue["triage_status"] == "valid" else "⚠️"
|
430
|
+
content_parts.extend(
|
431
|
+
[
|
432
|
+
f"### {triage_emoji} Auto-Triage: {issue['triage_status'].title()}",
|
433
|
+
issue.get("triage_reasoning", ""),
|
434
|
+
"",
|
435
|
+
]
|
436
|
+
)
|
437
|
+
|
438
|
+
# Add description if available
|
439
|
+
if issue.get("description"):
|
440
|
+
content_parts.extend(
|
441
|
+
[
|
442
|
+
"### Description",
|
443
|
+
issue["description"],
|
444
|
+
"",
|
445
|
+
]
|
446
|
+
)
|
447
|
+
|
448
|
+
# Add detailed explanation if available
|
449
|
+
if issue.get("explanation"):
|
450
|
+
content_parts.extend(
|
451
|
+
[
|
452
|
+
"### Technical Details",
|
453
|
+
issue["explanation"],
|
454
|
+
"",
|
455
|
+
]
|
456
|
+
)
|
457
|
+
|
458
|
+
# Add auto-fix suggestion if available
|
459
|
+
if issue.get("has_auto_fix"):
|
460
|
+
content_parts.extend(
|
461
|
+
[
|
462
|
+
"### 🔧 Suggested Fix",
|
463
|
+
issue.get("fix_explanation", "Auto-fix available"),
|
464
|
+
"",
|
465
|
+
]
|
466
|
+
)
|
467
|
+
|
468
|
+
if issue.get("fix_id"):
|
469
|
+
content_parts.extend(
|
470
|
+
[
|
471
|
+
f"**Fix ID:** `{issue['fix_id']}`",
|
472
|
+
"",
|
473
|
+
]
|
474
|
+
)
|
475
|
+
|
476
|
+
if issue.get("fix_diff") and issue["fix_diff"].strip():
|
477
|
+
# Use language-specific syntax highlighting if available, otherwise default to diff
|
478
|
+
diff_language = issue.get("file_language", "diff")
|
479
|
+
content_parts.extend(
|
480
|
+
[
|
481
|
+
"### Proposed Changes",
|
482
|
+
f"```{diff_language}",
|
483
|
+
issue["fix_diff"].strip(),
|
484
|
+
"```",
|
485
|
+
"",
|
486
|
+
]
|
487
|
+
)
|
488
|
+
|
489
|
+
content_parts.extend(
|
490
|
+
[
|
491
|
+
"---",
|
492
|
+
"**Action Required:** Review and apply the necessary security fixes for this issue.",
|
493
|
+
"",
|
494
|
+
f"🔗 **[View Full Issue Details on Corgea](https://www.corgea.app/issue/{issue['issue_id']}/)**",
|
495
|
+
"",
|
496
|
+
"*Generated by Corgea Security Scan*",
|
497
|
+
]
|
498
|
+
)
|
499
|
+
|
500
|
+
return "\n".join(filter(None, content_parts))
|
501
|
+
|
502
|
+
|
503
|
+
def _iter_issue_files(dir_path: pathlib.Path):
|
504
|
+
"""Iterate over JSON issue files in the directory."""
|
505
|
+
for json_file in dir_path.glob("*.json"):
|
506
|
+
try:
|
507
|
+
data = json.loads(json_file.read_text())
|
508
|
+
# Handle both wrapped {"issue": {...}} and direct issue format
|
509
|
+
issue = data.get("issue", data) if isinstance(data, dict) else data
|
510
|
+
yield issue
|
511
|
+
except json.JSONDecodeError as exc:
|
512
|
+
sys.stderr.write(f"Skipping invalid JSON {json_file}: {exc}\n")
|
513
|
+
|
514
|
+
|
515
|
+
def _build_summary_task_message(issues: list[dict]) -> str:
|
516
|
+
"""Build a comprehensive summary task message from security issues."""
|
517
|
+
if not issues:
|
518
|
+
return ""
|
519
|
+
|
520
|
+
# Group issues by urgency
|
521
|
+
by_urgency = {}
|
522
|
+
for issue in issues:
|
523
|
+
urgency = issue["urgency"]
|
524
|
+
if urgency not in by_urgency:
|
525
|
+
by_urgency[urgency] = []
|
526
|
+
by_urgency[urgency].append(issue)
|
527
|
+
|
528
|
+
# Build message
|
529
|
+
parts = [
|
530
|
+
"🔒 **Security Issues Found**",
|
531
|
+
"",
|
532
|
+
f"Corgea security scan identified **{len(issues)} security issue(s)** that need to be addressed before this PR can be merged.",
|
533
|
+
"",
|
534
|
+
]
|
535
|
+
|
536
|
+
# Add urgency-based sections
|
537
|
+
urgency_order = ["CR", "HI", "ME", "LO"] # Critical, High, Medium, Low
|
538
|
+
urgency_labels = {"CR": "Critical", "HI": "High", "ME": "Medium", "LO": "Low"}
|
539
|
+
|
540
|
+
for urgency in urgency_order:
|
541
|
+
if urgency in by_urgency:
|
542
|
+
urgency_issues = by_urgency[urgency]
|
543
|
+
urgency_label = urgency_labels.get(urgency, urgency)
|
544
|
+
|
545
|
+
parts.extend(
|
546
|
+
[f"### {urgency_label} Priority ({len(urgency_issues)} issue(s))", ""]
|
547
|
+
)
|
548
|
+
|
549
|
+
for issue in urgency_issues:
|
550
|
+
parts.append(
|
551
|
+
f"- **{issue['title']}** in `{issue['file']}` (line {issue['line']})"
|
552
|
+
)
|
553
|
+
|
554
|
+
parts.append("")
|
555
|
+
|
556
|
+
# Add footer
|
557
|
+
parts.extend(
|
558
|
+
[
|
559
|
+
"---",
|
560
|
+
"**Next Steps:**",
|
561
|
+
"1. Review the security issues identified above",
|
562
|
+
"2. Check the individual tasks created for each issue with detailed fix suggestions",
|
563
|
+
"3. Apply the necessary fixes to your code",
|
564
|
+
"4. Push your changes to update this pull request",
|
565
|
+
"5. The security scan will automatically re-run to verify fixes",
|
566
|
+
"",
|
567
|
+
"*This summary task was automatically generated by Corgea security scanning.*",
|
568
|
+
]
|
569
|
+
)
|
570
|
+
|
571
|
+
return "\n".join(parts)
|
572
|
+
|
573
|
+
|
574
|
+
def main(directory: str = "corgea_issues") -> None: # pragma: no cover
|
575
|
+
pr_id = os.environ.get("BITBUCKET_PR_ID")
|
576
|
+
if not pr_id:
|
577
|
+
sys.stderr.write("No BITBUCKET_PR_ID – not in a PR context, exiting.\n")
|
578
|
+
return
|
579
|
+
|
580
|
+
path = pathlib.Path(directory)
|
581
|
+
if not path.is_dir():
|
582
|
+
raise SystemExit(f"Artifact directory '{directory}' not found")
|
583
|
+
|
584
|
+
# Collect all security issues with full details
|
585
|
+
issues = []
|
586
|
+
full_issues = []
|
587
|
+
for issue_data in _iter_issue_files(path):
|
588
|
+
try:
|
589
|
+
issue_summary = _extract_issue_summary(issue_data)
|
590
|
+
full_issue = _extract_full_issue(issue_data)
|
591
|
+
issues.append(issue_summary)
|
592
|
+
full_issues.append(full_issue)
|
593
|
+
except Exception as exc: # noqa: BLE001
|
594
|
+
sys.stderr.write(f"Error parsing issue: {exc}\n")
|
595
|
+
continue
|
596
|
+
|
597
|
+
print(f"Found {len(issues)} security issue(s) from {directory}")
|
598
|
+
|
599
|
+
if issues:
|
600
|
+
# Create individual inline comments and tasks for each issue
|
601
|
+
task_success_count = 0
|
602
|
+
comment_success_count = 0
|
603
|
+
for full_issue in full_issues:
|
604
|
+
if _create_comment_and_task(pr_id, full_issue):
|
605
|
+
task_success_count += 1
|
606
|
+
# Check if it has valid file/line for inline comment
|
607
|
+
if (
|
608
|
+
full_issue.get("file")
|
609
|
+
and full_issue.get("line")
|
610
|
+
and full_issue["line"] > 0
|
611
|
+
):
|
612
|
+
comment_success_count += 1
|
613
|
+
else:
|
614
|
+
sys.stderr.write(
|
615
|
+
f"Failed to create comment and task for issue {full_issue['issue_id']}\n"
|
616
|
+
)
|
617
|
+
|
618
|
+
if comment_success_count > 0:
|
619
|
+
print(
|
620
|
+
f"✅ Created {comment_success_count} inline comments with linked tasks and {task_success_count - comment_success_count} standalone tasks ({task_success_count}/{len(full_issues)} total)"
|
621
|
+
)
|
622
|
+
else:
|
623
|
+
print(
|
624
|
+
f"✅ Created {task_success_count}/{len(full_issues)} standalone tasks for individual issues"
|
625
|
+
)
|
626
|
+
|
627
|
+
# Security issues found - create summary task
|
628
|
+
message = _build_summary_task_message(issues)
|
629
|
+
if _create_summary_task(pr_id, message):
|
630
|
+
print(
|
631
|
+
f"✅ Created summary task on PR #{pr_id} for {len(issues)} security issue(s)"
|
632
|
+
)
|
633
|
+
|
634
|
+
# Print summary by urgency
|
635
|
+
by_urgency = {}
|
636
|
+
for issue in issues:
|
637
|
+
urgency = issue["urgency"]
|
638
|
+
by_urgency[urgency] = by_urgency.get(urgency, 0) + 1
|
639
|
+
|
640
|
+
print("\nIssue breakdown:")
|
641
|
+
for urgency, count in sorted(by_urgency.items()):
|
642
|
+
print(f" {urgency}: {count}")
|
643
|
+
else:
|
644
|
+
print(f"❌ Failed to create summary task on PR #{pr_id}")
|
645
|
+
sys.exit(1)
|
646
|
+
else:
|
647
|
+
# No security issues found
|
648
|
+
print("✅ No security issues found - no tasks created")
|
649
|
+
|
650
|
+
|
651
|
+
if __name__ == "__main__":
|
652
|
+
dir_arg = sys.argv[1] if len(sys.argv) > 1 else "corgea_issues"
|
653
|
+
try:
|
654
|
+
main(dir_arg)
|
655
|
+
except ConfigError as ce:
|
656
|
+
sys.stderr.write(str(ce) + "\n")
|
657
|
+
sys.exit(1)
|