codedd-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codedd_cli/__init__.py +3 -0
- codedd_cli/__main__.py +19 -0
- codedd_cli/api/__init__.py +4 -0
- codedd_cli/api/client.py +118 -0
- codedd_cli/api/endpoints.py +44 -0
- codedd_cli/api/exceptions.py +25 -0
- codedd_cli/auditor/__init__.py +6 -0
- codedd_cli/auditor/architecture_analyzer.py +1241 -0
- codedd_cli/auditor/architecture_prompts.py +171 -0
- codedd_cli/auditor/complexity_analyzer.py +942 -0
- codedd_cli/auditor/dependency_scanner.py +2478 -0
- codedd_cli/auditor/file_auditor.py +572 -0
- codedd_cli/auditor/git_stats_collector.py +332 -0
- codedd_cli/auditor/response_parser.py +487 -0
- codedd_cli/auditor/vulnerability_validator.py +324 -0
- codedd_cli/auth/__init__.py +4 -0
- codedd_cli/auth/session.py +41 -0
- codedd_cli/auth/token_manager.py +87 -0
- codedd_cli/cli.py +69 -0
- codedd_cli/commands/__init__.py +1 -0
- codedd_cli/commands/audit_cmd.py +1877 -0
- codedd_cli/commands/audits_cmd.py +273 -0
- codedd_cli/commands/auth_cmd.py +230 -0
- codedd_cli/commands/config_cmd.py +454 -0
- codedd_cli/commands/scope_cmd.py +967 -0
- codedd_cli/config/__init__.py +4 -0
- codedd_cli/config/constants.py +22 -0
- codedd_cli/config/settings.py +369 -0
- codedd_cli/llm/__init__.py +1 -0
- codedd_cli/llm/key_manager.py +271 -0
- codedd_cli/models/__init__.py +5 -0
- codedd_cli/models/account.py +13 -0
- codedd_cli/models/audit.py +32 -0
- codedd_cli/models/local_directory.py +26 -0
- codedd_cli/scanner/__init__.py +18 -0
- codedd_cli/scanner/file_classifier.py +247 -0
- codedd_cli/scanner/file_walker.py +207 -0
- codedd_cli/scanner/line_counter.py +75 -0
- codedd_cli/utils/__init__.py +1 -0
- codedd_cli/utils/directory_validator.py +179 -0
- codedd_cli/utils/display.py +493 -0
- codedd_cli/utils/payload_inspector.py +180 -0
- codedd_cli/utils/security.py +14 -0
- codedd_cli/utils/validators.py +37 -0
- codedd_cli-0.1.0.dist-info/METADATA +276 -0
- codedd_cli-0.1.0.dist-info/RECORD +49 -0
- codedd_cli-0.1.0.dist-info/WHEEL +4 -0
- codedd_cli-0.1.0.dist-info/entry_points.txt +3 -0
- codedd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parse structured LLM audit responses into field dictionaries.
|
|
3
|
+
|
|
4
|
+
This module is the CLI-side equivalent of the server's
|
|
5
|
+
``AI_Auditor.parse_audit_response``. It converts the plain-text form
|
|
6
|
+
filled out by an LLM into a typed Python dict that matches the CodeDD
|
|
7
|
+
TypeDB schema for ``file`` / ``file_analysis`` entities.
|
|
8
|
+
|
|
9
|
+
All parsing logic is deterministic (regex + string splitting) and
|
|
10
|
+
contains no proprietary IP — the intelligence lives in the system prompt
|
|
11
|
+
served by the server at audit time.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Schema mapping: field_name → section prefix in the LLM response
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
SCHEMA_MAPPING: dict[str, str] = {
|
|
26
|
+
"is_script": "0.",
|
|
27
|
+
"is_script_explanation": "0.1.",
|
|
28
|
+
"script_purpose": "1.1.",
|
|
29
|
+
"domain": "1.2.",
|
|
30
|
+
"summary_all": "1.3.",
|
|
31
|
+
"recommendation": "1.4.",
|
|
32
|
+
"tags": "1.5.",
|
|
33
|
+
"readability": "2.1.",
|
|
34
|
+
"consistency": "2.2.",
|
|
35
|
+
"modularity": "2.3.",
|
|
36
|
+
"maintainability": "2.4.",
|
|
37
|
+
"reusability": "2.5.",
|
|
38
|
+
"redundancy": "2.6.",
|
|
39
|
+
"technical_debt": "2.7.",
|
|
40
|
+
"code_smells": "2.8.",
|
|
41
|
+
"summary_code_quality": "2.9.",
|
|
42
|
+
"completeness": "3.1.",
|
|
43
|
+
"edge_cases": "3.2.",
|
|
44
|
+
"error_handling": "3.3.",
|
|
45
|
+
"summary_functionality": "3.4.",
|
|
46
|
+
"efficiency": "4.1.",
|
|
47
|
+
"scalability": "4.2.",
|
|
48
|
+
"resource_utilization": "4.3.",
|
|
49
|
+
"load_handling": "4.4.",
|
|
50
|
+
"parallel_processing": "4.5.",
|
|
51
|
+
"database_interaction_efficiency": "4.6.",
|
|
52
|
+
"concurrency_management": "4.7.",
|
|
53
|
+
"state_management_efficiency": "4.8.",
|
|
54
|
+
"modularity_decoupling": "4.9.",
|
|
55
|
+
"configuration_customization_ease": "4.10.",
|
|
56
|
+
"summary_perf_scal": "4.11.",
|
|
57
|
+
"input_validation": "5.1.",
|
|
58
|
+
"data_handling": "5.2.",
|
|
59
|
+
"authentication": "5.3.",
|
|
60
|
+
"summary_security": "5.4.",
|
|
61
|
+
"independence": "6.1.",
|
|
62
|
+
"integration": "6.2.",
|
|
63
|
+
"summary_compatibility": "6.3.",
|
|
64
|
+
"inline_comments": "7.1.",
|
|
65
|
+
"summary_documentation": "7.2.",
|
|
66
|
+
"standards": "8.1.",
|
|
67
|
+
"design_patterns": "8.2.",
|
|
68
|
+
"code_complexity": "8.3.",
|
|
69
|
+
"refactoring_opportunities": "8.4.",
|
|
70
|
+
"summary_standards": "8.5.",
|
|
71
|
+
"reasons_of_flag": "9.1.",
|
|
72
|
+
"flag_color": "9.2.",
|
|
73
|
+
"time_to_fix_flag": "9.3.",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Pre-compiled regex for section headers (e.g. "1.1.", "9.3.")
|
|
77
|
+
_SECTION_HEADER_RE = re.compile(r"^\d+\.\d+\.")
|
|
78
|
+
|
|
79
|
+
# Allow optional Markdown around section headers (e.g. "**1.1." or "### 1.1.")
|
|
80
|
+
_MARKDOWN_PREFIX_RE = re.compile(r"^\s*(\*{1,2}\s*|#{1,3}\s*)")
|
|
81
|
+
|
|
82
|
+
# Pre-compiled regex to strip prompt-artefact phrases like "(Max 50 words)"
|
|
83
|
+
_PROMPT_ARTIFACT_RE = re.compile(
|
|
84
|
+
r"\s*\([Mm]ax\.?\s*\d+\s*words?\)\s*:?\s*",
|
|
85
|
+
re.IGNORECASE,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Internal helpers
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _normalize_response_text(text: str) -> str:
|
|
94
|
+
"""Remove prompt-artefact phrases from labels so parsing is robust."""
|
|
95
|
+
if not text:
|
|
96
|
+
return text
|
|
97
|
+
normalized = re.sub(
|
|
98
|
+
r"\s+\([Mm]ax\.?\s*\d+\s*words?\)\s*:?\s*",
|
|
99
|
+
": ",
|
|
100
|
+
text,
|
|
101
|
+
)
|
|
102
|
+
normalized = re.sub(
|
|
103
|
+
r"\s+\([Mm]ax\.?\s*\d+\s*words?\s*:\s*\)\s*:\s*",
|
|
104
|
+
": ",
|
|
105
|
+
normalized,
|
|
106
|
+
)
|
|
107
|
+
return normalized
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _normalize_line_for_parsing(line: str) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Strip Markdown formatting from a line so section headers are recognized.
|
|
113
|
+
|
|
114
|
+
LLMs often return the audit form in Markdown, e.g.:
|
|
115
|
+
**1.1. Script Purpose (Max 50 words):**
|
|
116
|
+
This script provides...
|
|
117
|
+
|
|
118
|
+
We need lines to start with the section prefix (e.g. "1.1.") for
|
|
119
|
+
startswith() and _SECTION_HEADER_RE to match. This helper:
|
|
120
|
+
- Strips leading ** or ### (and optional space)
|
|
121
|
+
- Replaces ":**" with ":" so the colon separates label from value
|
|
122
|
+
"""
|
|
123
|
+
s = line.strip()
|
|
124
|
+
if not s:
|
|
125
|
+
return s
|
|
126
|
+
# Strip leading Markdown bold or heading
|
|
127
|
+
s = _MARKDOWN_PREFIX_RE.sub("", s)
|
|
128
|
+
# Turn ":** " or ":**" into ":" so split(":", 1) gives correct value
|
|
129
|
+
s = s.replace(":**", ":", 1)
|
|
130
|
+
return s
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _clean_value_from_prompt_artifacts(value: str) -> str:
|
|
134
|
+
"""Strip prompt-artefact phrases from extracted *values*."""
|
|
135
|
+
if not value:
|
|
136
|
+
return value
|
|
137
|
+
return _PROMPT_ARTIFACT_RE.sub(" ", value).strip()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _sanitize_text(text: str) -> str:
|
|
141
|
+
"""Sanitize an extracted text value (mirrors server-side sanitize_text)."""
|
|
142
|
+
try:
|
|
143
|
+
sanitized = (
|
|
144
|
+
text.replace('"', "")
|
|
145
|
+
.replace("'", "")
|
|
146
|
+
.replace("[", "")
|
|
147
|
+
.replace("]", "")
|
|
148
|
+
.replace("/", "")
|
|
149
|
+
)
|
|
150
|
+
sanitized = sanitized.rstrip(".")
|
|
151
|
+
if sanitized.strip().lower() in ("na", "n/a", "not applicable", "not available"):
|
|
152
|
+
return "N/A"
|
|
153
|
+
return sanitized
|
|
154
|
+
except Exception:
|
|
155
|
+
return text
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _sanitize_domain(text: str) -> str:
|
|
159
|
+
"""Sanitize domain values — keep at most two words, strip quotes."""
|
|
160
|
+
try:
|
|
161
|
+
cleaned = text.replace("(", "").replace(")", "").replace("'", "").replace('"', "").replace(",", "")
|
|
162
|
+
words = cleaned.split()
|
|
163
|
+
return " ".join(words[:2]) if len(words) > 2 else cleaned
|
|
164
|
+
except Exception:
|
|
165
|
+
return text
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parse_time_to_fix(text: str | None) -> float | None:
|
|
169
|
+
"""Convert a raw time-to-fix string into a float (hours) or None."""
|
|
170
|
+
if text is None or str(text).strip().lower() in (
|
|
171
|
+
"na", "n/a", "not applicable", "not available", "none",
|
|
172
|
+
):
|
|
173
|
+
return None
|
|
174
|
+
try:
|
|
175
|
+
return float(text)
|
|
176
|
+
except ValueError:
|
|
177
|
+
match = re.search(r"\d+(\.\d+)?", str(text))
|
|
178
|
+
return float(match.group()) if match else None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _is_na(value: str | None) -> bool:
|
|
182
|
+
"""Return True if *value* is semantically N/A."""
|
|
183
|
+
return str(value or "").strip().lower() in (
|
|
184
|
+
"n/a", "na", "none", "not applicable", "not available",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Public API
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def parse_audit_response(response_text: str) -> tuple[Optional[dict], int]:
|
|
193
|
+
"""
|
|
194
|
+
Parse a raw LLM audit response into a structured field dictionary.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
response_text: The complete text output from the LLM.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
A tuple of ``(audit_data, none_response_count)``.
|
|
201
|
+
``audit_data`` is ``None`` when the response is fatally incomplete.
|
|
202
|
+
``none_response_count`` counts fields that came back as ``"None"``.
|
|
203
|
+
"""
|
|
204
|
+
if not response_text or not isinstance(response_text, str):
|
|
205
|
+
return None, 999
|
|
206
|
+
|
|
207
|
+
response_text = _normalize_response_text(response_text)
|
|
208
|
+
raw_lines = response_text.split("\n")
|
|
209
|
+
# Normalize lines so Markdown-wrapped headers (e.g. "**1.1. ...:**") are recognized
|
|
210
|
+
response_lines = [_normalize_line_for_parsing(ln) for ln in raw_lines]
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
# Helper: extract value for a section prefix, with continuation lines
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
def _parse_response_line(
|
|
216
|
+
point_key: str,
|
|
217
|
+
startswith: str,
|
|
218
|
+
default=None,
|
|
219
|
+
is_time_to_fix: bool = False,
|
|
220
|
+
) -> tuple[str, object]:
|
|
221
|
+
line_idx = next(
|
|
222
|
+
(i for i, line in enumerate(response_lines) if line.startswith(startswith)),
|
|
223
|
+
None,
|
|
224
|
+
)
|
|
225
|
+
if line_idx is None:
|
|
226
|
+
return (point_key, default)
|
|
227
|
+
|
|
228
|
+
line = response_lines[line_idx]
|
|
229
|
+
parts = line.split(":", 1)
|
|
230
|
+
if len(parts) < 2:
|
|
231
|
+
return (point_key, default)
|
|
232
|
+
|
|
233
|
+
result = parts[1].strip()
|
|
234
|
+
|
|
235
|
+
# If value after colon is empty, use continuation line(s) until next section header
|
|
236
|
+
if not result and line_idx + 1 < len(response_lines):
|
|
237
|
+
continuation: list[str] = []
|
|
238
|
+
for j in range(line_idx + 1, len(response_lines)):
|
|
239
|
+
next_line = response_lines[j]
|
|
240
|
+
if _SECTION_HEADER_RE.match(next_line.strip()):
|
|
241
|
+
break
|
|
242
|
+
continuation.append(next_line)
|
|
243
|
+
result = " ".join(continuation).strip()
|
|
244
|
+
|
|
245
|
+
if not result:
|
|
246
|
+
return (point_key, default)
|
|
247
|
+
|
|
248
|
+
# Clean up extracted value
|
|
249
|
+
if point_key != "domain" and not is_time_to_fix:
|
|
250
|
+
result = _clean_value_from_prompt_artifacts(result)
|
|
251
|
+
|
|
252
|
+
if point_key == "domain":
|
|
253
|
+
result = _sanitize_domain(result)
|
|
254
|
+
elif is_time_to_fix:
|
|
255
|
+
result = _parse_time_to_fix(result)
|
|
256
|
+
else:
|
|
257
|
+
result = _sanitize_text(result)
|
|
258
|
+
|
|
259
|
+
if result != default:
|
|
260
|
+
return (point_key, result)
|
|
261
|
+
return (point_key, default)
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
# Step 1: Check is_script (section 0.)
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
audit_data: dict = {}
|
|
267
|
+
|
|
268
|
+
is_script_line = next(
|
|
269
|
+
(line for line in response_lines if line.startswith("0.")),
|
|
270
|
+
None,
|
|
271
|
+
)
|
|
272
|
+
if is_script_line:
|
|
273
|
+
parts = is_script_line.split(":", 1)
|
|
274
|
+
if len(parts) > 1:
|
|
275
|
+
is_script_value = parts[1].strip().lower()
|
|
276
|
+
if is_script_value == "no":
|
|
277
|
+
explanation_line = next(
|
|
278
|
+
(line for line in response_lines if line.startswith("0.1.")),
|
|
279
|
+
None,
|
|
280
|
+
)
|
|
281
|
+
explanation = "N/A"
|
|
282
|
+
if explanation_line:
|
|
283
|
+
exp_parts = explanation_line.split(":", 1)
|
|
284
|
+
if len(exp_parts) > 1:
|
|
285
|
+
explanation = exp_parts[1].strip()
|
|
286
|
+
return {"is_script": "no", "is_script_explanation": explanation}, 0
|
|
287
|
+
elif is_script_value == "yes":
|
|
288
|
+
audit_data["is_script"] = "yes"
|
|
289
|
+
|
|
290
|
+
# ------------------------------------------------------------------
|
|
291
|
+
# Step 2: Parse every section in the schema mapping
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
none_response_count = 0
|
|
294
|
+
|
|
295
|
+
for key, startswith in SCHEMA_MAPPING.items():
|
|
296
|
+
if key == "is_script" and "is_script" in audit_data:
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
point_key, value = _parse_response_line(
|
|
300
|
+
key,
|
|
301
|
+
startswith.strip(),
|
|
302
|
+
default=None,
|
|
303
|
+
is_time_to_fix=False,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
if value == "None":
|
|
307
|
+
none_response_count += 1
|
|
308
|
+
audit_data[point_key] = value
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
# Step 3: If not a script, return early
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
is_script_value = audit_data.get("is_script")
|
|
314
|
+
if is_script_value is not None and str(is_script_value).lower() == "no":
|
|
315
|
+
return audit_data, 0
|
|
316
|
+
|
|
317
|
+
# ------------------------------------------------------------------
|
|
318
|
+
# Step 4: Build structured vulnerabilities list from 9.1/9.2/9.3
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
def _extract_value_with_continuation(prefix: str) -> str | None:
|
|
321
|
+
idx = next(
|
|
322
|
+
(i for i, line in enumerate(response_lines) if line.startswith(prefix)),
|
|
323
|
+
None,
|
|
324
|
+
)
|
|
325
|
+
if idx is None:
|
|
326
|
+
return None
|
|
327
|
+
parts = response_lines[idx].split(":", 1)
|
|
328
|
+
value = parts[1].strip() if len(parts) > 1 else ""
|
|
329
|
+
if not value and idx + 1 < len(response_lines):
|
|
330
|
+
continuation: list[str] = []
|
|
331
|
+
for j in range(idx + 1, len(response_lines)):
|
|
332
|
+
next_line = response_lines[j]
|
|
333
|
+
if _SECTION_HEADER_RE.match(next_line.strip()):
|
|
334
|
+
break
|
|
335
|
+
continuation.append(next_line)
|
|
336
|
+
value = " ".join(continuation).strip()
|
|
337
|
+
return value if value else None
|
|
338
|
+
|
|
339
|
+
raw_reasons = _extract_value_with_continuation("9.1.")
|
|
340
|
+
raw_colors = _extract_value_with_continuation("9.2.")
|
|
341
|
+
raw_times = _extract_value_with_continuation("9.3.")
|
|
342
|
+
|
|
343
|
+
any_9x_present = any(x is not None for x in [raw_reasons, raw_colors, raw_times])
|
|
344
|
+
all_9x_na = any_9x_present and all(
|
|
345
|
+
_is_na(v) for v in [raw_reasons, raw_colors, raw_times] if v is not None
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if all(_is_na(v) for v in [raw_reasons, raw_colors, raw_times]):
|
|
349
|
+
reasons_list: list[str] = []
|
|
350
|
+
colors_list: list[str] = []
|
|
351
|
+
times_list: list[float | None] = []
|
|
352
|
+
else:
|
|
353
|
+
reasons_list = (
|
|
354
|
+
[_sanitize_text(x.strip()) for x in raw_reasons.split(";") if x.strip()]
|
|
355
|
+
if raw_reasons else []
|
|
356
|
+
)
|
|
357
|
+
colors_list = (
|
|
358
|
+
[_sanitize_text(x.strip()) for x in raw_colors.split(";") if x.strip()]
|
|
359
|
+
if raw_colors else []
|
|
360
|
+
)
|
|
361
|
+
if raw_times and not _is_na(raw_times):
|
|
362
|
+
times_list = [_parse_time_to_fix(x.strip()) for x in raw_times.split(";")]
|
|
363
|
+
else:
|
|
364
|
+
times_list = []
|
|
365
|
+
|
|
366
|
+
vulnerabilities_list: list[dict] = []
|
|
367
|
+
max_len = len(reasons_list) if reasons_list else max(
|
|
368
|
+
len(colors_list), len(times_list),
|
|
369
|
+
) if any([colors_list, times_list]) else 0
|
|
370
|
+
|
|
371
|
+
for idx in range(max_len):
|
|
372
|
+
reason = reasons_list[idx] if idx < len(reasons_list) else "N/A"
|
|
373
|
+
color = colors_list[idx] if idx < len(colors_list) else "N/A"
|
|
374
|
+
hours = times_list[idx] if idx < len(times_list) else None
|
|
375
|
+
vulnerabilities_list.append({"reason": reason, "color": color, "hours": hours})
|
|
376
|
+
|
|
377
|
+
if vulnerabilities_list:
|
|
378
|
+
audit_data["vulnerabilities_list"] = vulnerabilities_list
|
|
379
|
+
|
|
380
|
+
# Backward-compatible aggregate attributes
|
|
381
|
+
aggregate_reasons = "; ".join(
|
|
382
|
+
v["reason"]
|
|
383
|
+
for v in vulnerabilities_list
|
|
384
|
+
if v.get("reason") and str(v["reason"]).strip().upper() != "N/A"
|
|
385
|
+
).strip()
|
|
386
|
+
if aggregate_reasons:
|
|
387
|
+
audit_data["reasons_of_flag"] = aggregate_reasons
|
|
388
|
+
|
|
389
|
+
has_red = any(
|
|
390
|
+
(v.get("color") or "").strip().lower() == "red" for v in vulnerabilities_list
|
|
391
|
+
)
|
|
392
|
+
has_orange = any(
|
|
393
|
+
(v.get("color") or "").strip().lower() == "orange" for v in vulnerabilities_list
|
|
394
|
+
)
|
|
395
|
+
if has_red:
|
|
396
|
+
audit_data["flag_color"] = "Red"
|
|
397
|
+
elif has_orange:
|
|
398
|
+
audit_data["flag_color"] = "Orange"
|
|
399
|
+
|
|
400
|
+
total_hours = 0.0
|
|
401
|
+
any_hours = False
|
|
402
|
+
for v in vulnerabilities_list:
|
|
403
|
+
h = v.get("hours")
|
|
404
|
+
if isinstance(h, (int, float)):
|
|
405
|
+
total_hours += float(h)
|
|
406
|
+
any_hours = True
|
|
407
|
+
if any_hours:
|
|
408
|
+
audit_data["time_to_fix_flag"] = total_hours
|
|
409
|
+
|
|
410
|
+
# ------------------------------------------------------------------
|
|
411
|
+
# Step 5: Completeness check
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
has_any_flag_point = (
|
|
414
|
+
(audit_data.get("reasons_of_flag") not in [None, "", "None", "N/A"])
|
|
415
|
+
or (audit_data.get("flag_color") not in [None, "", "None", "N/A"])
|
|
416
|
+
or isinstance(audit_data.get("time_to_fix_flag"), (int, float))
|
|
417
|
+
or (isinstance(audit_data.get("vulnerabilities_list"), list) and len(audit_data["vulnerabilities_list"]) > 0)
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
if not has_any_flag_point and all_9x_na:
|
|
421
|
+
audit_data.setdefault("reasons_of_flag", "N/A")
|
|
422
|
+
audit_data.setdefault("flag_color", "N/A")
|
|
423
|
+
elif not has_any_flag_point:
|
|
424
|
+
# Incomplete — return high none_response_count to trigger retry
|
|
425
|
+
return None, 999
|
|
426
|
+
|
|
427
|
+
return audit_data, none_response_count
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def is_audit_data_valid(audit_data: dict | None) -> bool:
|
|
431
|
+
"""
|
|
432
|
+
Validate that parsed audit data contains the minimum required fields.
|
|
433
|
+
|
|
434
|
+
For non-script content, only ``is_script`` and ``is_script_explanation``
|
|
435
|
+
are required. For script content, ``script_purpose``, ``domain``, and
|
|
436
|
+
``summary_all`` must be present.
|
|
437
|
+
"""
|
|
438
|
+
if not audit_data or not isinstance(audit_data, dict):
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
is_script = audit_data.get("is_script")
|
|
442
|
+
if is_script is not None and str(is_script).lower() == "no":
|
|
443
|
+
return all(
|
|
444
|
+
audit_data.get(f) for f in ("is_script", "is_script_explanation")
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
required_fields = ("script_purpose", "domain", "summary_all")
|
|
448
|
+
return all(audit_data.get(f) for f in required_fields)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def is_response_complete(response_text: str, audit_data: dict | None = None) -> bool:
|
|
452
|
+
"""
|
|
453
|
+
Check whether the LLM response is sufficiently complete.
|
|
454
|
+
|
|
455
|
+
Accepts as complete if:
|
|
456
|
+
- Parsed data indicates not-a-script
|
|
457
|
+
- Parsed data contains any flag info (9.x section)
|
|
458
|
+
- Explicit N/A for all 9.x points
|
|
459
|
+
- Fallback: raw text contains section 9.x markers
|
|
460
|
+
"""
|
|
461
|
+
try:
|
|
462
|
+
if isinstance(audit_data, dict):
|
|
463
|
+
is_script_value = audit_data.get("is_script")
|
|
464
|
+
if is_script_value is not None and str(is_script_value).lower() == "no":
|
|
465
|
+
return True
|
|
466
|
+
|
|
467
|
+
has_any_flag = (
|
|
468
|
+
(isinstance(audit_data.get("vulnerabilities_list"), list) and len(audit_data["vulnerabilities_list"]) > 0)
|
|
469
|
+
or (audit_data.get("reasons_of_flag") not in [None, "", "None"])
|
|
470
|
+
or (audit_data.get("flag_color") not in [None, "", "None"])
|
|
471
|
+
or isinstance(audit_data.get("time_to_fix_flag"), (int, float))
|
|
472
|
+
)
|
|
473
|
+
if has_any_flag:
|
|
474
|
+
return True
|
|
475
|
+
|
|
476
|
+
if (
|
|
477
|
+
str(audit_data.get("reasons_of_flag", "")).strip().upper() == "N/A"
|
|
478
|
+
and str(audit_data.get("flag_color", "")).strip().upper() == "N/A"
|
|
479
|
+
):
|
|
480
|
+
return True
|
|
481
|
+
|
|
482
|
+
# Fallback: check raw text markers
|
|
483
|
+
text = (response_text or "").lower()
|
|
484
|
+
markers = ("9. due diligence", "9.1.", "9.2.", "9.3.")
|
|
485
|
+
return any(m in text for m in markers)
|
|
486
|
+
except Exception:
|
|
487
|
+
return True # Be permissive to avoid false negatives
|