diff --git a/azure-pipelines-publish-results.yml b/azure-pipelines-publish-results.yml index 66262a3..ad0d223 100644 --- a/azure-pipelines-publish-results.yml +++ b/azure-pipelines-publish-results.yml @@ -62,19 +62,31 @@ steps: mergeTestResults: true failTaskOnFailedTests: false - # ---- Surface 2: Build summary tab ------------------------------------ + # ---- Surface 2: Build summary tab + Surface 3: rich report artifact ---- + # Generates Markdown (for the summary tab), HTML (rich interactive report), + # and CSV (Excel-friendly export) from a single cycode-results.json. The + # HTML + CSV + raw JSON are bundled into one 'cycode-report' artifact. - script: | - python3 scripts/cycode-summary.py cycode-results.json > cycode-summary.md - echo "##vso[task.uploadsummary]$(System.DefaultWorkingDirectory)/cycode-summary.md" - displayName: 'Publish "Cycode Scan Summary" tab' + set -e + mkdir -p cycode-report + cp cycode-results.json cycode-report/cycode-results.json + python3 scripts/cycode-summary.py cycode-results.json \ + --md cycode-report/cycode-summary.md \ + --html cycode-report/cycode-report.html \ + --csv cycode-report/cycode-report.csv + ls -la cycode-report/ + echo "##vso[task.uploadsummary]$(System.DefaultWorkingDirectory)/cycode-report/cycode-summary.md" + displayName: 'Build reports (Markdown summary, HTML, CSV) + publish summary tab' condition: succeededOrFailed() + env: + # Optional: override console base URL if your tenant uses a custom domain. + CYCODE_CONSOLE_URL: "https://app.cycode.com" - # ---- Surface 3: Downloadable artifact -------------------------------- - task: PublishBuildArtifacts@1 - displayName: "Publish raw Cycode JSON" + displayName: "Publish Cycode report bundle (JSON + HTML + CSV)" condition: succeededOrFailed() inputs: - pathToPublish: "cycode-results.json" + pathToPublish: "cycode-report" artifactName: "cycode-report" # ---- Final gate ------------------------------------------------------- diff --git a/scripts/cycode-summary.py b/scripts/cycode-summary.py index 8f8c73e..df289f4 100755 --- a/scripts/cycode-summary.py +++ b/scripts/cycode-summary.py @@ -1,68 +1,511 @@ #!/usr/bin/env python3 -"""Generate a short Markdown summary of a Cycode JSON scan result. +"""Generate Markdown + HTML + CSV reports from Cycode CLI JSON scan output. -The output is used with `##vso[task.uploadsummary]` so the summary appears -as a tab on the Azure Pipelines build summary page. +Produces up to three output formats from a single `cycode -o json scan` file: + + * Markdown — short summary suitable for Azure Pipelines' `task.uploadsummary`. + Written to stdout (or --md FILE). + * HTML — rich, self-contained interactive report (filterable, expandable + descriptions, severity badges). Written to --html FILE. + * CSV — flat, Excel-friendly export of every finding with the 7 columns + requested by customers. Written to --csv FILE. Usage: - cycode -o json scan -t sast path ./src > cycode.json - cycode-summary.py cycode.json > cycode-summary.md + cycode-summary.py cycode.json # Markdown to stdout + cycode-summary.py cycode.json \ + --md cycode-summary.md \ + --html cycode-report.html \ + --csv cycode-report.csv + +Column mapping (HTML + CSV): + Issue Name — detection_details.policy_display_name + Issue Description — detection_details.description (falls back to top-level message) + Where — detection_details.line / start_position + File — detection_details.file_path (agent workspace prefix stripped) + Metadata — severity, type, CWE, OWASP, category, language(s) + Mitigation — detection_details.remediation_guidelines (Markdown from platform) + Ref URL — /policies/ + (override base with CYCODE_CONSOLE_URL env var or --console-url) """ from __future__ import annotations +import argparse +import csv +import html import json +import os +import re import sys +from typing import Any + +SEVERITY_ORDER = ["Critical", "High", "Medium", "Low", "Info", "Unknown"] + +SEVERITY_COLOR = { + "Critical": "#b71c1c", + "High": "#e65100", + "Medium": "#f9a825", + "Low": "#1565c0", + "Info": "#546e7a", + "Unknown": "#37474f", +} -def extract_detections(data): - detections = [] +def extract_detections(data: Any) -> list[dict]: + detections: list[dict] = [] if isinstance(data, dict): - for block in data.get("scan_results", []) or []: - detections.extend(block.get("detections", []) or []) - detections.extend(data.get("detections", []) or []) + for block in data.get("scan_results") or []: + detections.extend(block.get("detections") or []) + detections.extend(data.get("detections") or []) elif isinstance(data, list): detections = list(data) return detections -def main(src: str) -> int: - with open(src) as f: - data = json.load(f) +def normalize_file_path(path: str) -> str: + """Strip ADO / GitHub Actions agent workspace prefixes for readability.""" + if not path: + return "" + # ADO self-hosted / hosted: /_work//s/ + m = re.search(r"/_work/\d+/s/(.+)$", path) + if m: + return m.group(1) + # GitHub Actions: /home/runner/work/// + m = re.search(r"/runner/work/[^/]+/[^/]+/(.+)$", path) + if m: + return m.group(1) + return path.lstrip("/") - detections = extract_detections(data) + +def row_from_detection(d: dict) -> dict: + dd = d.get("detection_details") or {} + cwe = dd.get("cwe") or [] + owasp = dd.get("owasp") or [] + langs = dd.get("languages") or [] + metadata_parts = [] + if cwe: + metadata_parts.append("CWE: " + "; ".join(cwe)) + if owasp: + metadata_parts.append("OWASP: " + "; ".join(owasp)) + if dd.get("category"): + metadata_parts.append(f"Category: {dd['category']}") + if langs: + metadata_parts.append("Languages: " + ", ".join(langs)) + return { + "severity": d.get("severity") or "Unknown", + "type": d.get("type") or "?", + "issue_name": dd.get("policy_display_name") or d.get("detection_rule_id") or "Unnamed finding", + "description": (dd.get("description") or d.get("message") or "").strip(), + "file": normalize_file_path(dd.get("file_path") or d.get("file_path") or ""), + "line": dd.get("line") or d.get("line") or "", + "metadata": " | ".join(metadata_parts), + "cwe": "; ".join(cwe), + "owasp": "; ".join(owasp), + "category": dd.get("category") or "", + "languages": ", ".join(langs), + "remediation": (dd.get("remediation_guidelines") or dd.get("custom_remediation_guidelines") or "").strip(), + "detection_rule_id": dd.get("detection_rule_id") or "", + "policy_id": dd.get("policy_id") or "", + "id": d.get("id") or "", + } + + +def console_url(row: dict, base_url: str) -> str: + base = base_url.rstrip("/") + rule_id = row.get("detection_rule_id") or row.get("policy_id") + if rule_id: + return f"{base}/policies/{rule_id}" + return base + + +def count_by_severity(rows: list[dict]) -> dict[str, int]: counts: dict[str, int] = {} - for d in detections: - sev = d.get("severity") or "Unknown" - counts[sev] = counts.get(sev, 0) + 1 + for r in rows: + counts[r["severity"]] = counts.get(r["severity"], 0) + 1 + return counts + + +def severity_sort_key(row: dict) -> tuple: + try: + idx = SEVERITY_ORDER.index(row["severity"]) + except ValueError: + idx = len(SEVERITY_ORDER) + return (idx, row["type"], row["file"], row["line"] or 0) + - lines = [] +# ----------------------------- Markdown ----------------------------------- + +def render_markdown(rows: list[dict], base_url: str, artifact_hint: str = "") -> str: + counts = count_by_severity(rows) + lines: list[str] = [] lines.append("## Cycode Scan Summary") lines.append("") - lines.append(f"**Total findings:** {len(detections)}") + lines.append(f"**Total findings:** {len(rows)}") lines.append("") lines.append("| Severity | Count |") lines.append("|---|---|") - for sev in ["Critical", "High", "Medium", "Low", "Info", "Unknown"]: + for sev in SEVERITY_ORDER: if counts.get(sev): lines.append(f"| {sev} | {counts[sev]} |") lines.append("") - if detections: - lines.append("### Top findings") - for d in detections[:10]: - dd = d.get("detection_details") or {} - path = dd.get("file_path") or d.get("file_path") or "?" - line = dd.get("line") or d.get("line") or "" - msg = (d.get("message") or d.get("detection_rule_id") or "")[:120] - loc = f"`{path}:{line}`" if line else f"`{path}`" - lines.append(f"- **[{d.get('severity', '?')}]** {msg} — {loc}") - - print("\n".join(lines)) + if artifact_hint: + lines.append(f"> Full HTML + CSV reports are published as the **{artifact_hint}** artifact on this build.") + lines.append("") + + if rows: + lines.append("### Top findings (by severity)") + lines.append("") + for r in rows[:15]: + loc = f"`{r['file']}:{r['line']}`" if r["line"] else f"`{r['file']}`" + url = console_url(r, base_url) + desc = r["description"][:200] + lines.append( + f"- **[{r['severity']}]** {r['issue_name']} — {loc} \n" + f" {desc}{'...' if len(r['description']) > 200 else ''} \n" + f" [↗ Console]({url})" + ) + lines.append("") + return "\n".join(lines) + + +# ------------------------------- HTML ------------------------------------- + +HTML_TEMPLATE = """ + + + +Cycode Scan Report + + + +

Cycode Scan Report

+
Generated {generated_at} · {total} finding{plural} across {scan_types}
+ +
+ {summary_cards} +
+ +
+ + + + +
+ + + + + + + + + + + + + + {rows} + +
SeverityIssue NameDescription & MitigationFile & LineMetadataConsole
+ +
+ Report generated from cycode -o json scan output. + Console base: {console_base}. + For full platform-side tracking (triaged state, reachability, custom policies), + connect this repository to Cycode via the SCM integration. +
+ + + + +""" + + +def render_html(rows: list[dict], base_url: str, scan_types: list[str]) -> str: + import datetime + + counts = count_by_severity(rows) + present_sevs = [s for s in SEVERITY_ORDER if counts.get(s)] + present_types = sorted({r["type"] for r in rows if r.get("type")}) + + summary_cards = ['
Total
' + f'
{len(rows)}
'] + for sev in present_sevs: + color = SEVERITY_COLOR.get(sev, "#000") + summary_cards.append( + f'
{html.escape(sev)}
' + f'
{counts[sev]}
' + ) + + severity_options = "\n ".join( + f'' for s in present_sevs + ) + type_options = "\n ".join( + f'' for t in present_types + ) + + row_html_parts: list[str] = [] + for r in rows: + sev_color = SEVERITY_COLOR.get(r["severity"], "#000") + url = console_url(r, base_url) + location = html.escape(r["file"] or "unknown") + line_html = f'
Line {html.escape(str(r["line"]))}
' if r["line"] else "" + meta_parts = [] + if r["cwe"]: meta_parts.append(f"
CWE: {html.escape(r['cwe'])}
") + if r["owasp"]: meta_parts.append(f"
OWASP: {html.escape(r['owasp'])}
") + if r["category"]: meta_parts.append(f"
Category: {html.escape(r['category'])}
") + if r["languages"]: meta_parts.append(f"
Language: {html.escape(r['languages'])}
") + metadata_html = "".join(meta_parts) or '' + + # Description + Mitigation combined in one cell with expandable
+ description_full = r["description"] + remediation = r["remediation"] + desc_short = description_full[:180] + ("..." if len(description_full) > 180 else "") + desc_cell_parts = [f'
{html.escape(desc_short)}
'] + if description_full and len(description_full) > 180: + desc_cell_parts.append( + f'
Full description' + f'
{html.escape(description_full)}
' + ) + if remediation: + desc_cell_parts.append( + f'
Mitigation guidance' + f'
{html.escape(remediation)}
' + ) + + # Build a searchable haystack for client-side filtering + haystack = " ".join([ + r["severity"], r["type"], r["issue_name"], r["description"], + r["file"], str(r["line"]), r["cwe"], r["owasp"], + r["category"], r["languages"], + ]).lower() + + row_html_parts.append( + f'' + f'{html.escape(r["severity"])}' + f'
{html.escape(r["type"])}
' + f'{html.escape(r["issue_name"])}' + f'{"".join(desc_cell_parts)}' + f'
{location}
{line_html}' + f'{metadata_html}' + f'View ↗' + f'' + ) + + if not row_html_parts: + row_html_parts.append('No findings.') + + return HTML_TEMPLATE.format( + generated_at=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + total=len(rows), + plural="" if len(rows) == 1 else "s", + scan_types=", ".join(scan_types) if scan_types else "scan", + summary_cards="\n ".join(summary_cards), + severity_options=severity_options, + type_options=type_options, + rows="\n ".join(row_html_parts), + console_base=html.escape(base_url), + ) + + +# -------------------------------- CSV ------------------------------------- + +CSV_COLUMNS = [ + "severity", + "type", + "issue_name", + "description", + "file", + "line", + "cwe", + "owasp", + "category", + "languages", + "mitigation", + "console_url", + "detection_rule_id", + "policy_id", + "detection_id", +] + + +def write_csv(rows: list[dict], path: str, base_url: str) -> None: + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f, quoting=csv.QUOTE_MINIMAL) + writer.writerow([ + "Severity", "Type", "Issue Name", "Issue Description", + "File", "Line", "CWE", "OWASP", "Category", "Languages", + "Mitigation", "Console URL", + "Detection Rule ID", "Policy ID", "Detection ID", + ]) + for r in rows: + writer.writerow([ + r["severity"], r["type"], r["issue_name"], r["description"], + r["file"], r["line"], r["cwe"], r["owasp"], r["category"], r["languages"], + r["remediation"], console_url(r, base_url), + r["detection_rule_id"], r["policy_id"], r["id"], + ]) + + +# ------------------------------- main ------------------------------------- + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("input_json") + parser.add_argument("--md", help="Write Markdown here (default: stdout)") + parser.add_argument("--html", help="Write HTML report here") + parser.add_argument("--csv", help="Write CSV export here") + parser.add_argument( + "--console-url", + default=os.environ.get("CYCODE_CONSOLE_URL", "https://app.cycode.com"), + help="Base URL of the Cycode console (default: $CYCODE_CONSOLE_URL or https://app.cycode.com)", + ) + parser.add_argument( + "--artifact-hint", + default=os.environ.get("CYCODE_ARTIFACT_NAME", "cycode-report"), + help="Artifact name to reference in the Markdown summary (default: $CYCODE_ARTIFACT_NAME or cycode-report)", + ) + args = parser.parse_args() + + with open(args.input_json) as f: + data = json.load(f) + + detections = extract_detections(data) + rows = [row_from_detection(d) for d in detections] + rows.sort(key=severity_sort_key) + + scan_types = sorted({r["type"] for r in rows if r.get("type")}) + + md = render_markdown(rows, args.console_url, args.artifact_hint if (args.html or args.csv) else "") + if args.md: + with open(args.md, "w", encoding="utf-8") as f: + f.write(md) + else: + print(md) + + if args.html: + with open(args.html, "w", encoding="utf-8") as f: + f.write(render_html(rows, args.console_url, scan_types)) + + if args.csv: + write_csv(rows, args.csv, args.console_url) + return 0 if __name__ == "__main__": - if len(sys.argv) != 2: - print("Usage: cycode-summary.py ", file=sys.stderr) - sys.exit(2) - sys.exit(main(sys.argv[1])) + sys.exit(main())