Skip to content

Commit 15dc018

Browse files
SNOW-2401514 add CVE scanner with daily checks and security ticket automation (#2652)
* SNOW-2402092: Add daily CVE scanner with automated JIRA ticket management - Implements daily Snyk CVE scanning on production dependencies - Creates one JIRA ticket per unique CVE under SNOW-2402092 - Automatically reopens closed tickets if CVE reappears - Uses unique labels per CVE to prevent duplicate ticket creation - Runs daily at 8 AM UTC with manual trigger support - Includes proper JIRA comment formatting for status updates * Make python script, not bash
1 parent 255477d commit 15dc018

File tree

3 files changed

+561
-0
lines changed

3 files changed

+561
-0
lines changed
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
#!/usr/bin/env python3
2+
"""
3+
This script reads CVEs from JSON and creates JIRA tickets.
4+
It mimics what the gajira-create action does but in a loop.
5+
"""
6+
7+
import argparse
8+
import json
9+
import os
10+
import sys
11+
from typing import Any, Dict, Optional, Union
12+
13+
import requests
14+
15+
16+
class JiraClient:
17+
"""Client for interacting with JIRA REST API."""
18+
19+
def __init__(self, base_url: str, email: str, api_token: str):
20+
self.base_url = base_url.rstrip("/")
21+
self.auth = (email, api_token)
22+
self.session = requests.Session()
23+
self.session.auth = self.auth
24+
self.session.headers.update({"Content-Type": "application/json"})
25+
26+
def search_existing_ticket(self, cve_id: str, parent_key: str) -> Optional[str]:
27+
"""Search for existing JIRA ticket by CVE ID and parent key."""
28+
# Convert CVE ID to lowercase for label search
29+
cve_label = cve_id.lower()
30+
31+
# Build JQL query - search by label (don't quote labels in JQL)
32+
jql = f"project = SNOW AND parent = {parent_key} AND labels = {cve_label} AND labels = automated"
33+
34+
# Search for existing ticket using v3 API
35+
# Note: requests library will automatically URL-encode the params
36+
url = f"{self.base_url}/rest/api/3/search/jql"
37+
params: Dict[str, Union[str, int]] = {"jql": jql, "maxResults": 1}
38+
39+
try:
40+
response = self.session.get(url, params=params) # type: ignore[arg-type]
41+
if response.status_code == 200:
42+
data = response.json()
43+
issues = data.get("issues", [])
44+
if len(issues) > 0:
45+
issue_key = issues[0].get("key")
46+
if issue_key:
47+
return issue_key
48+
# If key not in response, fetch it using the ID
49+
issue_id = issues[0].get("id")
50+
if issue_id:
51+
key_response = self.session.get(
52+
f"{self.base_url}/rest/api/2/issue/{issue_id}",
53+
params={"fields": "key"},
54+
)
55+
return key_response.json().get("key")
56+
else:
57+
print(f" ⚠️ Search failed: {response.text}", file=sys.stderr)
58+
except Exception as e:
59+
print(f" ⚠️ Search error: {e}", file=sys.stderr)
60+
61+
return None
62+
63+
def get_issue_status(self, issue_key: str) -> Optional[str]:
64+
"""Get the status of a JIRA issue."""
65+
try:
66+
url = f"{self.base_url}/rest/api/2/issue/{issue_key}"
67+
params = {"fields": "status"}
68+
response = self.session.get(url, params=params)
69+
if response.status_code == 200:
70+
return response.json()["fields"]["status"]["name"]
71+
except Exception as e:
72+
print(f" ⚠️ Error getting status: {e}", file=sys.stderr)
73+
return None
74+
75+
def reopen_issue(self, issue_key: str) -> bool:
76+
"""Reopen a closed JIRA issue."""
77+
try:
78+
# Get available transitions
79+
url = f"{self.base_url}/rest/api/2/issue/{issue_key}/transitions"
80+
response = self.session.get(url)
81+
if response.status_code != 200:
82+
return False
83+
84+
transitions = response.json().get("transitions", [])
85+
86+
# Find transition to reopen (TODO, Open, Reopened, etc.)
87+
reopen_transition = None
88+
for trans in transitions:
89+
name = trans.get("name", "")
90+
if any(
91+
keyword in name.lower() for keyword in ["todo", "open", "reopen"]
92+
):
93+
reopen_transition = trans.get("id")
94+
break
95+
96+
if reopen_transition:
97+
payload = {"transition": {"id": reopen_transition}}
98+
response = self.session.post(url, json=payload)
99+
return response.status_code in [200, 204]
100+
else:
101+
print(" ⚠️ Could not find transition to reopen")
102+
return False
103+
except Exception as e:
104+
print(f" ⚠️ Error reopening issue: {e}", file=sys.stderr)
105+
return False
106+
107+
def add_comment(self, issue_key: str, comment: str) -> bool:
108+
"""Add a comment to a JIRA issue."""
109+
try:
110+
url = f"{self.base_url}/rest/api/2/issue/{issue_key}/comment"
111+
payload = {"body": comment}
112+
response = self.session.post(url, json=payload)
113+
return response.status_code in [200, 201]
114+
except Exception as e:
115+
print(f" ⚠️ Error adding comment: {e}", file=sys.stderr)
116+
return False
117+
118+
def create_issue(self, issue_data: Dict[str, Any]) -> Optional[str]:
119+
"""Create a new JIRA issue."""
120+
try:
121+
url = f"{self.base_url}/rest/api/2/issue"
122+
response = self.session.post(url, json=issue_data)
123+
if response.status_code == 201:
124+
return response.json().get("key")
125+
else:
126+
print(f"❌ Failed to create ticket (HTTP {response.status_code})")
127+
try:
128+
print(f"Response: {json.dumps(response.json(), indent=2)}")
129+
except:
130+
print(f"Response: {response.text}")
131+
return None
132+
except Exception as e:
133+
print(f"❌ Error creating issue: {e}", file=sys.stderr)
134+
return None
135+
136+
137+
def process_cves(cves_file: str, parent_key: str, workflow_url: str):
138+
"""Process CVEs and create/update JIRA tickets."""
139+
140+
# Check if CVE file exists
141+
if not os.path.exists(cves_file):
142+
print(f"❌ CVE file not found: {cves_file}")
143+
sys.exit(1)
144+
145+
# Load CVEs from file
146+
with open(cves_file, "r") as f:
147+
cves = json.load(f)
148+
149+
cve_count = len(cves)
150+
print(f"📋 Processing {cve_count} CVE(s) from {cves_file}")
151+
152+
# Get JIRA credentials from environment
153+
jira_base_url = os.environ.get("JIRA_BASE_URL")
154+
jira_user_email = os.environ.get("JIRA_USER_EMAIL")
155+
jira_api_token = os.environ.get("JIRA_API_TOKEN")
156+
157+
if not all([jira_base_url, jira_user_email, jira_api_token]):
158+
print(
159+
"❌ Missing required environment variables: JIRA_BASE_URL, JIRA_USER_EMAIL, JIRA_API_TOKEN"
160+
)
161+
sys.exit(1)
162+
163+
# Initialize JIRA client (assert to satisfy type checker - we've validated above)
164+
assert jira_base_url is not None
165+
assert jira_user_email is not None
166+
assert jira_api_token is not None
167+
jira = JiraClient(jira_base_url, jira_user_email, jira_api_token)
168+
169+
created = 0
170+
updated = 0
171+
failed = 0
172+
173+
# Process each CVE
174+
for i, cve in enumerate(cves):
175+
cve_id = cve.get("cve_id")
176+
title = cve.get("title")
177+
severity = cve.get("severity")
178+
package = cve.get("package")
179+
description = cve.get("description")
180+
181+
print()
182+
print(f"🔒 Processing CVE {i+1}/{cve_count}: {cve_id}")
183+
184+
# Check for existing ticket
185+
print(" 🔍 Searching for existing ticket...")
186+
existing_key = jira.search_existing_ticket(cve_id, parent_key)
187+
188+
if existing_key:
189+
print(f" 📌 Found existing ticket: {existing_key}")
190+
191+
# Get ticket status
192+
status = jira.get_issue_status(existing_key)
193+
if status:
194+
print(f" 📊 Current status: {status}")
195+
196+
# Reopen if closed
197+
if any(
198+
keyword in status.lower()
199+
for keyword in ["done", "closed", "resolved"]
200+
):
201+
print(" 🔓 Reopening closed ticket...")
202+
if jira.reopen_issue(existing_key):
203+
print(" ✅ Ticket reopened")
204+
else:
205+
print(" ⚠️ Failed to reopen ticket")
206+
207+
# Add comment
208+
print(" 💬 Adding comment about CVE still present...")
209+
comment = f"""🔄 CVE Still Present in Latest Scan
210+
211+
CVE ID: {cve_id}
212+
Workflow Run: {workflow_url}
213+
214+
This vulnerability is still present in the latest dependency scan. Please prioritize remediation."""
215+
216+
if jira.add_comment(existing_key, comment):
217+
print(" ✅ Comment added")
218+
else:
219+
print(" ⚠️ Failed to add comment")
220+
221+
updated += 1
222+
continue
223+
224+
print(" ✨ No existing ticket found, creating new one...")
225+
226+
# Create JIRA ticket summary
227+
summary = f"🔒 {cve_id}: {title}"
228+
229+
# Create JIRA ticket description
230+
issue_desc = f"""**Security Vulnerability Detected**
231+
232+
**CVE ID:** {cve_id}
233+
**Severity:** {severity.upper()}
234+
**Affected Package:** {package}
235+
**Workflow Run:** {workflow_url}
236+
237+
**Description:**
238+
{description}
239+
240+
**Recommended Actions:**
241+
1. Review the CVE details and assess impact
242+
2. Check for available patches or updates
243+
3. Update the affected package to a secure version
244+
4. Re-run CVE scan to verify fix
245+
246+
_This ticket was automatically created by the Daily CVE Check workflow._"""
247+
248+
# Create CVE label (lowercase)
249+
cve_label = cve_id.lower()
250+
251+
# Create JIRA ticket payload
252+
payload = {
253+
"fields": {
254+
"project": {"key": "SNOW"},
255+
"issuetype": {"name": "Bug"},
256+
"summary": summary,
257+
"description": issue_desc,
258+
"parent": {"key": parent_key},
259+
"labels": ["dp-snowcli", "security", "cve", "automated", cve_label],
260+
"components": [{"id": "18653"}],
261+
"customfield_11401": {"id": "14723"},
262+
}
263+
}
264+
265+
# Create the issue
266+
issue_key = jira.create_issue(payload)
267+
if issue_key:
268+
print(f"✅ Created JIRA ticket: {issue_key} for {cve_id}")
269+
created += 1
270+
else:
271+
failed += 1
272+
273+
# Print summary
274+
print()
275+
print("✨ Summary:")
276+
print(f" - Created: {created} new ticket(s)")
277+
print(f" - Updated: {updated} existing ticket(s)")
278+
print(f" - Failed: {failed} ticket(s)")
279+
print(f" - Total: {cve_count} CVE(s)")
280+
281+
if failed > 0:
282+
sys.exit(1)
283+
284+
285+
def main():
286+
parser = argparse.ArgumentParser(
287+
description="Create JIRA tickets from CVE scan results"
288+
)
289+
parser.add_argument(
290+
"cves_file",
291+
nargs="?",
292+
default="cves.json",
293+
help="Path to CVEs JSON file (default: cves.json)",
294+
)
295+
parser.add_argument(
296+
"parent_key",
297+
nargs="?",
298+
default="SNOW-2380150",
299+
help="JIRA parent ticket key (default: SNOW-2380150)",
300+
)
301+
parser.add_argument(
302+
"workflow_url", nargs="?", default="", help="GitHub workflow run URL"
303+
)
304+
305+
args = parser.parse_args()
306+
process_cves(args.cves_file, args.parent_key, args.workflow_url)
307+
308+
309+
if __name__ == "__main__":
310+
main()

0 commit comments

Comments
 (0)