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