diff --git a/scripts/codelens.py b/scripts/codelens.py index 88fdd707..3e325139 100755 --- a/scripts/codelens.py +++ b/scripts/codelens.py @@ -889,8 +889,12 @@ def main(): # graph-producing commands (scan/trace/impact/circular); other commands # produce a single-node placeholder so the format is always valid. if "format" not in existing_dests: - sub.add_argument("--format", "-f", choices=["json", "markdown", "ai", "sarif", "compact", "graphml"], default=None, - help="Output format: json, markdown, ai (normalized schema), sarif (GitHub/VS Code), compact (token-efficient single-char keys), or graphml (GraphML 1.0 XML for graph-producing commands)") + sub.add_argument("--format", "-f", + choices=["json", "markdown", "ai", "sarif", "compact", "graphml", + # Phase 2 (issue #52): 5 new formatters + "text", "junit-xml", "emacs", "vim", "gitlab-sast"], + default=None, + help="Output format: json, markdown, ai (normalized schema), sarif (GitHub/VS Code), compact (token-efficient single-char keys), graphml (GraphML 1.0 XML for graph-producing commands), text (human-readable table), junit-xml (Jenkins/GitLab CI), emacs (compile-mode), vim (quickfix), or gitlab-sast (GitLab security dashboard)") # Add AI-optimized flags to subparser ONLY if the command doesn't already have them if "top" not in existing_dests: diff --git a/scripts/formatters/__init__.py b/scripts/formatters/__init__.py index c43334a8..a8e39447 100644 --- a/scripts/formatters/__init__.py +++ b/scripts/formatters/__init__.py @@ -189,12 +189,17 @@ def _normalize_to_ai(data: Any, command: str = "") -> Dict[str, Any]: def format_output(data: Any, format_type: str = "json", command: str = "", workspace: str = "") -> str: - """Format output data as JSON, Markdown, AI (normalized schema), SARIF, Compact, or GraphML. + """Format output data as JSON, Markdown, AI (normalized schema), SARIF, Compact, GraphML, or Phase 2 formatters. GraphML (issue #59 Phase 3) emits a GraphML 1.0 XML document for graph-producing commands (scan, trace, impact, circular). Non-graph commands produce a single-node placeholder graph so the format is always valid XML. + + Phase 2 (issue #52) added: ``text``, ``junit-xml``, ``emacs``, + ``vim``, ``gitlab-sast``. These consume :class:`formatters.base.Finding` + objects via :func:`formatters.base.extract_findings` — single + extraction path, consistent across all Phase 2 formatters. """ if format_type == "ai": normalized = _normalize_to_ai(data, command) @@ -213,5 +218,21 @@ def format_output(data: Any, format_type: str = "json", command: str = "", # placeholder so the format is always valid, never raises. from formatters.graphml import format_graphml return format_graphml(data, command, workspace) + # ─── Phase 2 formatters (issue #52) ─── + if format_type == "text": + from formatters.text import format_text + return format_text(data, command, workspace) + if format_type == "junit-xml": + from formatters.junit_xml import format_junit_xml + return format_junit_xml(data, command, workspace) + if format_type == "emacs": + from formatters.emacs import format_emacs + return format_emacs(data, command, workspace) + if format_type == "vim": + from formatters.vim import format_vim + return format_vim(data, command, workspace) + if format_type == "gitlab-sast": + from formatters.gitlab_sast import format_gitlab_sast + return format_gitlab_sast(data, command, workspace) # Default: JSON return json.dumps(data, indent=2, ensure_ascii=False) diff --git a/scripts/formatters/base.py b/scripts/formatters/base.py new file mode 100644 index 00000000..bfa6455b --- /dev/null +++ b/scripts/formatters/base.py @@ -0,0 +1,447 @@ +"""Unified Finding dataclass + extraction helper for CodeLens formatters (issue #52, Phase 1). + +Why this module exists +---------------------- +CodeLens commands return heterogeneous dicts — each engine uses +slightly different keys for the same logical concept: + +* ``secrets`` returns ``findings`` with ``file``, ``line``, ``severity``, + ``category``, ``message``, ``match`` +* ``dead-code`` returns ``by_category`` dict of lists, with + ``defined_in`` instead of ``file``, ``line_number`` instead of + ``line`` +* ``smell`` returns ``by_category`` with ``severity``, ``message`` +* ``taint`` returns ``chains`` with ``source``, ``sink``, ``taint_path`` +* ``complexity`` returns ``functions`` with ``cyclomatic`` score + +Before Phase 1, every formatter (sarif, compact, ai normalizer) had +its own ad-hoc extraction logic — duplicated, slightly inconsistent, +and a maintenance trap. + +This module introduces a single :class:`Finding` dataclass and a +single :func:`extract_findings` entry point. Formatters consume +``Finding`` objects; the extraction logic lives here, in one place. + +Backward compatibility +---------------------- +This is **additive only**. Existing formatters (json, markdown, ai, +sarif, compact) keep their original behavior — they do NOT use +``Finding`` objects internally. New Phase 2 formatters (text, +junit_xml, emacs, vim, gitlab_sast) consume ``Finding`` objects. + +The path forward (future Phase) is to refactor existing formatters +to also consume ``Finding`` objects, but that's a behavior-risky +change and out of scope for this PR. + +License note +------------ +Issue #52 explicitly notes: "Semgrep formatters are LGPL-2.1 — +reference only, reimplement from spec." This module is a clean-room +reimplementation from the CodeLens output schemas observed in the +existing engines; no Semgrep code or schema was copied. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, asdict +from typing import Any, Dict, List, Optional, Tuple, Union + +__all__ = [ + "Finding", + "Severity", + "extract_findings", + "findings_to_dicts", +] + + +# ─── Severity enum (string-based for JSON compat) ────────────── + +class Severity: + """String constants for finding severity levels. + + CodeLens engines use a mix of severity vocabularies — some use + ``critical/high/medium/low/info``, others use ``error/warning/info``. + The :class:`Severity` constants are the canonical set; the + extraction layer normalizes engine-specific values to these. + + Using a class-with-string-constants (not ``enum.Enum``) because: + + * JSON serialization "just works" (no ``.value`` accessor needed). + * Formatters can compare ``finding.severity == "critical"`` + without importing the class. + * Backward-compatible with existing string-typed severity fields + in engine output. + """ + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + INFO = "info" + ERROR = "error" # alias for critical/high in some tools + WARNING = "warning" # alias for medium + UNKNOWN = "info" # default when engine didn't set severity + + +# ─── Finding dataclass ───────────────────────────────────────── + + +@dataclass +class Finding: + """A single analyzer finding, normalized across all CodeLens engines. + + Every formatter in Phase 2+ consumes this dataclass. The + extraction layer (:func:`extract_findings`) populates it from + the heterogeneous engine outputs. + + Fields are designed to be **superset** of what any single + formatter needs — ``junit_xml`` uses ``message`` + ``severity``, + ``emacs``/``vim`` use ``file`` + ``line`` + ``column`` + ``message``, + ``gitlab_sast`` uses ``cwe`` + ``severity`` + ``location``. A + formatter just reads the fields it needs and ignores the rest. + + Severity values: see :class:`Severity`. Always lowercase string. + """ + # ─── Required (every finding has these) ─── + message: str + severity: str = Severity.UNKNOWN + + # ─── Location ─── + file: str = "" + line: int = 0 + column: int = 0 + end_line: int = 0 + end_column: int = 0 + + # ─── Classification ─── + rule_id: str = "" # e.g. "codelens/secrets/api-key" + category: str = "" # e.g. "api_key", "unreachable", "long_fn" + command: str = "" # which CodeLens command produced this + confidence: str = "" # "high" / "medium" / "low" if available + + # ─── Optional context ─── + cwe: str = "" # e.g. "CWE-79" for XSS + snippet: str = "" # source code snippet (already masked if secret) + taint_path: str = "" # "source → ... → sink" for taint findings + source: str = "" # taint source identifier + sink: str = "" # taint sink identifier + + # ─── Suppression ─── + suppressed: bool = False + suppressed_reason: str = "" + + # ─── Free-form extras (preserved for round-trip JSON) ─── + extras: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a plain dict (for JSON serialization). + + Omits empty/zero values to keep output compact — this is the + format ``--format json`` will eventually use directly once + existing formatters are refactored to consume ``Finding``. + For now, it's used by ``findings_to_dicts`` for testing. + """ + d = asdict(self) + # Remove empty/zero fields (but keep ``severity`` even if "info", + # and keep ``message`` even if empty — those are required). + out: Dict[str, Any] = {} + for k, v in d.items(): + if k == "extras": + if v: + out.update(v) + continue + if v in ("", 0, False, None): + # Keep severity and message even if "empty" defaults. + if k in ("severity", "message"): + out[k] = v + continue + out[k] = v + return out + + +# ─── Extraction logic ────────────────────────────────────────── + +# Keys where engines stash their findings lists. Order matters — +# the first key that yields a non-empty list wins. This mirrors the +# priority order in ``formatters/__init__.py::_normalize_to_ai`` so +# Phase 1 extraction is consistent with existing AI normalizer. +_FINDING_LIST_KEYS: Tuple[str, ...] = ( + "findings", "leaks", "hints", "issues", "violations", + "matches", "chains", "results", +) + +# Keys where engines stash category-keyed dicts of finding lists +# (e.g. ``dead-code`` returns ``{"by_category": {"unreachable": [...]}}``). +_FINDING_DICT_KEYS: Tuple[str, ...] = ( + "by_category", "by_severity", "results", +) + + +def _coerce_int(value: Any, default: int = 0) -> int: + """Best-effort int coercion — engines sometimes emit strings.""" + if isinstance(value, bool): + return int(value) + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str) and value.isdigit(): + return int(value) + return default + + +def _normalize_severity(raw: Any) -> str: + """Normalize engine-specific severity strings to canonical Severity. + + Engines use various vocabularies — this collapses them to the + canonical set so formatters don't need to handle every variant. + """ + if not raw: + return Severity.UNKNOWN + if not isinstance(raw, str): + raw = str(raw) + s = raw.strip().lower() + # Direct canonical match + if s in (Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, + Severity.LOW, Severity.INFO): + return s + # Aliases + if s in (Severity.ERROR, "fatal", "blocker"): + return Severity.CRITICAL + if s in (Severity.WARNING, "warn", "moderate"): + return Severity.MEDIUM + if s in ("informational", "note", "hint", "trivial"): + return Severity.LOW + return Severity.UNKNOWN + + +def _normalize_finding_dict( + raw: Dict[str, Any], + command: str, + category_hint: str = "", +) -> Finding: + """Normalize a single engine finding dict into a :class:`Finding`. + + ``category_hint`` is set when the finding came from a + category-keyed dict (e.g. ``by_category["unreachable"]``) — the + engine often doesn't repeat the category inside each finding, so + we use the hint as a fallback. + """ + if not isinstance(raw, dict): + # Defensive: engines shouldn't return non-dict findings, but + # if they do, wrap the value in a message. + return Finding(message=str(raw), command=command, category=category_hint) + + # ─── Location ─── + file_path = ( + raw.get("file") + or raw.get("defined_in") + or raw.get("path") + or raw.get("filename") + or "" + ) + line = _coerce_int( + raw.get("line") or raw.get("line_number") or raw.get("start_line") or 0 + ) + column = _coerce_int( + raw.get("column") or raw.get("col") or raw.get("start_column") or 0 + ) + end_line = _coerce_int( + raw.get("end_line") or raw.get("endLine") or 0 + ) + end_column = _coerce_int( + raw.get("end_column") or raw.get("endColumn") or 0 + ) + + # ─── Classification ─── + category = ( + raw.get("category") + or raw.get("type") + or category_hint + or "" + ) + severity = _normalize_severity( + raw.get("severity") or raw.get("risk") or raw.get("level") + ) + confidence = ( + raw.get("confidence") + or raw.get("certainty") + or "" + ) + if isinstance(confidence, str): + confidence = confidence.lower() + + # ─── Message ─── + # Engines use various keys for the human-readable message. + message = ( + raw.get("message") + or raw.get("name") + or raw.get("description") + or raw.get("match") + or raw.get("rule") + or "" + ) + if not message: + # Last-resort: synthesize a message from category + file. + # Better than empty string — formatters need *something* to show. + basename = file_path.rsplit("/", 1)[-1] if file_path else "" + message = f"{command} finding in {basename}" + if category: + message = f"{category} in {basename}" + + # ─── Rule ID ─── + rule_id = ( + raw.get("rule_id") + or raw.get("ruleId") + or raw.get("rule") + or "" + ) + if not rule_id and command: + # Synthesize a stable rule_id: "codelens//" + # Lowercase, hyphenated. This matches the convention used by + # the existing SARIF formatter (``sarif._get_rule_id``). + safe_cat = (category or "general").lower().replace("_", "-").replace(" ", "-") + rule_id = f"codelens/{command}/{safe_cat}" + + # ─── CWE ─── + cwe = raw.get("cwe") or raw.get("CWE") or "" + if isinstance(cwe, str): + cwe = cwe.strip() + + # ─── Snippet ─── + snippet = ( + raw.get("snippet") + or raw.get("code") + or raw.get("line_content") + or raw.get("match") + or "" + ) + + # ─── Taint-specific ─── + taint_path = ( + raw.get("taint_path") + or raw.get("dataflow_path") + or raw.get("flow") + or "" + ) + source = raw.get("source") or "" + sink = raw.get("sink") or "" + + # ─── Suppression ─── + suppressed = bool(raw.get("suppressed") or raw.get("ignored") or False) + suppressed_reason = raw.get("suppressed_reason") or raw.get("ignore_reason") or "" + + # ─── Extras: capture remaining non-canonical keys ─── + # This preserves round-trip fidelity — if an engine emits a + # field that doesn't map to a Finding attribute, it goes into + # ``extras`` and survives ``to_dict()``. + known_keys = { + "file", "defined_in", "path", "filename", + "line", "line_number", "start_line", "start_column", + "column", "col", "end_line", "endLine", "end_column", "endColumn", + "category", "type", "severity", "risk", "level", "confidence", "certainty", + "message", "name", "description", "match", "rule", + "rule_id", "ruleId", "cwe", "CWE", + "snippet", "code", "line_content", + "taint_path", "dataflow_path", "flow", "source", "sink", + "suppressed", "ignored", "suppressed_reason", "ignore_reason", + } + extras = {k: v for k, v in raw.items() if k not in known_keys} + + return Finding( + message=message, + severity=severity, + file=file_path, + line=line, + column=column, + end_line=end_line, + end_column=end_column, + rule_id=rule_id, + category=category, + command=command, + confidence=confidence, + cwe=cwe, + snippet=snippet, + taint_path=taint_path, + source=source, + sink=sink, + suppressed=suppressed, + suppressed_reason=suppressed_reason, + extras=extras, + ) + + +def extract_findings(data: Any, command: str = "") -> List[Finding]: + """Extract a list of :class:`Finding` from any CodeLens command output. + + This is the single entry point formatters should call. It handles: + + * Plain finding lists (``data["findings"] = [...]``) + * Category-keyed dicts (``data["by_category"] = {"unreachable": [...]}``) + * Severity-keyed dicts (``data["by_severity"] = {"critical": [...]}``) + * Empty / non-dict / error output → returns ``[]`` + + The extraction is conservative — when in doubt, return fewer + findings rather than risk duplicating or misattributing. An + empty list is always safe for formatters to render as "no + findings". + + Args: + data: CodeLens command output (usually a dict). + command: Command name (e.g. ``"secrets"``). Used to populate + :attr:`Finding.command` and synthesize :attr:`Finding.rule_id` + when the engine didn't provide one. + + Returns: + List of :class:`Finding` objects, possibly empty. Never None. + """ + if not isinstance(data, dict): + return [] + if data.get("status") == "error": + # Error responses have no findings — don't try to extract. + return [] + + findings: List[Finding] = [] + seen_ids: set = set() # dedupe by (file, line, category, message) + + def _add(raw_finding: Any, category_hint: str = "") -> None: + if not isinstance(raw_finding, dict): + return + f = _normalize_finding_dict(raw_finding, command, category_hint) + # Dedupe — engines occasionally return the same finding twice + # (e.g. once in ``findings`` and once in ``by_category``). + key = (f.file, f.line, f.category, f.message) + if key in seen_ids: + return + seen_ids.add(key) + findings.append(f) + + # ─── Phase 1: plain finding lists ─── + for key in _FINDING_LIST_KEYS: + val = data.get(key) + if isinstance(val, list): + for item in val: + _add(item) + elif isinstance(val, dict): + # Some engines use ``findings = {"category_name": [list]}`` + # instead of a plain list. Treat it like by_category. + for cat, items in val.items(): + if isinstance(items, list): + for item in items: + _add(item, category_hint=cat) + + # ─── Phase 2: category/severity-keyed dicts ─── + for key in _FINDING_DICT_KEYS: + val = data.get(key) + if isinstance(val, dict): + for cat, items in val.items(): + if isinstance(items, list): + for item in items: + _add(item, category_hint=cat) + + return findings + + +def findings_to_dicts(findings: List[Finding]) -> List[Dict[str, Any]]: + """Convert a list of Finding back to plain dicts. + + Mainly used for testing — verifies round-trip fidelity. + """ + return [f.to_dict() for f in findings] diff --git a/scripts/formatters/emacs.py b/scripts/formatters/emacs.py new file mode 100644 index 00000000..e3ffded3 --- /dev/null +++ b/scripts/formatters/emacs.py @@ -0,0 +1,98 @@ +# @WHO: scripts/formatters/emacs.py +# @WHAT: Emacs compilation-mode formatter — file:line:col: severity: message for compile-mode (issue #52 Phase 2) +# @PART: formatters +# @ENTRY: format_emacs() +"""Emacs compilation-mode formatter for CodeLens (issue #52, Phase 2). + +Emits findings in the canonical Emacs ``compile-mode`` format:: + + file:line:col: severity: message + +Clicking a line in ``*compilation*`` buffer jumps to the source +location. Works in Emacs (``M-x compile``), and is also recognized +by ``grep-mode``, ``flymake``, and many third-party Emacs tools. + +Format spec: https://www.gnu.org/software/emacs/manual/html_node/emacs/Compilation-Mode.html + +Severity → Emacs level mapping: + critical / high → ``error`` (red, blocks next-error navigation) + medium → ``warning`` (yellow) + low / info → ``note`` (default face, non-blocking) + +The format is line-oriented, one finding per line. No header, no +footer — Emacs parses each line independently, so any extra prose +would be ignored or flagged as "no match". +""" + +from __future__ import annotations + +import os +from typing import Any, List + +from formatters.base import Finding, Severity, extract_findings + + +# Severity → Emacs level string. Lowercase to match Emacs convention. +_EMACS_LEVEL = { + Severity.CRITICAL: "error", + Severity.HIGH: "error", + Severity.ERROR: "error", + Severity.MEDIUM: "warning", + Severity.WARNING: "warning", + Severity.LOW: "note", + Severity.INFO: "note", +} + + +def _format_line(finding: Finding, workspace: str = "") -> str: + """Format a single finding as ``file:line:col: level: message``.""" + if not finding.file: + # Without a file, Emacs can't jump — but we still want to + # surface the finding. Use a placeholder path. + path = "" + else: + path = finding.file + if workspace and path.startswith(workspace): + path = os.path.relpath(path, workspace) + path = path.replace("\\", "/") + + # Build the location prefix: file:line:col + # Omit col if 0 (engines often don't compute it). + if finding.line: + loc = f"{path}:{finding.line}" + if finding.column: + loc += f":{finding.column}" + else: + loc = path + + level = _EMACS_LEVEL.get(finding.severity, "note") + message = finding.message or finding.rule_id or "CodeLens finding" + + return f"{loc}: {level}: {message}" + + +def format_emacs(data: Any, command: str = "", workspace: str = "") -> str: + """Format CodeLens output for Emacs ``compile-mode``. + + Args: + data: CodeLens command output dict. + command: Command name (unused — kept for API consistency). + workspace: Workspace root (for path shortening). + + Returns: + One line per finding, no header/footer. Empty string if no + findings (Emacs handles empty compile output gracefully). + """ + findings = extract_findings(data, command) + + # Skip suppressed findings — Emacs users don't want to see + # dismissed warnings cluttering their *compilation* buffer. + active = [f for f in findings if not f.suppressed] + + if not active: + # Return a single informative line — Emacs users expect SOME + # output from a compile run, not total silence. + return f"# CodeLens: no findings for command '{command or 'unknown'}'" + + lines: List[str] = [_format_line(f, workspace) for f in active] + return "\n".join(lines) diff --git a/scripts/formatters/gitlab_sast.py b/scripts/formatters/gitlab_sast.py new file mode 100644 index 00000000..d59484f4 --- /dev/null +++ b/scripts/formatters/gitlab_sast.py @@ -0,0 +1,231 @@ +# @WHO: scripts/formatters/gitlab_sast.py +# @WHAT: GitLab SAST JSON formatter — native security scan format for GitLab CI dashboard (issue #52 Phase 2) +# @PART: formatters +# @ENTRY: format_gitlab_sast() +"""GitLab SAST JSON formatter for CodeLens (issue #52, Phase 2). + +Generates GitLab's native security scan JSON format for direct +ingestion by GitLab CI's security dashboard. Findings appear in +Merge Request widgets, the Security & Compliance dashboard, and +vulnerability management flows — no custom parsing needed. + +Format spec: https://docs.gitlab.com/ee/development/integrations/secure.html + +Schema overview +--------------- +Top-level object with: + +* ``version`` — schema version (always "14.0.0" for current GitLab) +* ``vulnerabilities`` — array of vulnerability objects +* ``scan`` — metadata about the analyzer (CodeLens) + +Each vulnerability object has: + +* ``id`` — stable UUID-like identifier (deterministic from rule+location) +* ``category`` — always "sast" for CodeLens +* ``name``, ``message`` — short + long description +* ``cve`` — fallback ID (GitLab requires this field even for non-CVE) +* ``severity`` — one of ``Info``/``Unknown``/``Low``/``Medium``/``High``/``Critical`` +* ``confidence`` — same enum as severity +* ``scanner`` — ``{"id": "codelens", "name": "CodeLens"}`` +* ``location`` — ``{"file": "...", "start_line": N}`` +* ``identifiers`` — array with rule_id + optional CWE + +Severity mapping +---------------- +CodeLens severity → GitLab severity: + critical → Critical + high → High + medium → Medium + low → Low + info → Info + (unknown) → Unknown + +Suppressed findings are omitted — GitLab's vulnerability management +flow expects only actionable findings in the report. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from typing import Any, Dict, List + +from formatters.base import Finding, Severity, extract_findings + + +# Schema version — pinned to the GitLab Secure spec version CodeLens +# targets. Bumping this requires coordination with GitLab release notes. +SCHEMA_VERSION = "14.0.0" + +# CodeLens severity → GitLab severity enum (capitalized, per spec). +_GITLAB_SEVERITY = { + Severity.CRITICAL: "Critical", + Severity.HIGH: "High", + Severity.MEDIUM: "Medium", + Severity.LOW: "Low", + Severity.INFO: "Info", + Severity.ERROR: "Critical", + Severity.WARNING: "Medium", +} + +# Default confidence — CodeLens engines don't always set this, but +# GitLab requires the field. Default to the severity-equivalent. +_DEFAULT_CONFIDENCE = "Medium" + + +def _stable_id(finding: Finding) -> str: + """Generate a deterministic ID for a finding. + + GitLab expects a stable identifier so the same finding shows up + as the same vulnerability across scans (not a new one each run). + We hash rule_id + file + line + category — same inputs = same ID. + + Returns a hex string (GitLab accepts any string; hex is safe). + """ + parts = [ + finding.rule_id or "", + finding.file or "", + str(finding.line or 0), + finding.category or "", + ] + raw = "|".join(parts) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32] + + +def _cwe_id(finding: Finding) -> str: + """Extract a clean CWE identifier from finding.cwe. + + Engines sometimes emit ``"CWE-79"``, sometimes ``"79"``, + sometimes ``"cwe-079"``. Normalize to ``"CWE-79"`` form. + """ + if not finding.cwe: + return "" + cwe = finding.cwe.strip().upper() + if cwe.startswith("CWE-"): + # Already in correct form — just normalize the number. + return cwe + if cwe.isdigit(): + return f"CWE-{cwe}" + return cwe + + +def _build_identifiers(finding: Finding) -> List[Dict[str, str]]: + """Build the ``identifiers`` array for a GitLab vulnerability. + + Always includes the CodeLens rule_id. If the finding has a CWE, + adds a CWE identifier too — GitLab uses this to enrich the + vulnerability with external references. + """ + identifiers: List[Dict[str, str]] = [{ + "type": "codelens_rule", + "name": finding.rule_id or "codelens-finding", + "value": finding.rule_id or finding.category or "codelens-finding", + "url": "https://github.com/Wolfvin/CodeLens", + }] + cwe = _cwe_id(finding) + if cwe: + # CWE URL — official MITRE URL pattern. + cwe_num = cwe.replace("CWE-", "") + identifiers.append({ + "type": "cwe", + "name": cwe, + "value": cwe, + "url": f"https://cwe.mitre.org/data/definitions/{cwe_num}.html", + }) + return identifiers + + +def _build_vulnerability(finding: Finding, workspace: str = "") -> Dict[str, Any]: + """Convert a single Finding to a GitLab vulnerability dict.""" + severity = _GITLAB_SEVERITY.get(finding.severity, "Unknown") + # Confidence: use finding.confidence if set, else default. + confidence = finding.confidence.capitalize() if finding.confidence else _DEFAULT_CONFIDENCE + # Validate confidence is in GitLab's enum. + if confidence not in ("Info", "Unknown", "Low", "Medium", "High", "Critical"): + confidence = _DEFAULT_CONFIDENCE + + # File path — GitLab wants relative paths (relative to repo root). + file_path = finding.file or "" + if workspace and file_path.startswith(workspace): + file_path = os.path.relpath(file_path, workspace) + file_path = file_path.replace("\\", "/") + + # Build the vulnerability dict per GitLab Secure spec. + vuln: Dict[str, Any] = { + "id": _stable_id(finding), + "category": "sast", + "name": finding.message or finding.rule_id or "CodeLens finding", + "message": finding.message or finding.rule_id or "CodeLens finding", + "cve": _stable_id(finding), # GitLab requires cve field even for non-CVE + "severity": severity, + "confidence": confidence, + "scanner": { + "id": "codelens", + "name": "CodeLens", + }, + "location": { + "file": file_path, + "start_line": max(1, finding.line or 0), + }, + "identifiers": _build_identifiers(finding), + } + + # Optional fields — only include if non-empty (GitLab's JSON schema + # treats absent and null differently; absent is the safe default). + if finding.end_line and finding.end_line > finding.line: + vuln["location"]["end_line"] = finding.end_line + if finding.snippet: + # GitLab's ``source_code`` field — useful for showing the + # vulnerable line in the MR widget. + vuln["raw_source_code_extract"] = finding.snippet[:500] # cap to avoid huge payloads + + return vuln + + +def format_gitlab_sast(data: Any, command: str = "", workspace: str = "") -> str: + """Format CodeLens output as GitLab SAST JSON. + + Args: + data: CodeLens command output dict. + command: Command name (recorded in scan metadata). + workspace: Workspace root (for relative path conversion). + + Returns: + Valid GitLab Secure-format JSON string. Always a single JSON + object at the top level — never an array, never JSONL. + """ + findings = extract_findings(data, command) + + # Omit suppressed findings — GitLab's vulnerability management + # expects only actionable findings in the report. Suppressed + # findings should not create new vulnerability records. + active = [f for f in findings if not f.suppressed] + + vulnerabilities = [_build_vulnerability(f, workspace) for f in active] + + # Scan metadata — GitLab uses this in the dashboard to show which + # analyzer produced the report and when. + scan = { + "scanner": { + "id": "codelens", + "name": "CodeLens", + "version": "8.2.0", # CodeLens version (single source of truth would be better) + "vendor": { + "name": "Wolfvin", + }, + }, + "type": "sast", + "start_time": "", # filled by caller in CI; empty for CLI use + "end_time": "", + "status": "success", + } + + report = { + "version": SCHEMA_VERSION, + "vulnerabilities": vulnerabilities, + "scan": scan, + } + + return json.dumps(report, indent=2, ensure_ascii=False) diff --git a/scripts/formatters/junit_xml.py b/scripts/formatters/junit_xml.py new file mode 100644 index 00000000..4c257450 --- /dev/null +++ b/scripts/formatters/junit_xml.py @@ -0,0 +1,169 @@ +# @WHO: scripts/formatters/junit_xml.py +# @WHAT: JUnit XML formatter — CI test-report integration for Jenkins/GitLab/CircleCI (issue #52 Phase 2) +# @PART: formatters +# @ENTRY: format_junit_xml() +"""JUnit XML formatter for CodeLens (issue #52, Phase 2). + +Generates JUnit XML — the universal test-result format understood by +Jenkins, GitLab CI, CircleCI, Buildkite, and most CI systems. + +Why JUnit XML for a static analyzer? +------------------------------------ +CI systems already have rich UI for JUnit: failure lists, trend +charts, "did this PR introduce new failures?" gates. By emitting +CodeLens findings as JUnit ```` elements, teams get all +that UI for free without writing custom integrations. + +Mapping +------- +Each CodeLens command run = one JUnit ````. +Each finding = one ```` with a ```` child. + +* ```` — one per command. +* ```` — one per finding. The testcase + name is the rule_id (e.g. ``codelens/secrets/api-key``), so CI + systems can group/dedupe by rule. +* ```` — carries the finding + message + severity as ``type``. The body is a multi-line stack + with file:line + snippet. +* Critical/high findings map to ```` (test failed). +* Medium/low/info findings map to ```` (test "skipped" — + CI treats this as non-blocking but still visible). +* Suppressed findings are omitted entirely (the user already + reviewed and dismissed them). + +Severity → JUnit mapping is conservative: only critical and high +block the build (become ````). Medium and below are +```` with a reason — visible in CI UI but non-blocking. +This matches how most teams configure their CI quality gates. + +Spec: https://llg.cubic.org/docs/junit/ (widely-implemented de-facto +standard, originally from Ant's JUnit reporter). +""" + +from __future__ import annotations + +import os +from typing import Any, Dict, List +from xml.sax.saxutils import escape, quoteattr + +from formatters.base import Finding, Severity, extract_findings + + +# JUnit spec is XML — characters must be escaped. ``xml.sax.saxutils`` +# handles ``&``, ``<``, ``>``, ``"``/``'`` for attribute values. + +# Severity → JUnit outcome +_BLOCKING_SEVERITIES = {Severity.CRITICAL, Severity.HIGH, Severity.ERROR} + + +def _format_location(finding: Finding, workspace: str = "") -> str: + """Format file:line for the failure body.""" + if not finding.file: + return "" + path = finding.file + if workspace and path.startswith(workspace): + path = os.path.relpath(path, workspace) + path = path.replace("\\", "/") + if finding.line: + path += f":{finding.line}" + if finding.column: + path += f":{finding.column}" + return path + + +def _format_failure_body(finding: Finding, workspace: str = "") -> str: + """Multi-line body for a JUnit element.""" + lines: List[str] = [] + lines.append(f"Location: {_format_location(finding, workspace)}") + if finding.category: + lines.append(f"Category: {finding.category}") + if finding.confidence: + lines.append(f"Confidence: {finding.confidence}") + if finding.cwe: + lines.append(f"CWE: {finding.cwe}") + if finding.taint_path: + lines.append(f"Taint path: {finding.taint_path}") + if finding.snippet: + lines.append("Snippet:") + # Indent snippet lines so the XML body is readable. + for snip_line in finding.snippet.splitlines(): + lines.append(f" {snip_line}") + return "\n".join(lines) + + +def format_junit_xml(data: Any, command: str = "", workspace: str = "") -> str: + """Format CodeLens output as JUnit XML. + + Args: + data: CodeLens command output dict. + command: Command name (becomes the ```` name suffix). + workspace: Workspace root (for path shortening in failure bodies). + + Returns: + Valid JUnit XML string. The output is a single ```` + root with one ```` child. Tests systems that only + expect a single ```` (older Jenkins plugins) can + grab ``root[0]``. + """ + findings = extract_findings(data, command) + + # Filter out suppressed findings — they were reviewed and dismissed. + active = [f for f in findings if not f.suppressed] + failures = [f for f in active if f.severity in _BLOCKING_SEVERITIES] + skips = [f for f in active if f.severity not in _BLOCKING_SEVERITIES] + + suite_name = f"codelens-{command or 'unknown'}" + # JUnit attributes are integers — counts must be ints. + tests_count = len(active) + failures_count = len(failures) + skipped_count = len(skips) + + lines: List[str] = [] + lines.append('') + lines.append( + f'' + ) + lines.append( + f' ' + ) + + # ─── Failures (critical/high) ─── + for f in failures: + # testcase name = rule_id (stable across runs, CI can group by it) + tc_name = f.rule_id or f.category or "codelens-finding" + lines.append( + f' ' + ) + # failure type = severity (uppercase, ASCII-safe) + failure_type = (f.severity or "failure").upper() + failure_msg = f.message or f.rule_id or "CodeLens finding" + body = _format_failure_body(f, workspace) + lines.append( + f' {escape(body)}' + ) + lines.append(' ') + + # ─── Skipped (medium/low/info) ─── + for f in skips: + tc_name = f.rule_id or f.category or "codelens-finding" + lines.append( + f' ' + ) + # with a reason — non-blocking but visible. + skip_reason = f"{f.severity}: {f.message}" + lines.append(f' ') + lines.append(' ') + + lines.append(' ') + lines.append('') + return "\n".join(lines) diff --git a/scripts/formatters/text.py b/scripts/formatters/text.py new file mode 100644 index 00000000..2c5779c9 --- /dev/null +++ b/scripts/formatters/text.py @@ -0,0 +1,155 @@ +# @WHO: scripts/formatters/text.py +# @WHAT: Text table formatter — human-readable ASCII table for terminal output (issue #52 Phase 2) +# @PART: formatters +# @ENTRY: format_text() +"""Text table formatter for CodeLens (issue #52, Phase 2). + +Human-readable table output for terminal consumption: + + RULE ID SEVERITY LOCATION MESSAGE + codelens/secrets/api-key critical src/auth.py:42:10 Hardcoded API key detected + codelens/dead-code/unreachable medium src/utils.py:128:1 Unreachable code after return + +Designed for ``--format text`` — when the user wants a quick +terminal-readable view without JSON noise. ASCII-only so it pipes +cleanly through ``grep``/``awk`` and works on Windows terminals. + +The formatter consumes :class:`formatters.base.Finding` objects via +:func:`formatters.base.extract_findings` — single extraction path, +consistent with all other Phase 2 formatters. +""" + +from __future__ import annotations + +import os +from typing import Any, Dict, List + +from formatters.base import Finding, Severity, extract_findings + + +# Column widths — tuned for 80-column terminals. Long fields are +# truncated with ellipsis. ``MESSAGE`` gets the leftover space. +_COL_WIDTHS = { + "rule_id": 32, + "severity": 9, + "location": 30, +} + +# Severity → display symbol (ASCII, not Unicode, for terminal compat). +_SEVERITY_SYMBOL = { + Severity.CRITICAL: "CRIT", + Severity.HIGH: "HIGH", + Severity.MEDIUM: "MED ", + Severity.LOW: "LOW ", + Severity.INFO: "INFO", + Severity.ERROR: "ERR ", + Severity.WARNING: "WARN", +} + + +def _truncate(text: str, width: int) -> str: + """Truncate text to width, appending ``...`` if truncated.""" + if len(text) <= width: + return text + if width <= 3: + return text[:width] + return text[: width - 3] + "..." + + +def _format_location(finding: Finding, workspace: str = "") -> str: + """Format ``file:line:column`` for the LOCATION column. + + Strips the workspace prefix to keep paths short. If ``file`` is + empty, returns ````. + """ + if not finding.file: + return "" + path = finding.file + if workspace and path.startswith(workspace): + path = os.path.relpath(path, workspace) + # Replace backslashes for cross-platform consistency. + path = path.replace("\\", "/") + + parts: List[str] = [path] + if finding.line: + parts.append(str(finding.line)) + if finding.column: + parts.append(str(finding.column)) + return ":".join(parts) + + +def format_text(data: Any, command: str = "", workspace: str = "") -> str: + """Format CodeLens output as a human-readable text table. + + Args: + data: CodeLens command output dict. + command: Command name (used in the header). + workspace: Workspace root (for path shortening). + + Returns: + Multi-line string with header + finding rows. If no findings, + returns a "no findings" message (not an empty string — empty + output looks like a bug). + """ + findings = extract_findings(data, command) + + # Split active vs suppressed — formatters' job is to surface + # actionable findings. Suppressed count is shown in the footer + # so the user knows dismissals happened, but suppressed findings + # don't get their own rows (they'd clutter the table). + active = [f for f in findings if not f.suppressed] + suppressed_count = len(findings) - len(active) + + if not active: + if isinstance(data, dict) and data.get("status") == "error": + return f"ERROR: {data.get('error', 'unknown error')}" + if suppressed_count: + return f"No active findings for command '{command or 'unknown'}' ({suppressed_count} suppressed)." + return f"No findings for command '{command or 'unknown'}'." + + # ─── Header ─── + header = ( + f"CodeLens — {len(active)} finding(s) from command '{command or 'unknown'}'" + ) + sep = "=" * 80 + + # ─── Column headers ─── + col_header = ( + f"{'RULE ID':<{_COL_WIDTHS['rule_id']}} " + f"{'SEVERITY':<{_COL_WIDTHS['severity']}} " + f"{'LOCATION':<{_COL_WIDTHS['location']}} " + f"MESSAGE" + ) + col_sep = "-" * 80 + + # ─── Rows ─── + lines: List[str] = [header, sep, col_header, col_sep] + for f in active: + rule = _truncate(f.rule_id or "", _COL_WIDTHS["rule_id"]) + sev = _truncate( + _SEVERITY_SYMBOL.get(f.severity, f.severity[:4].upper() or "UNKN"), + _COL_WIDTHS["severity"], + ) + loc = _truncate(_format_location(f, workspace), _COL_WIDTHS["location"]) + # Message gets the leftover width — don't truncate, let it wrap + # naturally (terminals handle that better than us hard-wrapping). + msg = f.message or "" + lines.append( + f"{rule:<{_COL_WIDTHS['rule_id']}} " + f"{sev:<{_COL_WIDTHS['severity']}} " + f"{loc:<{_COL_WIDTHS['location']}} " + f"{msg}" + ) + + lines.append(sep) + + # ─── Severity summary footer ─── + sev_counts: Dict[str, int] = {} + for f in active: + sev_counts[f.severity] = sev_counts.get(f.severity, 0) + 1 + summary_parts = [f"{count} {sev}" for sev, count in sorted(sev_counts.items())] + lines.append(f"Summary: {', '.join(summary_parts)}") + if suppressed_count: + lines.append(f"({suppressed_count} suppressed)") + + return "\n".join(lines) diff --git a/scripts/formatters/vim.py b/scripts/formatters/vim.py new file mode 100644 index 00000000..867f5ae1 --- /dev/null +++ b/scripts/formatters/vim.py @@ -0,0 +1,83 @@ +# @WHO: scripts/formatters/vim.py +# @WHAT: Vim quickfix formatter — file:line:col: message for :make/quickfix nav (issue #52 Phase 2) +# @PART: formatters +# @ENTRY: format_vim() +"""Vim quickfix formatter for CodeLens (issue #52, Phase 2). + +Emits findings in Vim's ``quickfix`` format:: + + file:line:col: message + +Slightly different from Emacs format — no ``severity:`` prefix +(quickfix doesn't have a native severity concept; severity goes +into the message text instead). Clicking a line in the quickfix +window jumps to the source location. + +Format spec: ``:help errorformat`` in Vim, or +https://vimdoc.sourceforge.net/htmldoc/quickfix.html#errorformat + +The format is line-oriented, one finding per line. Severity is +prefixed to the message so users see it in the quickfix window: +``file:line:col: [critical] message``. +""" + +from __future__ import annotations + +import os +from typing import Any, List + +from formatters.base import Finding, extract_findings + + +def _format_line(finding: Finding, workspace: str = "") -> str: + """Format a single finding as ``file:line:col: [severity] message``.""" + if not finding.file: + path = "" + else: + path = finding.file + if workspace and path.startswith(workspace): + path = os.path.relpath(path, workspace) + path = path.replace("\\", "/") + + if finding.line: + loc = f"{path}:{finding.line}" + if finding.column: + loc += f":{finding.column}" + else: + loc = path + + # Severity goes into the message — quickfix has no native severity + # field. Brackets make it visually distinct without being noisy. + severity_tag = "" + if finding.severity: + severity_tag = f"[{finding.severity}] " + + message = finding.message or finding.rule_id or "CodeLens finding" + return f"{loc}: {severity_tag}{message}" + + +def format_vim(data: Any, command: str = "", workspace: str = "") -> str: + """Format CodeLens output for Vim ``quickfix``. + + Args: + data: CodeLens command output dict. + command: Command name (unused — kept for API consistency). + workspace: Workspace root (for path shortening). + + Returns: + One line per finding, no header/footer. Empty string if no + findings — Vim handles empty quickfix gracefully (``:copen`` + shows an empty list). + """ + findings = extract_findings(data, command) + + active = [f for f in findings if not f.suppressed] + + if not active: + # Return empty — Vim users typically pipe output directly to + # ``:cgetexpr`` or ``:caddexpr``, which prefer no output over + # a comment line (which would appear as a parse-failed entry). + return "" + + lines: List[str] = [_format_line(f, workspace) for f in active] + return "\n".join(lines) diff --git a/tests/test_formatters_base.py b/tests/test_formatters_base.py new file mode 100644 index 00000000..98443972 --- /dev/null +++ b/tests/test_formatters_base.py @@ -0,0 +1,307 @@ +"""Tests for Phase 1 (Finding dataclass + extract_findings) of issue #52. + +Covers: + +* :class:`formatters.base.Finding` dataclass construction and ``to_dict()`` +* :func:`formatters.base.extract_findings` — extracting findings from + heterogeneous engine outputs +* :func:`formatters.base._normalize_severity` — engine severity → canonical +* :func:`formatters.base._normalize_finding_dict` — engine finding dict → Finding + +The extraction logic is the heart of Phase 1 — every Phase 2 formatter +depends on it producing consistent :class:`Finding` objects from the +many different shapes engines emit. +""" + +from __future__ import annotations + +import os +import sys + +import pytest + +SCRIPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "scripts", +) +if SCRIPTS_DIR not in sys.path: + sys.path.insert(0, SCRIPTS_DIR) + +from formatters.base import ( # noqa: E402 + Finding, + Severity, + extract_findings, + findings_to_dicts, + _normalize_severity, + _normalize_finding_dict, +) + + +# ─── Finding dataclass ───────────────────────────────────────── + + +class TestFindingDataclass: + """Verify Finding construction and to_dict().""" + + def test_minimal_construction(self): + f = Finding(message="test") + assert f.message == "test" + assert f.severity == Severity.UNKNOWN + assert f.file == "" + assert f.line == 0 + + def test_full_construction(self): + f = Finding( + message="Hardcoded API key", + severity=Severity.CRITICAL, + file="src/auth.py", + line=42, + column=10, + rule_id="codelens/secrets/api-key", + category="api_key", + command="secrets", + cwe="CWE-798", + snippet="sk_live_abc123", + ) + assert f.severity == Severity.CRITICAL + assert f.line == 42 + assert f.cwe == "CWE-798" + + def test_to_dict_omits_empty_fields(self): + """to_dict() should omit empty/zero fields to keep JSON compact.""" + f = Finding(message="test", severity="medium", file="x.py", line=10) + d = f.to_dict() + assert d["message"] == "test" + assert d["severity"] == "medium" + assert d["file"] == "x.py" + assert d["line"] == 10 + # Empty fields omitted. + assert "column" not in d + assert "cwe" not in d + assert "snippet" not in d + assert "suppressed" not in d # False is omitted + + def test_to_dict_keeps_severity_and_message_even_if_empty(self): + """severity and message are required — always present in to_dict.""" + f = Finding(message="") + d = f.to_dict() + assert "severity" in d + assert "message" in d + + def test_to_dict_extras_merged_into_top_level(self): + """extras dict is merged into the output, not nested under 'extras'.""" + f = Finding(message="test", extras={"custom_field": "value", "n": 42}) + d = f.to_dict() + assert d["custom_field"] == "value" + assert d["n"] == 42 + assert "extras" not in d # extras itself is not in output + + +# ─── Severity normalization ──────────────────────────────────── + + +class TestNormalizeSeverity: + """Verify engine-specific severity strings are normalized to canonical.""" + + @pytest.mark.parametrize("raw,expected", [ + # Direct canonical + ("critical", Severity.CRITICAL), + ("CRITICAL", Severity.CRITICAL), # case-insensitive + ("Critical", Severity.CRITICAL), + ("high", Severity.HIGH), + ("medium", Severity.MEDIUM), + ("low", Severity.LOW), + ("info", Severity.INFO), + # Aliases + ("error", Severity.CRITICAL), # error → critical (blocking) + ("fatal", Severity.CRITICAL), + ("blocker", Severity.CRITICAL), + ("warning", Severity.MEDIUM), + ("warn", Severity.MEDIUM), + ("moderate", Severity.MEDIUM), + ("informational", Severity.LOW), + ("note", Severity.LOW), + ("hint", Severity.LOW), + ("trivial", Severity.LOW), + # Edge cases + ("", Severity.UNKNOWN), + (None, Severity.UNKNOWN), + ("unknown_severity", Severity.UNKNOWN), + ("very_high", Severity.UNKNOWN), # not a recognized alias + ]) + def test_severity_normalization(self, raw, expected): + assert _normalize_severity(raw) == expected + + +# ─── extract_findings ────────────────────────────────────────── + + +class TestExtractFindings: + """Verify extraction from various engine output shapes.""" + + def test_empty_data_returns_empty_list(self): + assert extract_findings(None) == [] + assert extract_findings("not a dict") == [] + assert extract_findings({}) == [] + + def test_error_status_returns_empty(self): + """Error responses have no findings.""" + data = {"status": "error", "error": "boom", "findings": [{"file": "x.py"}]} + assert extract_findings(data, "secrets") == [] + + def test_plain_findings_list(self): + data = { + "findings": [ + {"file": "a.py", "line": 10, "severity": "critical", "message": "API key"}, + {"file": "b.py", "line": 20, "severity": "medium", "message": "webhook"}, + ], + } + findings = extract_findings(data, "secrets") + assert len(findings) == 2 + assert all(isinstance(f, Finding) for f in findings) + assert findings[0].file == "a.py" + assert findings[0].line == 10 + assert findings[0].severity == Severity.CRITICAL + assert findings[0].command == "secrets" + + def test_alternative_list_keys(self): + """Engines use different keys: findings, leaks, hints, issues, etc.""" + for key in ("findings", "leaks", "hints", "issues", "violations", "matches", "chains", "results"): + data = {key: [{"file": "x.py", "line": 1, "message": "test"}]} + findings = extract_findings(data, "cmd") + assert len(findings) == 1, f"failed for key={key}" + + def test_category_keyed_dict(self): + """by_category dict of lists — dead-code, smell pattern.""" + data = { + "by_category": { + "unreachable": [ + {"file": "x.py", "line": 10, "message": "unreachable code"}, + ], + "unused_variable": [ + {"file": "y.py", "line": 20, "message": "unused var"}, + ], + }, + } + findings = extract_findings(data, "dead-code") + assert len(findings) == 2 + categories = {f.category for f in findings} + assert categories == {"unreachable", "unused_variable"} + + def test_dedup_across_list_and_dict(self): + """Same finding in both 'findings' and 'by_category' → only one Finding.""" + finding_dict = {"file": "x.py", "line": 10, "category": "api_key", "message": "key"} + data = { + "findings": [finding_dict], + "by_category": {"api_key": [finding_dict]}, + } + findings = extract_findings(data, "secrets") + assert len(findings) == 1 # deduplicated + + def test_rule_id_synthesized_from_command_and_category(self): + """When engine doesn't provide rule_id, synthesize codelens//.""" + data = { + "findings": [{"file": "x.py", "line": 1, "category": "api_key", "message": "test"}], + } + findings = extract_findings(data, "secrets") + assert findings[0].rule_id == "codelens/secrets/api-key" + + def test_existing_rule_id_preserved(self): + """If engine provides rule_id, don't overwrite.""" + data = { + "findings": [{"file": "x.py", "line": 1, "rule_id": "custom-rule-001", "message": "test"}], + } + findings = extract_findings(data, "secrets") + assert findings[0].rule_id == "custom-rule-001" + + def test_field_aliases(self): + """Engines use different field names for the same concept.""" + data = { + "findings": [{ + "defined_in": "src/x.py", # alias for file + "line_number": 42, # alias for line + "col": 10, # alias for column + "risk": "high", # alias for severity + "name": "test finding", # alias for message + "type": "crypto", # alias for category + }], + } + findings = extract_findings(data, "secrets") + f = findings[0] + assert f.file == "src/x.py" + assert f.line == 42 + assert f.column == 10 + assert f.severity == Severity.HIGH + assert f.message == "test finding" + assert f.category == "crypto" + + def test_extras_preserved(self): + """Non-canonical fields go into extras and survive to_dict().""" + data = { + "findings": [{ + "file": "x.py", "line": 1, "message": "test", + "custom_field": "value", + "another_extra": 42, + }], + } + findings = extract_findings(data, "secrets") + f = findings[0] + assert f.extras.get("custom_field") == "value" + assert f.extras.get("another_extra") == 42 + # And survives to_dict() + d = f.to_dict() + assert d["custom_field"] == "value" + assert d["another_extra"] == 42 + + def test_suppressed_finding_extracted_with_flag(self): + data = { + "findings": [{ + "file": "x.py", "line": 1, "message": "test", + "suppressed": True, + "suppressed_reason": "false positive", + }], + } + findings = extract_findings(data, "secrets") + assert len(findings) == 1 + assert findings[0].suppressed is True + assert findings[0].suppressed_reason == "false positive" + + def test_taint_specific_fields(self): + data = { + "chains": [{ + "file": "x.py", "line": 1, "message": "SQLi", + "source": "request.input", + "sink": "cursor.execute", + "taint_path": "request.input → format_sql → cursor.execute", + }], + } + findings = extract_findings(data, "taint") + f = findings[0] + assert f.source == "request.input" + assert f.sink == "cursor.execute" + assert "request.input" in f.taint_path + + def test_non_dict_finding_in_list_skipped(self): + """Defensive: non-dict items in findings list don't crash.""" + data = {"findings": [{"file": "x.py", "line": 1, "message": "real"}, "not a dict", 42, None]} + findings = extract_findings(data, "secrets") + assert len(findings) == 1 # only the dict one extracted + + +# ─── Round-trip: Finding → dict → Finding ────────────────────── + + +class TestRoundTrip: + """findings_to_dicts produces dicts that match Finding.to_dict().""" + + def test_findings_to_dicts_returns_list_of_dicts(self): + findings = [ + Finding(message="a", severity="critical", file="x.py", line=1), + Finding(message="b", severity="medium", file="y.py", line=2), + ] + result = findings_to_dicts(findings) + assert isinstance(result, list) + assert len(result) == 2 + assert all(isinstance(d, dict) for d in result) + assert result[0]["message"] == "a" + assert result[1]["file"] == "y.py" diff --git a/tests/test_formatters_phase2.py b/tests/test_formatters_phase2.py new file mode 100644 index 00000000..bc4e21fc --- /dev/null +++ b/tests/test_formatters_phase2.py @@ -0,0 +1,483 @@ +"""Tests for Phase 2 formatters (issue #52): text, junit-xml, emacs, vim, gitlab-sast. + +Covers: + +* :func:`formatters.text.format_text` — human-readable table +* :func:`formatters.junit_xml.format_junit_xml` — JUnit XML for CI +* :func:`formatters.emacs.format_emacs` — compile-mode format +* :func:`formatters.vim.format_vim` — quickfix format +* :func:`formatters.gitlab_sast.format_gitlab_sast` — GitLab SAST JSON +* Integration: ``format_output(data, "")`` dispatches correctly + +Each formatter test verifies: + +1. **Output shape** — basic structure (XML validity, JSON validity, line count). +2. **Field mapping** — severity → format-specific level, file:line in the right place. +3. **Empty findings** — graceful handling (not crash, not empty string). +4. **Suppressed findings** — omitted from output (formatters' job is to surface actionable, not dismissed). +5. **Workspace path shortening** — absolute paths converted to relative. +""" + +from __future__ import annotations + +import json +import os +import sys +import xml.etree.ElementTree as ET + +import pytest + +SCRIPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "scripts", +) +if SCRIPTS_DIR not in sys.path: + sys.path.insert(0, SCRIPTS_DIR) + +from formatters import format_output # noqa: E402 +from formatters.base import Finding, Severity, extract_findings # noqa: E402 +from formatters.text import format_text # noqa: E402 +from formatters.junit_xml import format_junit_xml # noqa: E402 +from formatters.emacs import format_emacs # noqa: E402 +from formatters.vim import format_vim # noqa: E402 +from formatters.gitlab_sast import format_gitlab_sast # noqa: E402 + + +# ─── Fixtures ────────────────────────────────────────────────── + + +@pytest.fixture +def secrets_data(): + """Synthetic ``secrets`` command output with 3 findings of varying severity.""" + return { + "status": "ok", + "command": "secrets", + "findings": [ + { + "file": "src/auth.py", "line": 42, "column": 10, + "severity": "critical", "category": "api_key", + "message": "Hardcoded API key detected", + "match": "sk_live_abc123", "cwe": "CWE-798", + }, + { + "file": "src/utils.py", "line": 128, + "severity": "medium", "category": "webhook", + "message": "Webhook URL with credentials", + "match": "https://user:pass@hook.com", + }, + { + "file": "src/config.py", "line": 5, + "severity": "low", "category": "info", + "message": "Informational note about config", + }, + ], + "stats": {"total": 3, "critical": 1, "medium": 1, "low": 1}, + } + + +@pytest.fixture +def suppressed_data(): + """Findings with one suppressed — formatters should omit suppressed.""" + return { + "status": "ok", + "command": "secrets", + "findings": [ + { + "file": "a.py", "line": 1, "severity": "critical", + "message": "active finding", + }, + { + "file": "b.py", "line": 2, "severity": "high", + "message": "suppressed finding", + "suppressed": True, + "suppressed_reason": "false positive", + }, + ], + } + + +@pytest.fixture +def empty_data(): + """No findings — empty result.""" + return { + "status": "ok", + "command": "secrets", + "findings": [], + } + + +@pytest.fixture +def error_data(): + """Error response — formatters should handle gracefully.""" + return { + "status": "error", + "command": "secrets", + "error": "scan failed", + } + + +# ─── text formatter ──────────────────────────────────────────── + + +class TestTextFormatter: + """Human-readable table output.""" + + def test_renders_header_and_rows(self, secrets_data): + out = format_text(secrets_data, "secrets") + assert "CodeLens" in out + assert "3 finding(s)" in out + assert "Hardcoded API key detected" in out + assert "Webhook URL with credentials" in out + # Severity symbols appear + assert "CRIT" in out + assert "MED" in out + assert "LOW" in out + + def test_includes_severity_summary(self, secrets_data): + out = format_text(secrets_data, "secrets") + assert "Summary:" in out + assert "1 critical" in out + assert "1 medium" in out + + def test_empty_findings_returns_message(self, empty_data): + out = format_text(empty_data, "secrets") + assert "No findings" in out + + def test_error_data_returns_error_message(self, error_data): + out = format_text(error_data, "secrets") + assert "ERROR" in out + assert "scan failed" in out + + def test_suppressed_findings_omitted_but_counted(self, suppressed_data): + out = format_text(suppressed_data, "secrets") + # Active finding shows. + assert "active finding" in out + # Suppressed finding does NOT show in rows. + assert "suppressed finding" not in out + # But suppressed count is mentioned. + assert "1 suppressed" in out + + def test_workspace_path_shortened(self, secrets_data, tmp_path): + """Absolute paths under workspace get shortened to relative.""" + workspace = str(tmp_path) + data = { + "findings": [{ + "file": os.path.join(workspace, "src/x.py"), + "line": 1, "severity": "low", "message": "test", + }], + } + out = format_text(data, "test", workspace) + assert "src/x.py" in out + # Full path should NOT appear (it would be too long for the column). + assert workspace not in out + + +# ─── junit-xml formatter ─────────────────────────────────────── + + +class TestJunitXmlFormatter: + """JUnit XML for Jenkins/GitLab CI.""" + + def test_produces_valid_xml(self, secrets_data): + out = format_junit_xml(secrets_data, "secrets") + # Must be parseable as XML. + root = ET.fromstring(out) + assert root.tag == "testsuites" + assert root.get("tests") == "3" + + def test_has_testsuite_with_correct_counts(self, secrets_data): + out = format_junit_xml(secrets_data, "secrets") + root = ET.fromstring(out) + suite = root.find("testsuite") + assert suite is not None + assert suite.get("tests") == "3" + # 1 critical → failure; 1 medium + 1 low → skipped + assert suite.get("failures") == "1" + assert suite.get("disabled") == "2" + + def test_critical_finding_is_failure(self, secrets_data): + out = format_junit_xml(secrets_data, "secrets") + root = ET.fromstring(out) + failures = root.findall(".//failure") + assert len(failures) == 1 + # Failure message carries the finding message. + assert "Hardcoded API key" in failures[0].get("message", "") + + def test_medium_finding_is_skipped(self, secrets_data): + out = format_junit_xml(secrets_data, "secrets") + root = ET.fromstring(out) + skips = root.findall(".//skipped") + assert len(skips) == 2 # medium + low + + def test_failure_body_includes_location(self, secrets_data): + out = format_junit_xml(secrets_data, "secrets") + # Body text should include file:line. + assert "src/auth.py:42" in out + + def test_xml_escapes_special_characters(self): + """XML special chars in messages must be escaped.""" + data = { + "findings": [{ + "file": "x.py", "line": 1, "severity": "critical", + "message": 'Use of & other bad stuff', + }], + } + out = format_junit_xml(data, "secrets") + # Should parse without error. + root = ET.fromstring(out) + # And the message should be preserved after parsing. + failure = root.find(".//failure") + assert "script" in failure.get("message", "") + + def test_empty_findings_produces_valid_empty_xml(self, empty_data): + out = format_junit_xml(empty_data, "secrets") + root = ET.fromstring(out) + assert root.get("tests") == "0" + assert root.get("failures") == "0" + + def test_suppressed_findings_omitted(self, suppressed_data): + out = format_junit_xml(suppressed_data, "secrets") + root = ET.fromstring(out) + # Only the active finding, not the suppressed one. + assert root.get("tests") == "1" + assert "suppressed finding" not in out + + +# ─── emacs formatter ─────────────────────────────────────────── + + +class TestEmacsFormatter: + """compile-mode format: ``file:line:col: level: message``.""" + + def test_one_line_per_finding(self, secrets_data): + out = format_emacs(secrets_data, "secrets") + lines = [l for l in out.splitlines() if l.strip()] + assert len(lines) == 3 + + def test_format_matches_emacs_convention(self, secrets_data): + out = format_emacs(secrets_data, "secrets") + # First line should be: src/auth.py:42:10: error: Hardcoded API key detected + first_line = out.splitlines()[0] + assert "src/auth.py:42:10" in first_line + assert "error" in first_line # critical → error + assert "Hardcoded API key detected" in first_line + + def test_severity_to_level_mapping(self, secrets_data): + out = format_emacs(secrets_data, "secrets") + lines = out.splitlines() + # critical → error, medium → warning, low → note + assert "error" in lines[0] + assert "warning" in lines[1] + assert "note" in lines[2] + + def test_empty_findings_returns_informative_line(self, empty_data): + out = format_emacs(empty_data, "secrets") + assert "no findings" in out.lower() + + def test_suppressed_findings_omitted(self, suppressed_data): + out = format_emacs(suppressed_data, "secrets") + assert "active finding" in out + assert "suppressed finding" not in out + + def test_no_column_omits_col(self): + """When column=0, omit it from the location.""" + data = { + "findings": [{ + "file": "x.py", "line": 10, "severity": "low", + "message": "test", + }], + } + out = format_emacs(data, "secrets") + # Should be x.py:10: warning: test (no trailing :0) + line = out.splitlines()[0] + # Split by " : " — the location part should be just "x.py:10" + assert "x.py:10:" in line + assert "x.py:10:0" not in line + + +# ─── vim formatter ───────────────────────────────────────────── + + +class TestVimFormatter: + """quickfix format: ``file:line:col: [severity] message``.""" + + def test_one_line_per_finding(self, secrets_data): + out = format_vim(secrets_data, "secrets") + lines = [l for l in out.splitlines() if l.strip()] + assert len(lines) == 3 + + def test_format_matches_vim_convention(self, secrets_data): + out = format_vim(secrets_data, "secrets") + first_line = out.splitlines()[0] + assert "src/auth.py:42:10" in first_line + # Severity in brackets + assert "critical" in first_line + assert "Hardcoded API key detected" in first_line + + def test_empty_findings_returns_empty_string(self, empty_data): + """Vim prefers empty output over a comment line.""" + out = format_vim(empty_data, "secrets") + assert out == "" + + def test_suppressed_findings_omitted(self, suppressed_data): + out = format_vim(suppressed_data, "secrets") + assert "active finding" in out + assert "suppressed finding" not in out + + def test_no_column_omits_col(self): + data = { + "findings": [{ + "file": "x.py", "line": 10, "severity": "low", + "message": "test", + }], + } + out = format_vim(data, "secrets") + line = out.splitlines()[0] + assert "x.py:10:" in line + assert "x.py:10:0" not in line + + +# ─── gitlab-sast formatter ───────────────────────────────────── + + +class TestGitlabSastFormatter: + """GitLab SAST JSON for security dashboard.""" + + def test_produces_valid_json(self, secrets_data): + out = format_gitlab_sast(secrets_data, "secrets") + data = json.loads(out) + assert isinstance(data, dict) + assert "version" in data + assert "vulnerabilities" in data + assert "scan" in data + + def test_vulnerabilities_count_matches(self, secrets_data): + out = format_gitlab_sast(secrets_data, "secrets") + data = json.loads(out) + assert len(data["vulnerabilities"]) == 3 + + def test_severity_mapping_to_gitlab_enum(self, secrets_data): + out = format_gitlab_sast(secrets_data, "secrets") + data = json.loads(out) + sevs = [v["severity"] for v in data["vulnerabilities"]] + # critical → Critical, medium → Medium, low → Low + assert "Critical" in sevs + assert "Medium" in sevs + assert "Low" in sevs + + def test_vulnerability_has_required_fields(self, secrets_data): + out = format_gitlab_sast(secrets_data, "secrets") + data = json.loads(out) + v = data["vulnerabilities"][0] + # GitLab Secure spec required fields. + for field in ("id", "category", "name", "message", "cve", + "severity", "confidence", "scanner", "location", "identifiers"): + assert field in v, f"missing required field: {field}" + assert v["category"] == "sast" + assert v["scanner"]["id"] == "codelens" + + def test_stable_id_deterministic(self, secrets_data): + """Same finding → same ID across runs (GitLab dedupes by ID).""" + out1 = format_gitlab_sast(secrets_data, "secrets") + out2 = format_gitlab_sast(secrets_data, "secrets") + id1 = json.loads(out1)["vulnerabilities"][0]["id"] + id2 = json.loads(out2)["vulnerabilities"][0]["id"] + assert id1 == id2 + + def test_cwe_added_to_identifiers(self, secrets_data): + """Finding with CWE → cwe identifier added.""" + out = format_gitlab_sast(secrets_data, "secrets") + data = json.loads(out) + v = data["vulnerabilities"][0] # the API key finding has CWE-798 + id_types = [i["type"] for i in v["identifiers"]] + assert "cwe" in id_types + cwe_id = next(i for i in v["identifiers"] if i["type"] == "cwe") + assert cwe_id["name"] == "CWE-798" + + def test_empty_findings_produces_empty_vulnerabilities(self, empty_data): + out = format_gitlab_sast(empty_data, "secrets") + data = json.loads(out) + assert data["vulnerabilities"] == [] + # Scan metadata still present. + assert data["scan"]["status"] == "success" + + def test_suppressed_findings_omitted(self, suppressed_data): + out = format_gitlab_sast(suppressed_data, "secrets") + data = json.loads(out) + assert len(data["vulnerabilities"]) == 1 + assert data["vulnerabilities"][0]["message"] == "active finding" + + def test_scan_metadata_has_codelens_scanner(self, secrets_data): + out = format_gitlab_sast(secrets_data, "secrets") + data = json.loads(out) + assert data["scan"]["scanner"]["id"] == "codelens" + assert data["scan"]["scanner"]["name"] == "CodeLens" + assert data["scan"]["type"] == "sast" + + +# ─── Integration: format_output dispatches to new formatters ─── + + +class TestFormatOutputDispatch: + """Verify format_output() routes to the new Phase 2 formatters.""" + + @pytest.mark.parametrize("fmt,expected_substring", [ + ("text", "CodeLens"), + ("junit-xml", " --format `` via subprocess.""" + + def _run_cli(self, fmt): + env = os.environ.copy() + env["PYTHONPATH"] = SCRIPTS_DIR + return subprocess.run( + [sys.executable, os.path.join(SCRIPTS_DIR, "codelens.py"), + "secrets", "--format", fmt, "--help"], + capture_output=True, text=True, env=env, timeout=30, + ) + + def test_help_lists_new_formats(self): + """``codelens secrets --help`` should list the 5 new format choices.""" + result = self._run_cli("text") + # The --help output should mention all 5 new formats. + for fmt in ("text", "junit-xml", "emacs", "vim", "gitlab-sast"): + assert fmt in result.stdout, f"format {fmt} not in --help output" + + +import subprocess # noqa: E402 — used in TestCLISmoke above