Spaces:
Sleeping
Sleeping
File size: 11,569 Bytes
de73cb8 411112a de73cb8 fa772d5 de73cb8 bede70e de73cb8 00573bb de73cb8 af29c91 09136a3 014da88 af29c91 7f5a532 014da88 bede70e af29c91 411112a bede70e 411112a 014da88 411112a de73cb8 411112a af29c91 bede70e 09136a3 411112a af29c91 411112a bede70e 09136a3 de73cb8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | import os
import logging
from datetime import datetime, timedelta
import aiofiles
import json
import string
from openai import AsyncOpenAI
import httpx
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# get working directory
current_dir = os.path.dirname(os.path.abspath(__file__))
if "/Users/" in current_dir:
# If running on MacOS, set the report directory to a specific path
REPORT_DIR = "tmp/reports"
else:
REPORT_DIR = "/tmp/reports"
PROMPT_DIR = "app/prompts"
os.makedirs(REPORT_DIR, exist_ok=True)
client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
# Add a helper function to validate CVE-ID
def validate_cve_id(cve_id: str):
parts = cve_id.upper().split("-")
if len(parts) != 3 or not parts[2].isdigit():
return False
return True
def is_safe_path(base_path: str, target_path: str) -> bool:
"""
Validate that the target path is within the base path and does not involve directory traversal.
Args:
base_path (str): The base directory path.
target_path (str): The target file path to validate.
Returns:
bool: True if the target path is safe, False otherwise.
"""
# Resolve absolute paths
base_path = os.path.abspath(base_path)
target_path = os.path.abspath(target_path)
# Check if the target path starts with the base path
return os.path.commonpath([base_path]) == os.path.commonpath([base_path, target_path])
async def get_cve_info(cve_id: str) -> dict | None:
"""
Fetch CVE JSON data from cisagov/vulnrichment repo.
Args:
cve_id (str): The CVE identifier, e.g., "CVE-2025-1234".
Returns:
dict | None: Parsed CVE data or None if not found.
"""
try:
parts = cve_id.upper().split("-")
if len(parts) != 3 or not parts[2].isdigit():
logger.warning(f"Invalid CVE ID format: {cve_id}")
return None
year, cve_num = parts[1], parts[2].lstrip("0")
padded = parts[2].zfill(4)
num_len = len(padded)
if num_len == 4:
group = f"{padded[0]}xxx"
elif num_len == 5:
group = f"{padded[:2]}xxx"
elif num_len == 6:
group = f"{padded[:3]}xxx"
else:
logger.warning(f"Unsupported CVE numeric length: {cve_id}")
return None
url = f"https://raw.githubusercontent.com/cisagov/vulnrichment/develop/{year}/{group}/CVE-{year}-{padded}.json"
logger.info(f"Fetching CVE info from {url}")
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(url)
logger.info(f"Response status code: {resp}")
if resp.status_code == 200:
return resp.json()
elif resp.status_code == 404:
logger.info(f"CVE {cve_id} not found in vulnrichment.")
return None
else:
logger.error(f"Unexpected status {resp.status_code} for {url}")
return None
except Exception as e:
logger.exception(f"Failed to fetch CVE info for {cve_id}: {e}")
return None
def _report_base_path() -> str:
return REPORT_DIR
def _report_path(cve_id: str, suffix=".md") -> str:
return os.path.join(REPORT_DIR, f"{cve_id}{suffix}")
def _prompt_base_path() -> str:
return PROMPT_DIR
def _prompt_path(prompt_name: str, suffix=".md") -> str:
return os.path.join(PROMPT_DIR, f"{prompt_name}{suffix}")
def _lock_path(cve_id: str) -> str:
return os.path.join(REPORT_DIR, f".{cve_id}.lock")
async def report_exists(cve_id: str, suffix: str = ".md") -> bool:
path = _report_path(cve_id, suffix)
if not os.path.exists(path):
return False
mtime = datetime.fromtimestamp(os.path.getmtime(path))
return datetime.now() - mtime < timedelta(days=5)
async def load_report(cve_id: str, suffix: str = ".md") -> str:
path = os.path.join(REPORT_DIR, f"{cve_id}{suffix}")
logger.info(f"Loading report for cve_id={cve_id}, path={path}")
async with aiofiles.open(path, "r") as f:
content = await f.read()
logger.info(f"Report loaded for cve_id={cve_id}, content length={len(content)}")
return content if suffix == ".md" else json.loads(content)
async def write_report(cve_id: str, content, suffix: str = ".md"):
path = os.path.join(REPORT_DIR, f"{cve_id}{suffix}")
try:
async with aiofiles.open(path, "w") as f:
if suffix == ".md":
await f.write(content)
else:
await f.write(json.dumps(content, indent=2))
logger.info(f"✅ Report written: {path}")
except Exception as e:
logger.error(f"❌ Failed to write report to {path}: {e}")
async def get_cve_prompt(cve_id: str) -> str:
template = get_prompt_template("app/prompts/cve_analysis.md")
cve_info_text = ""
cve_info = await get_cve_info(cve_id)
if cve_info:
cve_info_text = json.dumps(cve_info, indent=2)
else:
cve_info_text = f"Could not retrieve data for {cve_id}. Search the web for information: CVSS score, description, exploitability, affected versions and references"
logging.info(f"Retrieved CVE info for {cve_id}: {cve_info_text}")
# if not cve_info:
# raise ValueError(f"Could not retrieve data for {cve_id}")
prompt = template.format(cve_id=cve_id, cve_info=cve_info_text)
return prompt
def get_prompt_template(path: str) -> str:
"""
Reads a prompt from a file and returns it as a string.
Args:
path (str): The file path to read the prompt from.
Returns:
str: The content of the file as a string.
Raises:
FileNotFoundError: If the file does not exist.
IOError: If there is an error reading the file.
"""
try:
with open(path, "r", encoding="utf-8") as file:
return file.read()
except FileNotFoundError:
logger.error(f"Prompt file not found: {path}")
raise FileNotFoundError(f"The specified prompt file at '{path}' was not found. Please ensure the file exists and the path is correct.")
except IOError as e:
logger.error(f"Error reading prompt file {path}: {e}")
raise IOError(f"An error occurred while reading the prompt file at '{path}': {e}")
async def generate_openai_stream(cve_id: str, json_result: bool = False):
logger.info(f"Generating OpenAI stream for cve_id={cve_id}")
editing_directions = "Provide the result as markdown text."
if json_result:
editing_directions = get_prompt_template("app/prompts/cve_analysis_json.md") # No args
prompt = await get_cve_prompt(cve_id)
stream = await client.chat.completions.create(
model = "gpt-4o-search-preview",
web_search_options={
"search_context_size": "low",
},
messages=[
{"role": "system", "content": editing_directions},
{"role": "user", "content": prompt}],
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta.content or ""
if delta:
logger.debug(f"Stream chunk received for cve_id={cve_id}, chunk length={len(delta)}")
yield delta
async def generate_openai_json(cve_id: str):
logger.info(f"Generating OpenAI JSON for cve_id={cve_id}")
prompt = await get_cve_prompt(cve_id)
system_prompt = get_prompt_template("app/prompts/cve_analysis_json.md")
model = "gpt-4o-search-preview"
response = await client.chat.completions.create(
model=model,
web_search_options={
"search_context_size": "low",
},
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
)
text = response.choices[0].message.content
if not text.strip():
logger.error(f"OpenAI returned an empty response for cve_id={cve_id}")
raise ValueError("OpenAI returned an empty response. Unable to generate JSON.")
logger.info(f"OpenAI JSON generated for cve_id={cve_id}, response length={len(text)}")
return text
async def generate_openai_report(request_object: dict, format: str = 'json'):
logger.info(f"Generating report in {format} format")
response_format = { "type": "text" }
system_prompt: str = ''
if request_object['prompt']['source'] == 'name':
template = get_prompt_template(_prompt_path(request_object['prompt']['name']))
if format == 'json':
name, ext = os.path.splitext(_prompt_path(request_object['prompt']['name']))
system_prompt = get_prompt_template(f"{name}_json{ext}")
response_format = { "type": "json_object" }
else:
template = request_object['prompt']['prompt']
prompt = safe_format(template, request_object['prompt']['params'])
response = await client.chat.completions.create(
model = "gpt-4o-search-preview",
web_search_options={
"search_context_size": "low",
},
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
# response_format=response_format, # Not supported with web search
)
return response.choices[0].message.content
async def ghsa_to_cve(ghsa_id: str) -> str:
"""
Convert a GHSA ID to a CVE ID using OpenAI's API.
Args:
ghsa_id (str): The GHSA identifier, e.g., "GHSA-1234-5678-9abc".
Returns:
str: The corresponding CVE ID or an error message.
"""
logger.info(f"Converting GHSA ID {ghsa_id} to CVE ID")
prompt = f"Convert the following GHSA ID to a CVE ID: {ghsa_id}. Return ONLY the CVE ID (or None if not found)."
model = "gpt-4o-search-preview"
response = await client.chat.completions.create(
model=model,
web_search_options={
"search_context_size": "low",
},
messages=[
{"role": "user", "content": prompt}
],
)
# Log input and output token usage
logger.info(f"Input tokens: {response.usage.prompt_tokens}, Output tokens: {response.usage.completion_tokens}")
cve_id = response.choices[0].message.content
if not cve_id.strip():
logger.error(f"OpenAI returned an empty response for cve_id={cve_id}")
raise ValueError("OpenAI returned an empty response. Unable to generate JSON.")
cve_id = cve_id.rstrip() # Remove any leading/trailing whitespace
if validate_cve_id(cve_id): # Validate CVE-ID
logger.info(f"OpenAI converted GHSA {ghsa_id} to CVE ID {cve_id}")
return cve_id
else:
logger.error(f"OpenAI returned an invalid CVE ID: {cve_id}")
raise ValueError(f"OpenAI returned an invalid CVE ID: {cve_id}")
def safe_format(template: str, params: dict) -> str:
# Filling a prompt string with parameters
logger.info(f"Filling prompt string: {template} with parameters: {params}")
formatter = string.Formatter()
try:
variables = [field_name for _, field_name, _, _ in formatter.parse(template) if field_name]
except ValueError:
raise ValueError(f'Invalid template in prompt_prompt')
if not variables:
return template
missing = [var for var in variables if var not in params]
if missing:
raise KeyError(f'Missing keys for template: {missing}')
filled_template = template.format_map(params)
logger.info(f"Filled prompt string with the parameters: {filled_template}")
return filled_template
|