ai / app /utils.py
noir3301
small fixes
fa772d5
Raw History Blame Contribute Delete
11.6 kB
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