torusguard 1.3.5 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.torusguard/.manifest.json +37 -36
- package/.torusguard/runs/report-latest.html +5862 -315
- package/.torusguard/runs/results-latest.sarif +20 -0
- package/.torusguard/runs/run-20260918-104221-audit/findings.json +1 -0
- package/.torusguard/runs/run-20260918-104221-audit/findings.md +8 -0
- package/.torusguard/runs/run-20260918-104221-audit/manifest.json +12 -0
- package/.torusguard/runs/run-20260918-104221-audit/summary.md +5 -0
- package/.torusguard/runs/run-20260918-104236-audit/findings.json +2314 -0
- package/.torusguard/runs/run-20260918-104236-audit/findings.md +960 -0
- package/.torusguard/runs/run-20260918-104236-audit/manifest.json +12 -0
- package/.torusguard/runs/run-20260918-104236-audit/summary.md +5 -0
- package/.torusguard/runs/run-20260918-105723-audit/findings.json +2382 -0
- package/.torusguard/runs/run-20260918-105723-audit/findings.md +960 -0
- package/.torusguard/runs/run-20260918-105723-audit/manifest.json +12 -0
- package/.torusguard/runs/run-20260918-105723-audit/summary.md +5 -0
- package/.torusguard/runs/run-20260918-105731-audit/findings.json +1 -0
- package/.torusguard/runs/run-20260918-105731-audit/findings.md +8 -0
- package/.torusguard/runs/run-20260918-105731-audit/manifest.json +12 -0
- package/.torusguard/runs/run-20260918-105731-audit/summary.md +5 -0
- package/.torusguard/runs/run-20260918-105940-audit/findings.json +1 -0
- package/.torusguard/runs/run-20260918-105940-audit/findings.md +8 -0
- package/.torusguard/runs/run-20260918-105940-audit/manifest.json +13 -0
- package/.torusguard/runs/run-20260918-105940-audit/remediation.md +7 -0
- package/.torusguard/runs/run-20260918-105940-audit/summary.md +5 -0
- package/.torusguard/runs/run-20260919-145701-audit/findings.json +1 -0
- package/.torusguard/runs/run-20260919-145701-audit/findings.md +8 -0
- package/.torusguard/runs/run-20260919-145701-audit/manifest.json +12 -0
- package/.torusguard/runs/run-20260919-145701-audit/summary.md +5 -0
- package/.torusguard/runs/run-20260919-151729-audit/findings.json +1 -0
- package/.torusguard/runs/run-20260919-151729-audit/findings.md +8 -0
- package/.torusguard/runs/run-20260919-151729-audit/manifest.json +13 -0
- package/.torusguard/runs/run-20260919-151729-audit/remediation.md +7 -0
- package/.torusguard/runs/run-20260919-151729-audit/summary.md +5 -0
- package/.torusguard/runs/run-20260919-152340-audit/findings.json +1 -0
- package/.torusguard/runs/run-20260919-152340-audit/findings.md +8 -0
- package/.torusguard/runs/run-20260919-152340-audit/manifest.json +12 -0
- package/.torusguard/runs/run-20260919-152340-audit/summary.md +5 -0
- package/.torusguard/runs/run-20260919-152345-audit/findings.json +1 -0
- package/.torusguard/runs/run-20260919-152345-audit/findings.md +8 -0
- package/.torusguard/runs/run-20260919-152345-audit/manifest.json +12 -0
- package/.torusguard/runs/run-20260919-152345-audit/summary.md +5 -0
- package/.torusguard/runs/run-20260919-153500-audit/findings.json +1 -0
- package/.torusguard/runs/run-20260919-153500-audit/findings.md +8 -0
- package/.torusguard/runs/run-20260919-153500-audit/manifest.json +12 -0
- package/.torusguard/runs/run-20260919-153500-audit/summary.md +5 -0
- package/.torusguard/scripts/__pycache__/apply_runner.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/audit_runner.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/diff_guard.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/finding_scorer.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/harden_runner.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/html_reporter.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/manifest_builder.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/memory_engine.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/monorepo_detector.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/recheck_runner.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/recipes_runner.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/report_sync.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/run_manager.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/sarif_exporter.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/stack_detect.cpython-314.pyc +0 -0
- package/.torusguard/scripts/__pycache__/term_ui.cpython-314.pyc +0 -0
- package/.torusguard/scripts/apply_runner.py +108 -9
- package/.torusguard/scripts/audit_runner.py +336 -24
- package/.torusguard/scripts/harden_runner.py +124 -70
- package/.torusguard/scripts/html_reporter.py +3585 -648
- package/.torusguard/scripts/manifest_builder.py +2 -0
- package/.torusguard/scripts/recheck_runner.py +5 -4
- package/.torusguard/scripts/recipes_runner.py +91 -8
- package/.torusguard/scripts/report_sync.py +496 -73
- package/.torusguard/scripts/sarif_exporter.py +48 -8
- package/.torusguard/scripts/stack_detect.py +44 -2
- package/.torusguard/scripts/status_runner.py +267 -0
- package/.torusguard/scripts/term_ui.py +1 -1
- package/.torusguard/skills/torusguard/SKILL.md +75 -72
- package/.torusguard/skills/torusguard-apply/SKILL.md +84 -80
- package/.torusguard/skills/torusguard-audit/SKILL.md +87 -83
- package/.torusguard/skills/torusguard-authorize/SKILL.md +7 -2
- package/.torusguard/skills/torusguard-exploit-check/SKILL.md +7 -2
- package/.torusguard/skills/torusguard-full/SKILL.md +7 -2
- package/.torusguard/skills/torusguard-harden/SKILL.md +98 -94
- package/.torusguard/skills/torusguard-init/SKILL.md +68 -64
- package/.torusguard/skills/torusguard-recheck/SKILL.md +81 -77
- package/.torusguard/skills/torusguard-report/SKILL.md +93 -72
- package/.torusguard/skills/torusguard-status/SKILL.md +62 -58
- package/.torusguard/skills/torusguard-verify/SKILL.md +7 -2
- package/.torusguard/skills/torusguard-web-validate/SKILL.md +7 -2
- package/.torusguard/workflows/apply.md +1 -1
- package/.torusguard/workflows/audit.md +1 -1
- package/.torusguard/workflows/authorize.md +1 -1
- package/.torusguard/workflows/exploit-check.md +1 -1
- package/.torusguard/workflows/harden.md +1 -1
- package/.torusguard/workflows/init.md +1 -1
- package/.torusguard/workflows/memory.md +1 -1
- package/.torusguard/workflows/recheck.md +1 -1
- package/.torusguard/workflows/report.md +1 -1
- package/.torusguard/workflows/status.md +1 -1
- package/.torusguard/workflows/verify.md +1 -1
- package/.torusguard/workflows/web-validate.md +1 -1
- package/README.md +115 -81
- package/bin/torusguard.js +244 -38
- package/package.json +6 -2
- package/skills/torusguard/SKILL.md +1 -1
- package/skills/torusguard/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/skills/torusguard/bootstrap.py +52 -8
- package/skills/torusguard/payload/.manifest.json +51 -36
- package/skills/torusguard/payload/scripts/__pycache__/apply_runner.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/audit_runner.cpython-311.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/audit_runner.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/diff_guard.cpython-311.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/diff_guard.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/finding_scorer.cpython-311.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/finding_scorer.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/harden_runner.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/html_reporter.cpython-311.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/html_reporter.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/manifest_builder.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/memory_engine.cpython-311.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/memory_engine.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/monorepo_detector.cpython-311.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/monorepo_detector.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/recheck_runner.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/report_sync.cpython-311.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/report_sync.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/rules_sync.cpython-311.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/run_manager.cpython-311.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/run_manager.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/sarif_exporter.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/stack_detect.cpython-311.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/stack_detect.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/term_ui.cpython-311.pyc +0 -0
- package/skills/torusguard/payload/scripts/__pycache__/term_ui.cpython-314.pyc +0 -0
- package/skills/torusguard/payload/scripts/apply_runner.py +108 -9
- package/skills/torusguard/payload/scripts/audit_runner.py +336 -24
- package/skills/torusguard/payload/scripts/harden_runner.py +124 -70
- package/skills/torusguard/payload/scripts/html_reporter.py +3585 -648
- package/skills/torusguard/payload/scripts/manifest_builder.py +2 -0
- package/skills/torusguard/payload/scripts/recheck_runner.py +5 -4
- package/skills/torusguard/payload/scripts/recipes_runner.py +91 -8
- package/skills/torusguard/payload/scripts/report_sync.py +496 -73
- package/skills/torusguard/payload/scripts/sarif_exporter.py +48 -8
- package/skills/torusguard/payload/scripts/stack_detect.py +44 -2
- package/skills/torusguard/payload/scripts/status_runner.py +267 -0
- package/skills/torusguard/payload/scripts/term_ui.py +1 -1
- package/skills/torusguard/payload/skills/torusguard/SKILL.md +1 -1
- package/skills/torusguard/payload/skills/torusguard-apply/SKILL.md +1 -1
- package/skills/torusguard/payload/skills/torusguard-audit/SKILL.md +1 -1
- package/skills/torusguard/payload/skills/torusguard-authorize/SKILL.md +1 -1
- package/skills/torusguard/payload/skills/torusguard-exploit-check/SKILL.md +1 -1
- package/skills/torusguard/payload/skills/torusguard-full/SKILL.md +1 -1
- package/skills/torusguard/payload/skills/torusguard-harden/SKILL.md +1 -1
- package/skills/torusguard/payload/skills/torusguard-init/SKILL.md +1 -1
- package/skills/torusguard/payload/skills/torusguard-recheck/SKILL.md +1 -1
- package/skills/torusguard/payload/skills/torusguard-report/SKILL.md +40 -23
- package/skills/torusguard/payload/skills/torusguard-status/SKILL.md +1 -1
- package/skills/torusguard/payload/skills/torusguard-verify/SKILL.md +1 -1
- package/skills/torusguard/payload/skills/torusguard-web-validate/SKILL.md +1 -1
- package/skills/torusguard/payload/workflows/apply.md +9 -1
- package/skills/torusguard/payload/workflows/audit.md +8 -2
- package/skills/torusguard/payload/workflows/authorize.md +1 -1
- package/skills/torusguard/payload/workflows/exploit-check.md +1 -1
- package/skills/torusguard/payload/workflows/harden.md +6 -1
- package/skills/torusguard/payload/workflows/init.md +7 -1
- package/skills/torusguard/payload/workflows/memory.md +1 -1
- package/skills/torusguard/payload/workflows/recheck.md +1 -1
- package/skills/torusguard/payload/workflows/recipes.md +55 -0
- package/skills/torusguard/payload/workflows/report.md +59 -55
- package/skills/torusguard/payload/workflows/status.md +4 -1
- package/skills/torusguard/payload/workflows/torusguard-apply.md +110 -0
- package/skills/torusguard/payload/workflows/torusguard-audit.md +105 -0
- package/skills/torusguard/payload/workflows/torusguard-authorize.md +96 -0
- package/skills/torusguard/payload/workflows/torusguard-exploit-check.md +98 -0
- package/skills/torusguard/payload/workflows/torusguard-harden.md +104 -0
- package/skills/torusguard/payload/workflows/torusguard-init.md +106 -0
- package/skills/torusguard/payload/workflows/torusguard-recheck.md +101 -0
- package/skills/torusguard/payload/workflows/torusguard-recipes.md +55 -0
- package/skills/torusguard/payload/workflows/torusguard-report.md +105 -0
- package/skills/torusguard/payload/workflows/torusguard-status.md +103 -0
- package/skills/torusguard/payload/workflows/torusguard-verify.md +100 -0
- package/skills/torusguard/payload/workflows/torusguard-web-validate.md +96 -0
- package/skills/torusguard/payload/workflows/torusguard.md +19 -0
- package/skills/torusguard/payload/workflows/verify.md +1 -1
- package/skills/torusguard/payload/workflows/web-validate.md +1 -1
- package/skills/torusguard-apply/SKILL.md +1 -1
- package/skills/torusguard-audit/SKILL.md +1 -1
- package/skills/torusguard-authorize/SKILL.md +1 -1
- package/skills/torusguard-exploit-check/SKILL.md +1 -1
- package/skills/torusguard-full/SKILL.md +1 -1
- package/skills/torusguard-harden/SKILL.md +1 -1
- package/skills/torusguard-init/SKILL.md +1 -1
- package/skills/torusguard-recheck/SKILL.md +1 -1
- package/skills/torusguard-report/SKILL.md +40 -23
- package/skills/torusguard-status/SKILL.md +1 -1
- package/skills/torusguard-verify/SKILL.md +1 -1
- package/skills/torusguard-web-validate/SKILL.md +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
TorusGuard Living Security Report Sync Engine (v1.3.
|
|
3
|
+
TorusGuard Living Security Report Sync Engine (v1.3.6)
|
|
4
4
|
Maintains a living, ground-truth `security_report.md` ledger at the root of the workspace.
|
|
5
5
|
Tracks finding lifecycles from Discovery -> Candidate -> Applied -> Resolved -> Regressed.
|
|
6
6
|
Pure Python 3.10+ standard library (zero external dependencies).
|
|
@@ -10,6 +10,7 @@ import sys
|
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
12
|
import json
|
|
13
|
+
import time
|
|
13
14
|
import datetime
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
from typing import Dict, List, Any, Optional, Tuple
|
|
@@ -42,9 +43,106 @@ def get_report_path(target_root: Path) -> Path:
|
|
|
42
43
|
"""Return absolute path to security_report.md at the workspace root."""
|
|
43
44
|
return target_root / "security_report.md"
|
|
44
45
|
|
|
46
|
+
|
|
47
|
+
def atomic_write_text(file_path: Path, content: str, max_retries: int = 3, backoff_base: float = 0.05) -> bool:
|
|
48
|
+
"""
|
|
49
|
+
Atomically write text to disk using a temporary file and atomic replace.
|
|
50
|
+
Handles Windows file-locking (e.g. browser read locks on open HTML reports)
|
|
51
|
+
via exponential backoff retries.
|
|
52
|
+
"""
|
|
53
|
+
file_path = Path(file_path).resolve()
|
|
54
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
pid = os.getpid()
|
|
57
|
+
ts = int(time.time() * 1000)
|
|
58
|
+
tmp_path = file_path.with_name(f".tmp.{pid}.{ts}.{file_path.name}")
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
62
|
+
f.write(content)
|
|
63
|
+
f.flush()
|
|
64
|
+
os.fsync(f.fileno())
|
|
65
|
+
except Exception as e:
|
|
66
|
+
if tmp_path.exists():
|
|
67
|
+
try:
|
|
68
|
+
tmp_path.unlink()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
raise e
|
|
72
|
+
|
|
73
|
+
for attempt in range(max_retries):
|
|
74
|
+
try:
|
|
75
|
+
os.replace(tmp_path, file_path)
|
|
76
|
+
return True
|
|
77
|
+
except (PermissionError, OSError):
|
|
78
|
+
if attempt == max_retries - 1:
|
|
79
|
+
try:
|
|
80
|
+
file_path.write_text(content, encoding="utf-8")
|
|
81
|
+
if tmp_path.exists():
|
|
82
|
+
tmp_path.unlink()
|
|
83
|
+
return True
|
|
84
|
+
except Exception:
|
|
85
|
+
if tmp_path.exists():
|
|
86
|
+
try:
|
|
87
|
+
tmp_path.unlink()
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
raise
|
|
91
|
+
time.sleep(backoff_base * (2 ** attempt))
|
|
92
|
+
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def reconcile_from_run_artifacts(target_root: Path) -> List[Dict[str, Any]]:
|
|
97
|
+
"""
|
|
98
|
+
Authoritative fallback: if security_report.md is missing or damaged,
|
|
99
|
+
heal state from the latest run's findings.json in .torusguard/runs/.
|
|
100
|
+
"""
|
|
101
|
+
runs_dir = target_root / ".torusguard" / "runs"
|
|
102
|
+
if not runs_dir.is_dir():
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
run_folders = sorted(
|
|
106
|
+
[d for d in runs_dir.iterdir() if d.is_dir() and (d / "findings.json").is_file()],
|
|
107
|
+
key=lambda p: p.stat().st_mtime,
|
|
108
|
+
reverse=True
|
|
109
|
+
)
|
|
110
|
+
if not run_folders:
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
latest_file = run_folders[0] / "findings.json"
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(latest_file.read_text(encoding="utf-8"))
|
|
116
|
+
if isinstance(data, list):
|
|
117
|
+
return data
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
|
|
45
123
|
def parse_existing_report(report_path: Path) -> Dict[str, Any]:
|
|
46
|
-
"""Parse existing security_report.md into structured data if it exists.
|
|
124
|
+
"""Parse existing security_report.md into structured data if it exists.
|
|
125
|
+
Supports both the legacy format and the new v2 summary-first format.
|
|
126
|
+
Heals from latest run findings if report is absent or damaged.
|
|
127
|
+
"""
|
|
47
128
|
if not report_path.is_file():
|
|
129
|
+
recovered = reconcile_from_run_artifacts(report_path.parent)
|
|
130
|
+
if recovered:
|
|
131
|
+
f_map = {}
|
|
132
|
+
for r in recovered:
|
|
133
|
+
fid = r.get("finding_id", "")
|
|
134
|
+
if fid:
|
|
135
|
+
f_map[fid] = {
|
|
136
|
+
"finding_id": fid,
|
|
137
|
+
"rule_id": r.get("rule_id", "TG-GEN"),
|
|
138
|
+
"title": r.get("title", "Security Finding"),
|
|
139
|
+
"status": "🔴 OPEN",
|
|
140
|
+
"severity": r.get("severity", "Medium"),
|
|
141
|
+
"confidence": f"{r.get('confidence_score', 70)}% ({r.get('confidence_band', 'High Confidence')})",
|
|
142
|
+
"target": f"{r.get('file_path', '')}:{r.get('line_number', 1)}",
|
|
143
|
+
"history": []
|
|
144
|
+
}
|
|
145
|
+
return {"findings": f_map, "metadata": {}}
|
|
48
146
|
return {"findings": {}, "metadata": {}}
|
|
49
147
|
|
|
50
148
|
try:
|
|
@@ -53,8 +151,9 @@ def parse_existing_report(report_path: Path) -> Dict[str, Any]:
|
|
|
53
151
|
return {"findings": {}, "metadata": {}}
|
|
54
152
|
|
|
55
153
|
findings = {}
|
|
56
|
-
|
|
57
|
-
|
|
154
|
+
|
|
155
|
+
# ── Format A (legacy): ### [RULE-ID] Title ──
|
|
156
|
+
card_pattern_legacy = re.compile(
|
|
58
157
|
r'###\s+\[(TG-[A-Z]+-\d+)\]\s+(.*?)\n'
|
|
59
158
|
r'- \*\*Finding ID:\*\*\s+`([^`]+)`\n'
|
|
60
159
|
r'- \*\*Status:\*\*\s+([^\n]+)\n'
|
|
@@ -63,7 +162,7 @@ def parse_existing_report(report_path: Path) -> Dict[str, Any]:
|
|
|
63
162
|
re.MULTILINE
|
|
64
163
|
)
|
|
65
164
|
|
|
66
|
-
for m in
|
|
165
|
+
for m in card_pattern_legacy.finditer(content):
|
|
67
166
|
rule_id = m.group(1).strip()
|
|
68
167
|
title = m.group(2).strip()
|
|
69
168
|
finding_id = m.group(3).strip()
|
|
@@ -83,7 +182,53 @@ def parse_existing_report(report_path: Path) -> Dict[str, Any]:
|
|
|
83
182
|
"history": []
|
|
84
183
|
}
|
|
85
184
|
|
|
86
|
-
#
|
|
185
|
+
# ── Format B (new v2): ### TG-RULE-001 · Title `status` `confidence` ──
|
|
186
|
+
card_pattern_v2 = re.compile(
|
|
187
|
+
r'###\s+(TG-[A-Z]+-\d+)\s+·\s+(.*?)\s+`([^`]+)`\s+`([^`]+)`[\r\n]+'
|
|
188
|
+
r'-\s+\*\*Finding ID:\*\*\s+`([^`]+)`(?:[^\r\n]*\*\*Severity:\*\*\s+`([^`]+)`)?(?:[^\r\n]*\*\*Cluster:\*\*\s+`([^`]+)`)?[^\r\n]*[\r\n]+'
|
|
189
|
+
r'(?:📁|- \*\*Target:\*\*)\s*`?([^\r\n`]+)`?(?:[\r\n]+\*\*Problem:\*\*\s+([^\r\n]+))?',
|
|
190
|
+
re.MULTILINE
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
for m in card_pattern_v2.finditer(content):
|
|
194
|
+
rule_id = m.group(1).strip()
|
|
195
|
+
title = m.group(2).strip()
|
|
196
|
+
status = m.group(3).strip()
|
|
197
|
+
confidence = m.group(4).strip()
|
|
198
|
+
finding_id = m.group(5).strip()
|
|
199
|
+
sev_explicit = m.group(6).strip() if m.group(6) else None
|
|
200
|
+
cluster_explicit = m.group(7).strip() if m.group(7) else None
|
|
201
|
+
target = m.group(8).strip()
|
|
202
|
+
problem = m.group(9).strip() if m.group(9) else None
|
|
203
|
+
|
|
204
|
+
if finding_id not in findings: # Don't overwrite legacy matches
|
|
205
|
+
if sev_explicit:
|
|
206
|
+
severity = sev_explicit
|
|
207
|
+
else:
|
|
208
|
+
# Infer severity from section header or status
|
|
209
|
+
severity = "High" # Default
|
|
210
|
+
match_pos = m.start()
|
|
211
|
+
preceding = content[:match_pos]
|
|
212
|
+
if "Critical" in preceding.split("##")[-1]:
|
|
213
|
+
severity = "Critical"
|
|
214
|
+
elif "Medium" in preceding.split("##")[-1]:
|
|
215
|
+
severity = "Medium"
|
|
216
|
+
|
|
217
|
+
findings[finding_id] = {
|
|
218
|
+
"finding_id": finding_id,
|
|
219
|
+
"rule_id": rule_id,
|
|
220
|
+
"title": title,
|
|
221
|
+
"status": status,
|
|
222
|
+
"severity": severity,
|
|
223
|
+
"confidence": confidence,
|
|
224
|
+
"target": target,
|
|
225
|
+
"cluster": cluster_explicit or "cluster-general",
|
|
226
|
+
"description": problem or "Security violation detected.",
|
|
227
|
+
"history": []
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Extract history from inline blockquote format (new) or table format (legacy)
|
|
231
|
+
# Legacy: #### Lifecycle History for `finding_id`
|
|
87
232
|
history_pattern = re.compile(r'#### Lifecycle History for `([^`]+)`\n([\s\S]*?)(?=\n---|\n###|\Z)')
|
|
88
233
|
for hm in history_pattern.finditer(content):
|
|
89
234
|
fid = hm.group(1).strip()
|
|
@@ -100,8 +245,71 @@ def parse_existing_report(report_path: Path) -> Dict[str, Any]:
|
|
|
100
245
|
"details": cols[3]
|
|
101
246
|
})
|
|
102
247
|
|
|
248
|
+
# New format: > Last: Action — timestamp · `run_id`
|
|
249
|
+
inline_history = re.compile(
|
|
250
|
+
r'###\s+TG-[A-Z]+-\d+.*?\n.*?Finding ID:\*\*\s+`([^`]+)`.*?'
|
|
251
|
+
r'>\s+Last:\s+(.*?)\s+—\s+(.*?)\s+·\s+`([^`]+)`',
|
|
252
|
+
re.DOTALL
|
|
253
|
+
)
|
|
254
|
+
for hm in inline_history.finditer(content):
|
|
255
|
+
fid = hm.group(1).strip()
|
|
256
|
+
if fid in findings and not findings[fid]["history"]:
|
|
257
|
+
findings[fid]["history"].append({
|
|
258
|
+
"timestamp": hm.group(3).strip(),
|
|
259
|
+
"action": hm.group(2).strip(),
|
|
260
|
+
"run_id": hm.group(4).strip(),
|
|
261
|
+
"details": "Restored from inline history"
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
# Enrich findings from run history if available
|
|
265
|
+
runs_dir = report_path.parent / ".torusguard" / "runs"
|
|
266
|
+
run_findings_map: Dict[str, Any] = {}
|
|
267
|
+
if runs_dir.is_dir():
|
|
268
|
+
for d in sorted(runs_dir.iterdir()):
|
|
269
|
+
if d.is_dir() and (d / "findings.json").is_file():
|
|
270
|
+
try:
|
|
271
|
+
f_items = json.loads((d / "findings.json").read_text(encoding="utf-8"))
|
|
272
|
+
for it in f_items:
|
|
273
|
+
if isinstance(it, dict) and it.get("finding_id"):
|
|
274
|
+
run_findings_map[it["finding_id"]] = it
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
for fid, f in findings.items():
|
|
279
|
+
if fid in run_findings_map:
|
|
280
|
+
rf = run_findings_map[fid]
|
|
281
|
+
if not f.get("cluster") or f.get("cluster") == "cluster-general":
|
|
282
|
+
f["cluster"] = rf.get("cluster") or "cluster-general"
|
|
283
|
+
if not f.get("category"):
|
|
284
|
+
f["category"] = rf.get("category") or "general"
|
|
285
|
+
if not f.get("target") or "\n" in f.get("target", "") or "**Problem:**" in f.get("target", "") or f.get("target") == "unknown":
|
|
286
|
+
f["target"] = f"{rf.get('file_path', 'unknown')}:{rf.get('line_number', 1)}"
|
|
287
|
+
if not f.get("description") or f.get("description") == "Security violation detected.":
|
|
288
|
+
f["description"] = rf.get("description") or "Security invariant check failed."
|
|
289
|
+
if not f.get("snippet"):
|
|
290
|
+
f["snippet"] = rf.get("snippet") or rf.get("matched_text", "")
|
|
291
|
+
if not f.get("file_path"):
|
|
292
|
+
f["file_path"] = rf.get("file_path", "")
|
|
293
|
+
if not f.get("line_number"):
|
|
294
|
+
f["line_number"] = rf.get("line_number", 1)
|
|
295
|
+
|
|
103
296
|
return {"findings": findings, "raw_content": content}
|
|
104
297
|
|
|
298
|
+
|
|
299
|
+
def parse_metadata_block(content: str) -> Dict[str, Any]:
|
|
300
|
+
"""Parse the YAML metadata block between TORUSGUARD_METADATA_START/END markers."""
|
|
301
|
+
metadata = {}
|
|
302
|
+
meta_match = re.search(
|
|
303
|
+
r'<!-- TORUSGUARD_METADATA_START\s*\n(.*?)\nTORUSGUARD_METADATA_END -->',
|
|
304
|
+
content, re.DOTALL
|
|
305
|
+
)
|
|
306
|
+
if meta_match:
|
|
307
|
+
for line in meta_match.group(1).strip().splitlines():
|
|
308
|
+
if ':' in line:
|
|
309
|
+
key, _, val = line.partition(':')
|
|
310
|
+
metadata[key.strip()] = val.strip()
|
|
311
|
+
return metadata
|
|
312
|
+
|
|
105
313
|
def compute_health_score(findings: List[Dict[str, Any]]) -> Tuple[int, str]:
|
|
106
314
|
"""
|
|
107
315
|
Compute security health score (0-100) based on remaining open/regressed findings.
|
|
@@ -127,7 +335,8 @@ def compute_health_score(findings: List[Dict[str, Any]]) -> Tuple[int, str]:
|
|
|
127
335
|
return score, status
|
|
128
336
|
|
|
129
337
|
def render_report(findings: List[Dict[str, Any]], target_root: Path) -> str:
|
|
130
|
-
"""Render the
|
|
338
|
+
"""Render the living security_report.md with summary-first layout,
|
|
339
|
+
AI-parseable metadata, severity grouping, and concise finding cards."""
|
|
131
340
|
score, status_text = compute_health_score(findings)
|
|
132
341
|
now_str = format_timestamp()
|
|
133
342
|
|
|
@@ -146,88 +355,248 @@ def render_report(findings: List[Dict[str, Any]], target_root: Path) -> str:
|
|
|
146
355
|
med_count = sum(1 for f in findings if f["severity"].lower() == "medium")
|
|
147
356
|
low_count = sum(1 for f in findings if f["severity"].lower() == "low")
|
|
148
357
|
|
|
358
|
+
crit_open = sum(1 for f in findings if f["severity"].lower() == "critical" and ("OPEN" in f["status"] or "REGRESSED" in f["status"]))
|
|
359
|
+
high_open = sum(1 for f in findings if f["severity"].lower() == "high" and ("OPEN" in f["status"] or "REGRESSED" in f["status"]))
|
|
360
|
+
med_open = sum(1 for f in findings if f["severity"].lower() == "medium" and ("OPEN" in f["status"] or "REGRESSED" in f["status"]))
|
|
361
|
+
|
|
362
|
+
# Sort findings by status priority and severity
|
|
363
|
+
sev_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
364
|
+
def sort_key(f):
|
|
365
|
+
is_active = 0 if ("OPEN" in f["status"] or "REGRESSED" in f["status"]) else 1
|
|
366
|
+
s_rank = sev_rank.get(f["severity"].lower(), 9)
|
|
367
|
+
return (is_active, s_rank, f["rule_id"])
|
|
368
|
+
|
|
369
|
+
sorted_findings = sorted(findings, key=sort_key)
|
|
370
|
+
|
|
371
|
+
# Group by severity for active findings
|
|
372
|
+
active = [f for f in sorted_findings if "OPEN" in f["status"] or "REGRESSED" in f["status"]]
|
|
373
|
+
resolved = [f for f in sorted_findings if "RESOLVED" in f["status"]]
|
|
374
|
+
in_progress = [f for f in sorted_findings if any(s in f["status"] for s in ("CANDIDATE", "APPLIED", "VERIFIED"))]
|
|
375
|
+
suppressed = [f for f in sorted_findings if "FALSE POSITIVE" in f["status"]]
|
|
376
|
+
|
|
377
|
+
critical_findings = [f for f in active if f["severity"].lower() == "critical"]
|
|
378
|
+
high_findings = [f for f in active if f["severity"].lower() == "high"]
|
|
379
|
+
medium_findings = [f for f in active if f["severity"].lower() in ("medium", "low")]
|
|
380
|
+
|
|
381
|
+
# ── Build the report ──
|
|
149
382
|
lines = [
|
|
150
|
-
"# 🛡️ TorusGuard
|
|
383
|
+
"# 🛡️ TorusGuard Security Report",
|
|
151
384
|
"",
|
|
152
|
-
"
|
|
153
|
-
"
|
|
154
|
-
"
|
|
385
|
+
"<!-- TORUSGUARD_METADATA_START",
|
|
386
|
+
f"health_score: {score}",
|
|
387
|
+
f"status: {status_text}",
|
|
388
|
+
f"total_findings: {total}",
|
|
389
|
+
f"critical: {crit_count}",
|
|
390
|
+
f"critical_open: {crit_open}",
|
|
391
|
+
f"high: {high_count}",
|
|
392
|
+
f"high_open: {high_open}",
|
|
393
|
+
f"medium: {med_count}",
|
|
394
|
+
f"medium_open: {med_open}",
|
|
395
|
+
f"low: {low_count}",
|
|
396
|
+
f"open: {open_count}",
|
|
397
|
+
f"resolved: {resolved_count}",
|
|
398
|
+
f"regressed: {regressed_count}",
|
|
399
|
+
f"suppressed: {fp_count}",
|
|
400
|
+
f"last_scan: {now_str}",
|
|
401
|
+
"TORUSGUARD_METADATA_END -->",
|
|
155
402
|
"",
|
|
156
|
-
"
|
|
403
|
+
"## Executive Summary",
|
|
157
404
|
"",
|
|
158
|
-
"
|
|
405
|
+
"| Metric | Value |",
|
|
406
|
+
"| :--- | :--- |",
|
|
407
|
+
f"| **Health Score** | `{score}/100` — {status_text} |",
|
|
408
|
+
f"| **Critical (Open)** | {crit_open} |",
|
|
409
|
+
f"| **High (Open)** | {high_open} |",
|
|
410
|
+
f"| **Medium (Open)** | {med_open} |",
|
|
411
|
+
f"| **Resolved** | {resolved_count} |",
|
|
412
|
+
f"| **In Progress** | {candidate_count + applied_count} |",
|
|
413
|
+
f"| **Last Scan** | {now_str} |",
|
|
159
414
|
"",
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
""
|
|
165
|
-
"
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
# Action required callout
|
|
418
|
+
if crit_open > 0:
|
|
419
|
+
lines.append(f"> ⚠️ **Action Required:** {crit_open} critical finding{'s' if crit_open != 1 else ''} require immediate attention. Run `npx torusguard harden` to generate patches.")
|
|
420
|
+
lines.append("")
|
|
421
|
+
|
|
422
|
+
# ── Governed Remediation & Problem-Solved Velocity Table ──
|
|
423
|
+
crit_resolved = max(0, crit_count - crit_open)
|
|
424
|
+
high_resolved = max(0, high_count - high_open)
|
|
425
|
+
med_resolved = max(0, med_count - med_open)
|
|
426
|
+
low_resolved = max(0, low_count)
|
|
427
|
+
|
|
428
|
+
crit_pct = int((crit_resolved / crit_count) * 100) if crit_count > 0 else 100
|
|
429
|
+
high_pct = int((high_resolved / high_count) * 100) if high_count > 0 else 100
|
|
430
|
+
med_pct = int((med_resolved / med_count) * 100) if med_count > 0 else 100
|
|
431
|
+
overall_pct = int((resolved_count / total) * 100) if total > 0 else 100
|
|
432
|
+
|
|
433
|
+
lines.extend([
|
|
434
|
+
"### ⚡ Governed Remediation & Problem-Solved Velocity",
|
|
171
435
|
"",
|
|
172
|
-
"
|
|
436
|
+
"| Severity Layer | Total Discovered | Remediated & Hardened | Active Exposure | Closure Rate | Status |",
|
|
437
|
+
"| :--- | :---: | :---: | :---: | :---: | :---: |",
|
|
438
|
+
f"| **Critical** | {crit_count} | {crit_resolved} | {crit_open} | {crit_pct}% | {'🟢 100% DEFENDED' if crit_open == 0 else '🔴 ACTION REQUIRED'} |",
|
|
439
|
+
f"| **High** | {high_count} | {high_resolved} | {high_open} | {high_pct}% | {'🟢 100% DEFENDED' if high_open == 0 else '🟠 NEEDS ATTENTION'} |",
|
|
440
|
+
f"| **Medium / Low** | {med_count + low_count} | {med_resolved + low_resolved} | {med_open} | {med_pct}% | {'🟢 100% DEFENDED' if med_open == 0 else '🟡 PENDING REVIEW'} |",
|
|
441
|
+
f"| **Total Flaws** | **{total}** | **{resolved_count}** | **{open_count}** | **{overall_pct}%** | **{'🟢 100% HARDENED' if open_count == 0 else '🟡 REMEDIATION IN PROGRESS'}** |",
|
|
173
442
|
"",
|
|
174
|
-
|
|
175
|
-
""
|
|
176
|
-
]
|
|
443
|
+
])
|
|
177
444
|
|
|
178
|
-
#
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
445
|
+
# ── Directory Attack Surface Overview Table ──
|
|
446
|
+
dir_stats: Dict[str, Dict[str, int]] = {}
|
|
447
|
+
for f in findings:
|
|
448
|
+
t = f.get('target', '')
|
|
449
|
+
fp = t.split(':')[0] if ':' in t else t
|
|
450
|
+
fp = fp.replace('\\', '/')
|
|
451
|
+
d_name = f"{fp.split('/')[0]}/" if '/' in fp else "root"
|
|
452
|
+
if d_name not in dir_stats:
|
|
453
|
+
dir_stats[d_name] = {"total": 0, "resolved": 0, "open": 0}
|
|
454
|
+
dir_stats[d_name]["total"] += 1
|
|
455
|
+
if "RESOLVED" in f.get("status", ""):
|
|
456
|
+
dir_stats[d_name]["resolved"] += 1
|
|
457
|
+
else:
|
|
458
|
+
dir_stats[d_name]["open"] += 1
|
|
459
|
+
|
|
460
|
+
if dir_stats:
|
|
461
|
+
lines.extend([
|
|
462
|
+
"### 🗺️ Directory Attack Surface Defended",
|
|
463
|
+
"",
|
|
464
|
+
"| Directory / Module | Total Invariants Checked | Vulnerabilities Neutralized | Active Risk | Defense Posture |",
|
|
465
|
+
"| :--- | :---: | :---: | :---: | :--- |",
|
|
466
|
+
])
|
|
467
|
+
for d_name, d_data in sorted(dir_stats.items(), key=lambda x: x[1]["total"], reverse=True):
|
|
468
|
+
posture_str = "🟢 Hardened Zone (100% Invariants Enforced)" if d_data["open"] == 0 else f"🔴 Active Exposure ({d_data['open']} Open Flaws)"
|
|
469
|
+
lines.append(f"| `{d_name}` | {d_data['total']} | {d_data['resolved']} | {d_data['open']} | {posture_str} |")
|
|
470
|
+
lines.append("")
|
|
184
471
|
|
|
185
|
-
|
|
472
|
+
# ── Root-Cause Architectural Clusters Overview Table ──
|
|
473
|
+
cluster_stats: Dict[str, Dict[str, Any]] = {}
|
|
474
|
+
for f in findings:
|
|
475
|
+
c_id = f.get('cluster') or f.get('category') or 'cluster-general'
|
|
476
|
+
if c_id not in cluster_stats:
|
|
477
|
+
cluster_stats[c_id] = {"total": 0, "resolved": 0, "open": 0, "rules": set()}
|
|
478
|
+
cluster_stats[c_id]["total"] += 1
|
|
479
|
+
cluster_stats[c_id]["rules"].add(f.get("rule_id", "TG-GEN"))
|
|
480
|
+
if "RESOLVED" in f.get("status", ""):
|
|
481
|
+
cluster_stats[c_id]["resolved"] += 1
|
|
482
|
+
else:
|
|
483
|
+
cluster_stats[c_id]["open"] += 1
|
|
484
|
+
|
|
485
|
+
if cluster_stats:
|
|
486
|
+
lines.extend([
|
|
487
|
+
"### 🏛️ Root-Cause Architectural Clusters",
|
|
488
|
+
"",
|
|
489
|
+
"| Cluster ID | Threat Architectural Domain | Neutralized Issues | Enforced Invariants | Posture State |",
|
|
490
|
+
"| :--- | :--- | :---: | :--- | :--- |",
|
|
491
|
+
])
|
|
492
|
+
for c_id, c_data in sorted(cluster_stats.items(), key=lambda x: x[1]["total"], reverse=True):
|
|
493
|
+
c_domain = c_id.replace("cluster-", "").replace("-", " ").title()
|
|
494
|
+
rules_str = ", ".join(f"`{r}`" for r in sorted(c_data["rules"])[:3])
|
|
495
|
+
c_state = "🟢 Dismantled & Defended" if c_data["open"] == 0 else f"🔴 {c_data['open']} Active Flaws"
|
|
496
|
+
lines.append(f"| `{c_id}` | {c_domain} | {c_data['resolved']} | {rules_str} | {c_state} |")
|
|
497
|
+
lines.append("")
|
|
498
|
+
|
|
499
|
+
lines.append("---")
|
|
500
|
+
lines.append("")
|
|
501
|
+
|
|
502
|
+
# ── Critical Findings Section ──
|
|
503
|
+
if critical_findings:
|
|
504
|
+
lines.append(f"## 🔴 Critical Findings ({len(critical_findings)})")
|
|
505
|
+
lines.append("")
|
|
506
|
+
for f in critical_findings:
|
|
507
|
+
lines.extend(_render_finding_card(f))
|
|
508
|
+
|
|
509
|
+
# ── High Findings Section ──
|
|
510
|
+
if high_findings:
|
|
511
|
+
lines.append(f"## 🟠 High Findings ({len(high_findings)})")
|
|
512
|
+
lines.append("")
|
|
513
|
+
for f in high_findings:
|
|
514
|
+
lines.extend(_render_finding_card(f))
|
|
186
515
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
lines.append(f"
|
|
190
|
-
lines.append(f"- **Status:** {f['status']}")
|
|
191
|
-
lines.append(f"- **Severity:** {f['severity']} | **Confidence:** {f.get('confidence', '70% (High Confidence)')}")
|
|
192
|
-
lines.append(f"- **Target:** `{f['target']}`")
|
|
193
|
-
if f.get("cluster"):
|
|
194
|
-
lines.append(f"- **Root-Cause Cluster:** `{f['cluster']}`")
|
|
516
|
+
# ── Medium/Low Findings Section ──
|
|
517
|
+
if medium_findings:
|
|
518
|
+
lines.append(f"## 🟡 Medium / Low Findings ({len(medium_findings)})")
|
|
195
519
|
lines.append("")
|
|
196
|
-
|
|
520
|
+
for f in medium_findings:
|
|
521
|
+
lines.extend(_render_finding_card(f))
|
|
522
|
+
|
|
523
|
+
# ── In Progress Section ──
|
|
524
|
+
if in_progress:
|
|
525
|
+
lines.append(f"## 🔵 In Progress ({len(in_progress)})")
|
|
197
526
|
lines.append("")
|
|
198
|
-
|
|
527
|
+
for f in in_progress:
|
|
528
|
+
lines.extend(_render_finding_card(f))
|
|
529
|
+
|
|
530
|
+
# ── Resolved Section ──
|
|
531
|
+
if resolved:
|
|
532
|
+
lines.append(f"## ✅ Resolved ({len(resolved)})")
|
|
199
533
|
lines.append("")
|
|
200
|
-
|
|
201
|
-
lines.
|
|
202
|
-
lines.append("")
|
|
203
|
-
if f.get("snippet"):
|
|
204
|
-
lines.append("```")
|
|
205
|
-
lines.append(f["snippet"].strip())
|
|
206
|
-
lines.append("```")
|
|
207
|
-
lines.append("")
|
|
208
|
-
if f.get("proposed_diff"):
|
|
209
|
-
lines.append("**Proposed Minimal Patch Preview (Ponytail Bounded):**")
|
|
210
|
-
lines.append("```diff")
|
|
211
|
-
lines.append(f["proposed_diff"].strip())
|
|
212
|
-
lines.append("```")
|
|
213
|
-
lines.append("")
|
|
214
|
-
|
|
215
|
-
# History table
|
|
216
|
-
history = f.get("history", [])
|
|
217
|
-
if history:
|
|
218
|
-
lines.append(f"#### Lifecycle History for `{f['finding_id']}`")
|
|
219
|
-
lines.append("| Timestamp | Action | Run ID | Details |")
|
|
220
|
-
lines.append("| :--- | :--- | :--- | :--- |")
|
|
221
|
-
for h in history:
|
|
222
|
-
lines.append(f"| {h['timestamp']} | {h['action']} | `{h['run_id']}` | {h['details']} |")
|
|
223
|
-
lines.append("")
|
|
534
|
+
for f in resolved:
|
|
535
|
+
lines.extend(_render_finding_card(f))
|
|
224
536
|
|
|
537
|
+
# ── Suppressed Section ──
|
|
538
|
+
if suppressed:
|
|
539
|
+
lines.append(f"## ⚪ Suppressed ({len(suppressed)})")
|
|
540
|
+
lines.append("")
|
|
541
|
+
for f in suppressed:
|
|
542
|
+
lines.append(f"- **{f['rule_id']}** · {f['title']} — `{f['target']}` · False Positive")
|
|
543
|
+
lines.append("")
|
|
225
544
|
lines.append("---")
|
|
226
545
|
lines.append("")
|
|
227
546
|
|
|
547
|
+
# Footer
|
|
548
|
+
lines.append("---")
|
|
549
|
+
lines.append(f"*Generated by TorusGuard v1.3.6 · {now_str} · 100% Local-First*")
|
|
550
|
+
lines.append("")
|
|
551
|
+
|
|
228
552
|
return "\n".join(lines)
|
|
229
553
|
|
|
230
554
|
|
|
555
|
+
def _render_finding_card(f: Dict[str, Any]) -> List[str]:
|
|
556
|
+
"""Render a concise finding card (4-6 lines max)."""
|
|
557
|
+
status_full = f["status"]
|
|
558
|
+
conf = f.get('confidence', '70% (High Confidence)')
|
|
559
|
+
sev = f.get('severity', 'High')
|
|
560
|
+
target = f.get('target', 'unknown')
|
|
561
|
+
if "\n" in target:
|
|
562
|
+
target = target.splitlines()[0]
|
|
563
|
+
target = target.strip().strip("`")
|
|
564
|
+
if target.startswith("📁"):
|
|
565
|
+
target = target.replace("📁", "").strip()
|
|
566
|
+
|
|
567
|
+
desc = f.get('what_is_wrong') or f.get('description') or 'Security violation detected.'
|
|
568
|
+
if "\n" in desc:
|
|
569
|
+
desc = desc.splitlines()[0]
|
|
570
|
+
desc = desc.strip().strip("`")
|
|
571
|
+
if desc.startswith("**Problem:**"):
|
|
572
|
+
desc = desc.replace("**Problem:**", "").strip()
|
|
573
|
+
|
|
574
|
+
cluster_badge = f" · **Cluster:** `{f['cluster']}`" if f.get("cluster") else ""
|
|
575
|
+
card = [
|
|
576
|
+
f"### {f['rule_id']} · {f['title']} `{status_full}` `{conf}`",
|
|
577
|
+
f"- **Finding ID:** `{f['finding_id']}` · **Severity:** `{sev}`{cluster_badge}",
|
|
578
|
+
f"📁 `{target}`",
|
|
579
|
+
f"**Problem:** {desc}",
|
|
580
|
+
]
|
|
581
|
+
if f.get("what_should_change"):
|
|
582
|
+
card.append(f"**Fix:** {f['what_should_change']}")
|
|
583
|
+
if f.get("proposed_diff"):
|
|
584
|
+
card.append("")
|
|
585
|
+
card.append("```diff")
|
|
586
|
+
card.append(f["proposed_diff"].strip())
|
|
587
|
+
card.append("```")
|
|
588
|
+
card.append("")
|
|
589
|
+
# Include lifecycle history inline (compact)
|
|
590
|
+
history = f.get("history", [])
|
|
591
|
+
if history and len(history) > 0:
|
|
592
|
+
last_event = history[-1]
|
|
593
|
+
card.append(f"> Last: {last_event.get('action', '?')} — {last_event.get('timestamp', '?')} · `{last_event.get('run_id', '?')}`")
|
|
594
|
+
card.append("")
|
|
595
|
+
card.append("---")
|
|
596
|
+
card.append("")
|
|
597
|
+
return card
|
|
598
|
+
|
|
599
|
+
|
|
231
600
|
def record_audit_findings(target_root: Path, findings: List[Dict[str, Any]], run_id: str):
|
|
232
601
|
"""
|
|
233
602
|
Called at the conclusion of `npx torusguard audit`.
|
|
@@ -298,7 +667,8 @@ def record_audit_findings(target_root: Path, findings: List[Dict[str, Any]], run
|
|
|
298
667
|
|
|
299
668
|
# Render and save
|
|
300
669
|
rendered = render_report(list(existing_map.values()), target_root)
|
|
301
|
-
report_file
|
|
670
|
+
atomic_write_text(report_file, rendered)
|
|
671
|
+
sync_html_if_exists(target_root)
|
|
302
672
|
return report_file
|
|
303
673
|
|
|
304
674
|
|
|
@@ -340,7 +710,8 @@ def record_harden_bundles(target_root: Path, bundles: List[Dict[str, Any]], run_
|
|
|
340
710
|
})
|
|
341
711
|
|
|
342
712
|
rendered = render_report(list(existing_map.values()), target_root)
|
|
343
|
-
report_file
|
|
713
|
+
atomic_write_text(report_file, rendered)
|
|
714
|
+
sync_html_if_exists(target_root)
|
|
344
715
|
return report_file
|
|
345
716
|
|
|
346
717
|
|
|
@@ -378,7 +749,8 @@ def record_applied_patches(target_root: Path, applied_bundles: List[Dict[str, An
|
|
|
378
749
|
})
|
|
379
750
|
|
|
380
751
|
rendered = render_report(list(existing_map.values()), target_root)
|
|
381
|
-
report_file
|
|
752
|
+
atomic_write_text(report_file, rendered)
|
|
753
|
+
sync_html_if_exists(target_root)
|
|
382
754
|
return report_file
|
|
383
755
|
|
|
384
756
|
|
|
@@ -419,5 +791,56 @@ def record_recheck_results(target_root: Path, recheck_results: Dict[str, Any], r
|
|
|
419
791
|
})
|
|
420
792
|
|
|
421
793
|
rendered = render_report(list(existing_map.values()), target_root)
|
|
422
|
-
report_file
|
|
794
|
+
atomic_write_text(report_file, rendered)
|
|
795
|
+
sync_html_if_exists(target_root)
|
|
796
|
+
return report_file
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def record_rollback_results(target_root: Path, restored_files: List[str], run_id: str):
|
|
800
|
+
"""
|
|
801
|
+
Called at the conclusion of `npx torusguard rollback`.
|
|
802
|
+
Reverts findings for restored files back to OPEN 🔴.
|
|
803
|
+
"""
|
|
804
|
+
report_file = get_report_path(target_root)
|
|
805
|
+
existing = parse_existing_report(report_file)
|
|
806
|
+
existing_map = existing["findings"]
|
|
807
|
+
now_str = format_timestamp()
|
|
808
|
+
|
|
809
|
+
norm_restored = {rf.replace("\\", "/") for rf in restored_files}
|
|
810
|
+
|
|
811
|
+
for item in existing_map.values():
|
|
812
|
+
target_path = item.get("target", "").split(":")[0].replace("\\", "/")
|
|
813
|
+
if any(rf in target_path or target_path in rf for rf in norm_restored):
|
|
814
|
+
item["status"] = "🔴 OPEN"
|
|
815
|
+
item["history"].append({
|
|
816
|
+
"timestamp": now_str,
|
|
817
|
+
"action": "Rollback Restored",
|
|
818
|
+
"run_id": run_id,
|
|
819
|
+
"details": f"File {target_path} reverted from pre-apply snapshot. Status restored to OPEN."
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
rendered = render_report(list(existing_map.values()), target_root)
|
|
823
|
+
atomic_write_text(report_file, rendered)
|
|
824
|
+
sync_html_if_exists(target_root)
|
|
423
825
|
return report_file
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def sync_html_if_exists(target_root: Path):
|
|
829
|
+
"""
|
|
830
|
+
If the user has already generated report.html (at root or in .torusguard/runs),
|
|
831
|
+
refresh it so the HTML dashboard stays in lockstep with security_report.md.
|
|
832
|
+
"""
|
|
833
|
+
root_html = target_root / "report.html"
|
|
834
|
+
runs_html = target_root / ".torusguard" / "runs" / "report-latest.html"
|
|
835
|
+
if root_html.is_file() or runs_html.is_file():
|
|
836
|
+
try:
|
|
837
|
+
s_dir = Path(__file__).resolve().parent
|
|
838
|
+
if str(s_dir) not in sys.path:
|
|
839
|
+
sys.path.insert(0, str(s_dir))
|
|
840
|
+
import html_reporter
|
|
841
|
+
if runs_html.is_file():
|
|
842
|
+
html_reporter.emit_html_report(target_path=runs_html, root_dir=target_root)
|
|
843
|
+
if root_html.is_file():
|
|
844
|
+
html_reporter.emit_html_report(target_path=root_html, root_dir=target_root)
|
|
845
|
+
except Exception:
|
|
846
|
+
pass
|