|
| 1 | +import json |
| 2 | +import logging |
| 3 | + |
| 4 | +from dojo.models import Finding |
| 5 | + |
| 6 | +logger = logging.getLogger(__name__) |
| 7 | + |
| 8 | +# Ordered (ascending) severity ladder used by this parser. Index positions drive the |
| 9 | +# probe-family adjustment (a "+1"/"-1" nudge) on top of the score-derived base severity. |
| 10 | +# This is the parser's own ranking and is deliberately independent of the reverse-ordered |
| 11 | +# Finding.SEVERITIES mapping in dojo.models. |
| 12 | +SEVERITY_LADDER = ["Info", "Low", "Medium", "High", "Critical"] |
| 13 | + |
| 14 | +# Probe families whose hits warrant nudging severity UP one rung: active attack, |
| 15 | +# code-execution, or jailbreak intent. |
| 16 | +SEVERITY_UP_FAMILIES = { |
| 17 | + "dan", |
| 18 | + "promptinject", |
| 19 | + "latentinjection", |
| 20 | + "exploitation", |
| 21 | + "malwaregen", |
| 22 | + "xss", |
| 23 | +} |
| 24 | + |
| 25 | +# Probe families whose hits warrant nudging severity DOWN one rung: content/quality |
| 26 | +# issues that usually carry lower direct risk than an exploit. |
| 27 | +SEVERITY_DOWN_FAMILIES = { |
| 28 | + "misleading", |
| 29 | + "snowball", |
| 30 | + "continuation", |
| 31 | + "toxicity", |
| 32 | +} |
| 33 | + |
| 34 | +# Starter probe-family -> CWE mapping. Verified against MITRE CWE 4.x: |
| 35 | +# CWE-1427 Improper Neutralization of Input Used for LLM Prompting (prompt injection) |
| 36 | +# CWE-1426 Improper Validation of Generative AI Output (default / output safety) |
| 37 | +# CWE-79 Improper Neutralization of Input During Web Page Generation (XSS) |
| 38 | +# CWE-200 Exposure of Sensitive Information to an Unauthorized Actor |
| 39 | +# Intentionally coarse; refine as garak's probe taxonomy is mapped more finely. |
| 40 | +PROBE_FAMILY_CWE = { |
| 41 | + "promptinject": 1427, |
| 42 | + "dan": 1427, |
| 43 | + "latentinjection": 1427, |
| 44 | + "goodside": 1427, |
| 45 | + "xss": 79, |
| 46 | + "leakreplay": 200, |
| 47 | + "divergence": 200, |
| 48 | +} |
| 49 | +DEFAULT_CWE = 1426 |
| 50 | + |
| 51 | +# Fallback score for a hit record that carries no numeric score. Every line in a |
| 52 | +# garak hit log is, by construction, a detector hit, so an unscored hit is treated |
| 53 | +# as a strong hit rather than benign. |
| 54 | +DEFAULT_HIT_SCORE = 1.0 |
| 55 | + |
| 56 | + |
| 57 | +class GarakParser: |
| 58 | + |
| 59 | + """ |
| 60 | + Parser for garak (https://github.com/NVIDIA/garak), NVIDIA's LLM vulnerability scanner. |
| 61 | +
|
| 62 | + Consumes the JSON Lines hit log (``garak.<run_id>.hitlog.jsonl``) produced by a garak |
| 63 | + run. Every line in a hit log is, by construction, a detector hit, so each record maps to |
| 64 | + (or aggregates into) a DefectDojo Finding. Verified against the garak 0.15.x hit-log |
| 65 | + schema defined in garak/evaluators/base.py. |
| 66 | + """ |
| 67 | + |
| 68 | + def get_scan_types(self): |
| 69 | + return ["Garak Scan"] |
| 70 | + |
| 71 | + def get_label_for_scan_types(self, scan_type): |
| 72 | + return "Garak Scan" |
| 73 | + |
| 74 | + def get_description_for_scan_types(self, scan_type): |
| 75 | + return ( |
| 76 | + "Import the JSON Lines hit log (garak.<run_id>.hitlog.jsonl) produced by garak, " |
| 77 | + "NVIDIA's LLM vulnerability scanner. Each detector hit becomes a Finding; hits for " |
| 78 | + "the same probe, target, and detector are aggregated into one Finding." |
| 79 | + ) |
| 80 | + |
| 81 | + def get_findings(self, file, test): |
| 82 | + self.dupes = {} |
| 83 | + if file is None: |
| 84 | + return [] |
| 85 | + logger.debug("Garak parser: reading hit log %s", getattr(file, "name", file)) |
| 86 | + for raw_line in file: |
| 87 | + # Decode with utf-8-sig and strip any leading BOM so a hit log re-saved by a |
| 88 | + # BOM-adding editor (common on Windows) does not break json parsing of line 1. |
| 89 | + line = raw_line.decode("utf-8-sig") if isinstance(raw_line, bytes) else raw_line |
| 90 | + line = line.strip() |
| 91 | + if not line: |
| 92 | + continue |
| 93 | + try: |
| 94 | + record = json.loads(line) |
| 95 | + except json.JSONDecodeError as e: |
| 96 | + msg = ( |
| 97 | + "Invalid Garak hit log: expected JSON Lines (one JSON hit record per " |
| 98 | + "line). Provide the garak.<run_id>.hitlog.jsonl file produced by garak." |
| 99 | + ) |
| 100 | + raise ValueError(msg) from e |
| 101 | + if isinstance(record, dict) and record.get("probe"): |
| 102 | + self._process_hit(record, test) |
| 103 | + return list(self.dupes.values()) |
| 104 | + |
| 105 | + def _process_hit(self, record, test): |
| 106 | + probe = record.get("probe", "") |
| 107 | + detector = record.get("detector", "") |
| 108 | + generator = record.get("generator", "") |
| 109 | + goal = record.get("goal", "") |
| 110 | + probe_family = probe.split(".")[0] if probe else "" |
| 111 | + detector_family = detector.split(".")[0] if detector else "" |
| 112 | + severity = self._severity(record.get("score"), probe_family) |
| 113 | + |
| 114 | + # Aggregate every hit of the same probe against the same target via the same detector |
| 115 | + # into one Finding: bump the occurrence count and escalate to the most severe rung seen. |
| 116 | + # The description/prompt/output are taken from the first hit; only severity is escalated. |
| 117 | + dupe_key = f"{probe}::{generator}::{detector}" |
| 118 | + if dupe_key in self.dupes: |
| 119 | + finding = self.dupes[dupe_key] |
| 120 | + finding.nb_occurences += 1 |
| 121 | + if SEVERITY_LADDER.index(severity) > SEVERITY_LADDER.index(finding.severity): |
| 122 | + finding.severity = severity |
| 123 | + return |
| 124 | + |
| 125 | + title = f"{probe}: {goal}".strip().rstrip(":").strip() |
| 126 | + if len(title) > 255: |
| 127 | + title = title[:252] + "..." |
| 128 | + |
| 129 | + finding = Finding( |
| 130 | + test=test, |
| 131 | + title=title, |
| 132 | + description=self._build_description(record), |
| 133 | + severity=severity, |
| 134 | + cwe=PROBE_FAMILY_CWE.get(probe_family, DEFAULT_CWE), |
| 135 | + references=self._reference(probe_family), |
| 136 | + component_name=generator or None, |
| 137 | + vuln_id_from_tool=probe, |
| 138 | + unique_id_from_tool=dupe_key, |
| 139 | + static_finding=True, |
| 140 | + dynamic_finding=False, |
| 141 | + nb_occurences=1, |
| 142 | + ) |
| 143 | + finding.unsaved_tags = [tag for tag in ["garak", probe_family, detector_family] if tag] |
| 144 | + self.dupes[dupe_key] = finding |
| 145 | + |
| 146 | + def _severity(self, score, probe_family): |
| 147 | + try: |
| 148 | + score_val = float(score) |
| 149 | + except (TypeError, ValueError): |
| 150 | + score_val = DEFAULT_HIT_SCORE |
| 151 | + if score_val >= 0.9: |
| 152 | + base = 3 # High |
| 153 | + elif score_val >= 0.7: |
| 154 | + base = 2 # Medium |
| 155 | + elif score_val >= 0.4: |
| 156 | + base = 1 # Low |
| 157 | + else: |
| 158 | + base = 0 # Info |
| 159 | + if probe_family in SEVERITY_UP_FAMILIES: |
| 160 | + base += 1 |
| 161 | + elif probe_family in SEVERITY_DOWN_FAMILIES: |
| 162 | + base -= 1 |
| 163 | + base = max(0, min(base, len(SEVERITY_LADDER) - 1)) |
| 164 | + return SEVERITY_LADDER[base] |
| 165 | + |
| 166 | + def _reference(self, probe_family): |
| 167 | + if not probe_family: |
| 168 | + return "https://reference.garak.ai/en/latest/probes.html" |
| 169 | + return f"https://reference.garak.ai/en/latest/garak.probes.{probe_family}.html" |
| 170 | + |
| 171 | + def _build_description(self, record): |
| 172 | + goal = record.get("goal") |
| 173 | + probe = record.get("probe") |
| 174 | + detector = record.get("detector") |
| 175 | + score = record.get("score") |
| 176 | + generator = record.get("generator") |
| 177 | + triggers = record.get("triggers") |
| 178 | + prompt_text = self._message_text(record.get("prompt")) |
| 179 | + output_text = self._message_text(record.get("output")) |
| 180 | + |
| 181 | + parts = [] |
| 182 | + if goal: |
| 183 | + parts.append(f"**Goal:** {goal}") |
| 184 | + if probe: |
| 185 | + parts.append(f"**Probe:** {probe}") |
| 186 | + if detector: |
| 187 | + parts.append(f"**Detector:** {detector}") |
| 188 | + if score is not None: |
| 189 | + parts.append(f"**Detector score:** {score}") |
| 190 | + if generator: |
| 191 | + parts.append(f"**Target:** {generator}") |
| 192 | + if prompt_text: |
| 193 | + parts.append(f"**Prompt:**\n```\n{prompt_text}\n```") |
| 194 | + if output_text: |
| 195 | + parts.append(f"**Model output:**\n```\n{output_text}\n```") |
| 196 | + if triggers: |
| 197 | + parts.append(f"**Triggers:**\n```json\n{json.dumps(triggers, indent=2)}\n```") |
| 198 | + return "\n\n".join(parts) |
| 199 | + |
| 200 | + def _message_text(self, obj): |
| 201 | + """ |
| 202 | + Extract human-readable text from a garak prompt or output value. |
| 203 | +
|
| 204 | + garak serialises a prompt as a Conversation (via dataclasses.asdict) -> |
| 205 | + {"turns": [{"role": ..., "content": {"text": ...}}], "notes": {}} and an output as a |
| 206 | + single Message -> {"text": ...}. Older or looser payloads may carry a plain string. |
| 207 | + All three shapes are handled. |
| 208 | + """ |
| 209 | + if obj is None: |
| 210 | + return "" |
| 211 | + if isinstance(obj, str): |
| 212 | + return obj |
| 213 | + if isinstance(obj, dict): |
| 214 | + if obj.get("text") is not None: |
| 215 | + return str(obj["text"]) |
| 216 | + turns = obj.get("turns") |
| 217 | + if isinstance(turns, list): |
| 218 | + lines = [] |
| 219 | + for turn in turns: |
| 220 | + if not isinstance(turn, dict): |
| 221 | + continue |
| 222 | + content = turn.get("content") |
| 223 | + role = turn.get("role") or "" |
| 224 | + text = "" |
| 225 | + if isinstance(content, dict): |
| 226 | + text = content.get("text") or "" |
| 227 | + elif isinstance(content, str): |
| 228 | + text = content |
| 229 | + if text: |
| 230 | + lines.append(f"{role}: {text}" if role else text) |
| 231 | + return "\n".join(lines) |
| 232 | + return "" |
0 commit comments