diff --git a/app/routes/scan.py b/app/routes/scan.py index 3a6115d..c9c5d6c 100644 --- a/app/routes/scan.py +++ b/app/routes/scan.py @@ -1,6 +1,6 @@ from collections import defaultdict -from flask import Blueprint, jsonify +from flask import Blueprint, jsonify, render_template from flask_login import current_user, login_required from sqlalchemy.orm import joinedload @@ -14,10 +14,10 @@ @scan_bp.route("/api/scans/", methods=["GET"]) @login_required def scan_status(scan_id): - scan = Scan.query.filter( - Scan.id == scan_id, Project.owner_id == current_user.id - ).first() - if not scan: + scan = ( + Scan.query.options(joinedload(Scan.project)).filter(Scan.id == scan_id).first() + ) + if not scan or scan.project.owner_id != current_user.id: return jsonify({"error": "Данного скана не существует"}), 404 return jsonify( { @@ -32,20 +32,28 @@ def scan_status(scan_id): ), 200 -@scan_bp.route("/api/scans//report", methods=["GET"]) -@login_required -def report_json(scan_id): +def group_sort_severity(findings): + grouped = defaultdict(list) + for f in findings: + grouped[f["severity"]].append(f) + for finding_list in grouped.values(): + finding_list.sort(key=lambda x: x["file_path"]) + summary = { + severity: len(finding_list) for severity, finding_list in grouped.items() + } + return dict(grouped), summary + + +def findings(scan_id): scan = ( - Scan.query.options(joinedload(Scan.project)) - .filter(Scan.id == scan_id, Project.owner_id == current_user.id) - .first() + Scan.query.options(joinedload(Scan.project)).filter(Scan.id == scan_id).first() ) - if not scan: - return jsonify({"error": "Данного скана не существует"}), 404 + if not scan or scan.project.owner_id != current_user.id: + return None, None, (jsonify({"error": "Данного скана не существует"}), 404) if scan.status == "failed": - return jsonify({"error": scan.error_message}), 409 + return None, None, (jsonify({"error": scan.error_message}), 409) if scan.status != "done": - return jsonify({"status": scan.status}), 409 + return None, None, (jsonify({"status": scan.status}), 409) finding = Finding.query.filter( Scan.id == scan_id, Project.owner_id == current_user.id ).all() @@ -66,20 +74,17 @@ def report_json(scan_id): } for f in finding ] - - def group_sort_severity(findings): - grouped = defaultdict(list) - for f in findings: - grouped[f["severity"]].append(f) - for finding_list in grouped.values(): - finding_list.sort(key=lambda x: x["file_path"]) - summary = { - severity: len(finding_list) for severity, finding_list in grouped.items() - } - return dict(grouped), summary - grouped, summary = group_sort_severity(findings) + return scan, (summary, grouped), None + +@scan_bp.route("/api/scans//report", methods=["GET"]) +@login_required +def report_json(scan_id): + scan, data, err = findings(scan_id) + if err: + return err + summary, grouped = data return jsonify( { "scan_id": scan.id, @@ -91,3 +96,23 @@ def group_sort_severity(findings): "findings": grouped, } ) + + +@scan_bp.route("/api/scans//report.md", methods=["GET"]) +@login_required +def report_md(scan_id): + scan, data, err = findings(scan_id) + if err: + return err + summary, grouped = data + context = { + "summary": summary, + "grouped": grouped, + "project_title": scan.project.title, + "repo_url": scan.project.repo_url, + "scan_id": scan.id, + "commit_sha": scan.commit_sha, + "finished_at": scan.finished_at, + "truncated": scan.truncated, + } + return render_template("/reports/scan_report.md", **context) diff --git a/app/templates/reports/scan_report.md b/app/templates/reports/scan_report.md new file mode 100644 index 0000000..f4066ae --- /dev/null +++ b/app/templates/reports/scan_report.md @@ -0,0 +1,53 @@ +# Отчёт о сканировании — {{ project_title }} + +| Параметр | Значение | +|---|---| +| Проект | {{ project_title }} | +| Репозиторий | [{{ repo_url }}]({{ repo_url }}) | +| ID скана | {{ scan_id }} | +| Коммит | `{{ commit_sha }}` | +| Завершён | {{ finished_at or "—" }} | + +{% if truncated %} +> **Внимание:** список находок обрезан из-за превышения лимита. Отчёт может быть неполным. +{% endif %} + +## Сводка + +| Критичность | Кол-во | +|---|---| +| P0 — Критические | {{ summary.get("P0", 0) }} | +| P1 — Высокие | {{ summary.get("P1", 0) }} | +| P2 — Средние | {{ summary.get("P2", 0) }} | +| **Итого** | **{{ summary.get("P0", 0) + summary.get("P1", 0) + summary.get("P2", 0) }}** | + +## Находки + +{% for severity, title in [("P0", "Критические"), ("P1", "Высокие"), ("P2", "Средние")] %} +### {{ severity }} — {{ title }} + +{% for f in grouped.get(severity, []) %} +**Находка {{ loop.index }}** + +| Поле | Значение | +|---|---| +| Правило | `{{ f.rule_id }}` | +| Файл | `{{ f.file_path }}` | +| Строка | {{ f.line_no or "—" }} | +| Секрет (маска) | `{{ f.masked_value }}` | +| Уверенность | {{ f.confidence }} | +| Источник | {{ f.source }} | +| Статус | {{ f.status }} | +| Коммит | `{{ f.commit_sha }}` | +{% if f.context %} +| Контекст | `{{ f.context }}` | +{% endif %} + +{% else %} +Находок не обнаружено. + +{% endfor %} +{% endfor %} +--- + +*Отчёт сформирован сервисом [ZabGU DevSecOps Hub](https://github.com/mshqq/ZabGU-DevSecOps-Hub). Секреты приведены в маскированном виде.*