Skip to content

Commit fe4a98a

Browse files
authored
Merge pull request #225 from KennaSecurity/Age-Custom-Field-on-Vulnerabilities
Age custom field on vulnerabilities
2 parents 41e49bb + b41d0c5 commit fe4a98a

File tree

2 files changed

+233
-0
lines changed

2 files changed

+233
-0
lines changed

Diff for: Age Custom Field on Vulnerabilities/README.md

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Add a Custom Field for Age on vulnerabilities
2+
3+
## Introduction
4+
Some customers may want to see the number of days since a vulnerability was first found. This script calculates the number of days a vulnerability has been open at the point of script execution and categorizes the calculated duration into pre-configured date ranges. This README also provides guidance on how the custom field can be configured so that the date ranges can be shown as vulnerability filters for easy filtering and possibly grouping within the CVM platform.
5+
6+
## Usage
7+
python age_custom_field.py
8+
9+
## Updates/Edits needed to execute the script
10+
11+
### 1: Create Custom Field
12+
Create two custom fields in Cisco Vulnerability Management:
13+
1) Create a *Numeric* custom field to populate the exact # of days . This field is named as *vuln_age* in the script.
14+
2) Create a *String (long)* custom field to populate the range of days . This field is named as *vuln_age_range* in the script.
15+
16+
*Note: It is recommended to select faceted search option for second custom field that is the range of days, to see range options under the vulnerability filters.
17+
If faceted search is enabled for first custom field then it will be cumbersome to scroll through all the individual vuln ages that show up.*
18+
19+
### 2: Update the base_url
20+
By default, https://api.kennasecurity.com/ is being used on line #18. Update it to w.r.t your environment.
21+
22+
### 3: API Key Token
23+
Set an environment variable named API_KEY with your actual API key as its value. The way you do this can vary depending on your operating system and the interface you're using (command line, graphical interface, etc.).
24+
#### Windows:
25+
You can set an environment variable in Windows using the setx command in the command prompt:
26+
*setx API_KEY "your-api-key"*
27+
28+
#### Mac OS or Linux:
29+
In macOS or Linux, you can set an environment variable in the terminal using the export command:
30+
*export API_KEY=your-api-key*
31+
32+
### 4: Custom Field ID
33+
Update *vuln_age* on line #19 and *vuln_age_range* on Line #20 in the code, with custom field id numbers from your environment as created in step #1 above.
34+
35+
### 5: Wait time for Export
36+
By default the script waits for maximum time of 120 minutes to get the export from the customer's environment, in case your export is big and needs more time,
37+
please update the *max_wait_time=7200* on Line #53 (in seconds) to accomodate your export.
38+
Note: The scipt was tested with 1200 seconds (20 minutes) with record count of ~2M and it executed successfully.
39+
40+
### 6: Date Range for Reporting
41+
By default the script has the following ranges listed below, but these can be edited & customized to meet customer's reporting requirement.
42+
43+
"<= 30 days"
44+
45+
"31 - 60 days"
46+
47+
"61 - 90 days"
48+
49+
"91 - 180 days"
50+
51+
"> 180 days"
52+
53+
## Requirements
54+
* python
55+
* json
56+
* csv
+177
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import requests
2+
import csv
3+
import time
4+
import json
5+
import gzip
6+
import io
7+
import os
8+
from collections import defaultdict
9+
import sys
10+
from datetime import datetime
11+
from tqdm import tqdm
12+
import logging
13+
from dateutil import parser
14+
import pytz
15+
16+
# Configuration
17+
token_variable = os.environ.get('API_KEY')
18+
base_url = "https://api.kennasecurity.com"
19+
vuln_age = 37 # replace 37 with the custom field id for age from your environment
20+
vuln_age_range = 38 # replace 38 with the custom field id for range from your environment
21+
thresh_num = 25000 # Threshold for how many IDs you want to send in each request. Max possible is 30k as per API docs
22+
batch_size = 25000 # Number of vulnerabilities to process in each batch
23+
24+
# Setup logging to a file
25+
logging.basicConfig(filename='script_log.txt', level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
26+
27+
def request_data_export(token_variable):
28+
"""Send a request to the Kenna API to start a data export and return the search ID."""
29+
url = f"{base_url}/data_exports"
30+
headers = {
31+
'X-Risk-Token': token_variable,
32+
'accept': 'application/json',
33+
'content-type': 'application/json'
34+
}
35+
data = {
36+
"export_settings": {
37+
"format": "json",
38+
"model": "vulnerability",
39+
"slim": False,
40+
},
41+
"status": [
42+
"open",
43+
"risk accepted",
44+
"false positive"
45+
]
46+
}
47+
response = requests.post(url, headers=headers, json=data)
48+
if response.status_code == 200:
49+
return response.json()['search_id']
50+
else:
51+
logging.error(f"Failed to send POST request. Status Code: {response.status_code}. Response Text: {response.text}")
52+
return None
53+
54+
def wait_for_data_export(search_id, token_variable, max_wait_time=7200, sleep_time=10):
55+
"""Poll the Kenna API to check if the data export is ready and download the data once it is available."""
56+
start_time = time.time()
57+
status_url = f"{base_url}/data_exports/status?search_id={search_id}"
58+
headers = {
59+
'X-Risk-Token': token_variable,
60+
'accept': 'application/json'
61+
}
62+
while True:
63+
status_response = requests.get(status_url, headers=headers)
64+
if status_response.status_code == 200 and status_response.json().get('message') == "Export ready for download":
65+
url = f"{base_url}/data_exports?search_id={search_id}"
66+
headers = {
67+
'X-Risk-Token': token_variable,
68+
'accept': 'application/gzip'
69+
}
70+
response = requests.get(url, headers=headers)
71+
if response.status_code == 200:
72+
decompressed_file = gzip.GzipFile(fileobj=io.BytesIO(response.content))
73+
data = json.load(decompressed_file)
74+
return data
75+
else:
76+
logging.error(f"Failed to fetch data. Status Code: {response.status_code}. Response Text: {response.text}")
77+
return None
78+
elif time.time() - start_time > max_wait_time:
79+
logging.error(f"Timed out after waiting for {max_wait_time} seconds.")
80+
return None
81+
else:
82+
logging.info(f"Data export is still in progress. Waiting for {sleep_time} seconds before trying again.")
83+
print(f"Data export is still in progress. Waiting for {sleep_time} seconds before trying again.")
84+
time.sleep(sleep_time)
85+
86+
def send_bulk_updates(vulns, vuln_age, vuln_age_range, token_variable):
87+
"""Send bulk updates to the Kenna API to update vulnerabilities with custom fields."""
88+
url = f"{base_url}/vulnerabilities/bulk"
89+
headers = {
90+
'X-Risk-Token': token_variable,
91+
'accept': 'application/json',
92+
'content-type': 'application/json'
93+
}
94+
with tqdm(total=len(vulns), desc="Sending bulk updates", unit="vuln") as pbar:
95+
for vuln in vulns:
96+
payload = {
97+
"vulnerability_ids": [vuln['id']],
98+
"vulnerability": {
99+
"custom_fields": {
100+
str(vuln_age): vuln['age_value'],
101+
str(vuln_age_range): vuln['range_value']
102+
}
103+
}
104+
}
105+
logging.info(f"Sending payload for vulnerability ID {vuln['id']}: {json.dumps(payload)}")
106+
response = requests.put(url, headers=headers, json=payload)
107+
if response.status_code == 200:
108+
logging.info(f"Successfully updated vulnerability ID {vuln['id']}")
109+
else:
110+
logging.error(f"Failed to update vulnerability ID {vuln['id']}. Response Status Code: {response.status_code}. Response Text: {response.text}")
111+
pbar.update(1)
112+
113+
def calculate_age_in_days(first_found_on):
114+
"""Calculate the age of a vulnerability in days based on the first found date."""
115+
try:
116+
# Parse the first found date
117+
first_found_date = parser.isoparse(first_found_on)
118+
logging.info(f"Parsed first found date: {first_found_date}")
119+
# Get the current date in UTC and make it timezone-aware
120+
today = datetime.utcnow().replace(tzinfo=pytz.UTC)
121+
logging.info(f"Current date (UTC): {today}")
122+
# Calculate the age in days
123+
age_in_days = (today - first_found_date).days
124+
logging.info(f"First found date: {first_found_date}, Today: {today}, Age in days: {age_in_days}")
125+
return age_in_days
126+
except Exception as e:
127+
logging.error(f"Error calculating age in days: {e}")
128+
return None
129+
130+
def determine_range(age_in_days):
131+
"""Determine the age range category for a vulnerability based on its age in days."""
132+
if age_in_days is None:
133+
return "Unknown"
134+
if age_in_days <= 30:
135+
return "<= 30 days"
136+
elif 30 < age_in_days <= 60:
137+
return "31 - 60 days"
138+
elif 60 < age_in_days <= 90:
139+
return "61 - 90 days"
140+
elif 90 < age_in_days <= 180:
141+
return "91 - 180 days"
142+
else:
143+
return "> 180 days"
144+
145+
def main():
146+
"""Main function to orchestrate the entire process."""
147+
search_id = request_data_export(token_variable)
148+
if not search_id:
149+
sys.exit(1)
150+
151+
vulns_data = wait_for_data_export(search_id, token_variable)
152+
if not vulns_data:
153+
sys.exit(1)
154+
155+
# Process vulnerabilities and calculate age in days
156+
total_vulns = len(vulns_data['vulnerabilities'])
157+
vulns_to_update = []
158+
with tqdm(total=total_vulns, desc="Processing vulnerabilities", unit="vuln") as pbar:
159+
for vuln in vulns_data['vulnerabilities']:
160+
if 'first_found_on' in vuln:
161+
age_value = calculate_age_in_days(vuln['first_found_on'])
162+
range_value = determine_range(age_value)
163+
vulns_to_update.append({
164+
'id': vuln['id'],
165+
'age_value': age_value,
166+
'range_value': range_value
167+
})
168+
if len(vulns_to_update) >= batch_size:
169+
send_bulk_updates(vulns_to_update, vuln_age, vuln_age_range, token_variable)
170+
vulns_to_update = []
171+
pbar.update(1)
172+
# Send remaining vulnerabilities
173+
if vulns_to_update:
174+
send_bulk_updates(vulns_to_update, vuln_age, vuln_age_range, token_variable)
175+
176+
if __name__ == "__main__":
177+
main()

0 commit comments

Comments
 (0)