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.
Files changed (194) hide show
  1. package/.torusguard/.manifest.json +37 -36
  2. package/.torusguard/runs/report-latest.html +5862 -315
  3. package/.torusguard/runs/results-latest.sarif +20 -0
  4. package/.torusguard/runs/run-20260918-104221-audit/findings.json +1 -0
  5. package/.torusguard/runs/run-20260918-104221-audit/findings.md +8 -0
  6. package/.torusguard/runs/run-20260918-104221-audit/manifest.json +12 -0
  7. package/.torusguard/runs/run-20260918-104221-audit/summary.md +5 -0
  8. package/.torusguard/runs/run-20260918-104236-audit/findings.json +2314 -0
  9. package/.torusguard/runs/run-20260918-104236-audit/findings.md +960 -0
  10. package/.torusguard/runs/run-20260918-104236-audit/manifest.json +12 -0
  11. package/.torusguard/runs/run-20260918-104236-audit/summary.md +5 -0
  12. package/.torusguard/runs/run-20260918-105723-audit/findings.json +2382 -0
  13. package/.torusguard/runs/run-20260918-105723-audit/findings.md +960 -0
  14. package/.torusguard/runs/run-20260918-105723-audit/manifest.json +12 -0
  15. package/.torusguard/runs/run-20260918-105723-audit/summary.md +5 -0
  16. package/.torusguard/runs/run-20260918-105731-audit/findings.json +1 -0
  17. package/.torusguard/runs/run-20260918-105731-audit/findings.md +8 -0
  18. package/.torusguard/runs/run-20260918-105731-audit/manifest.json +12 -0
  19. package/.torusguard/runs/run-20260918-105731-audit/summary.md +5 -0
  20. package/.torusguard/runs/run-20260918-105940-audit/findings.json +1 -0
  21. package/.torusguard/runs/run-20260918-105940-audit/findings.md +8 -0
  22. package/.torusguard/runs/run-20260918-105940-audit/manifest.json +13 -0
  23. package/.torusguard/runs/run-20260918-105940-audit/remediation.md +7 -0
  24. package/.torusguard/runs/run-20260918-105940-audit/summary.md +5 -0
  25. package/.torusguard/runs/run-20260919-145701-audit/findings.json +1 -0
  26. package/.torusguard/runs/run-20260919-145701-audit/findings.md +8 -0
  27. package/.torusguard/runs/run-20260919-145701-audit/manifest.json +12 -0
  28. package/.torusguard/runs/run-20260919-145701-audit/summary.md +5 -0
  29. package/.torusguard/runs/run-20260919-151729-audit/findings.json +1 -0
  30. package/.torusguard/runs/run-20260919-151729-audit/findings.md +8 -0
  31. package/.torusguard/runs/run-20260919-151729-audit/manifest.json +13 -0
  32. package/.torusguard/runs/run-20260919-151729-audit/remediation.md +7 -0
  33. package/.torusguard/runs/run-20260919-151729-audit/summary.md +5 -0
  34. package/.torusguard/runs/run-20260919-152340-audit/findings.json +1 -0
  35. package/.torusguard/runs/run-20260919-152340-audit/findings.md +8 -0
  36. package/.torusguard/runs/run-20260919-152340-audit/manifest.json +12 -0
  37. package/.torusguard/runs/run-20260919-152340-audit/summary.md +5 -0
  38. package/.torusguard/runs/run-20260919-152345-audit/findings.json +1 -0
  39. package/.torusguard/runs/run-20260919-152345-audit/findings.md +8 -0
  40. package/.torusguard/runs/run-20260919-152345-audit/manifest.json +12 -0
  41. package/.torusguard/runs/run-20260919-152345-audit/summary.md +5 -0
  42. package/.torusguard/runs/run-20260919-153500-audit/findings.json +1 -0
  43. package/.torusguard/runs/run-20260919-153500-audit/findings.md +8 -0
  44. package/.torusguard/runs/run-20260919-153500-audit/manifest.json +12 -0
  45. package/.torusguard/runs/run-20260919-153500-audit/summary.md +5 -0
  46. package/.torusguard/scripts/__pycache__/apply_runner.cpython-314.pyc +0 -0
  47. package/.torusguard/scripts/__pycache__/audit_runner.cpython-314.pyc +0 -0
  48. package/.torusguard/scripts/__pycache__/diff_guard.cpython-314.pyc +0 -0
  49. package/.torusguard/scripts/__pycache__/finding_scorer.cpython-314.pyc +0 -0
  50. package/.torusguard/scripts/__pycache__/harden_runner.cpython-314.pyc +0 -0
  51. package/.torusguard/scripts/__pycache__/html_reporter.cpython-314.pyc +0 -0
  52. package/.torusguard/scripts/__pycache__/manifest_builder.cpython-314.pyc +0 -0
  53. package/.torusguard/scripts/__pycache__/memory_engine.cpython-314.pyc +0 -0
  54. package/.torusguard/scripts/__pycache__/monorepo_detector.cpython-314.pyc +0 -0
  55. package/.torusguard/scripts/__pycache__/recheck_runner.cpython-314.pyc +0 -0
  56. package/.torusguard/scripts/__pycache__/recipes_runner.cpython-314.pyc +0 -0
  57. package/.torusguard/scripts/__pycache__/report_sync.cpython-314.pyc +0 -0
  58. package/.torusguard/scripts/__pycache__/run_manager.cpython-314.pyc +0 -0
  59. package/.torusguard/scripts/__pycache__/sarif_exporter.cpython-314.pyc +0 -0
  60. package/.torusguard/scripts/__pycache__/stack_detect.cpython-314.pyc +0 -0
  61. package/.torusguard/scripts/__pycache__/term_ui.cpython-314.pyc +0 -0
  62. package/.torusguard/scripts/apply_runner.py +108 -9
  63. package/.torusguard/scripts/audit_runner.py +336 -24
  64. package/.torusguard/scripts/harden_runner.py +124 -70
  65. package/.torusguard/scripts/html_reporter.py +3585 -648
  66. package/.torusguard/scripts/manifest_builder.py +2 -0
  67. package/.torusguard/scripts/recheck_runner.py +5 -4
  68. package/.torusguard/scripts/recipes_runner.py +91 -8
  69. package/.torusguard/scripts/report_sync.py +496 -73
  70. package/.torusguard/scripts/sarif_exporter.py +48 -8
  71. package/.torusguard/scripts/stack_detect.py +44 -2
  72. package/.torusguard/scripts/status_runner.py +267 -0
  73. package/.torusguard/scripts/term_ui.py +1 -1
  74. package/.torusguard/skills/torusguard/SKILL.md +75 -72
  75. package/.torusguard/skills/torusguard-apply/SKILL.md +84 -80
  76. package/.torusguard/skills/torusguard-audit/SKILL.md +87 -83
  77. package/.torusguard/skills/torusguard-authorize/SKILL.md +7 -2
  78. package/.torusguard/skills/torusguard-exploit-check/SKILL.md +7 -2
  79. package/.torusguard/skills/torusguard-full/SKILL.md +7 -2
  80. package/.torusguard/skills/torusguard-harden/SKILL.md +98 -94
  81. package/.torusguard/skills/torusguard-init/SKILL.md +68 -64
  82. package/.torusguard/skills/torusguard-recheck/SKILL.md +81 -77
  83. package/.torusguard/skills/torusguard-report/SKILL.md +93 -72
  84. package/.torusguard/skills/torusguard-status/SKILL.md +62 -58
  85. package/.torusguard/skills/torusguard-verify/SKILL.md +7 -2
  86. package/.torusguard/skills/torusguard-web-validate/SKILL.md +7 -2
  87. package/.torusguard/workflows/apply.md +1 -1
  88. package/.torusguard/workflows/audit.md +1 -1
  89. package/.torusguard/workflows/authorize.md +1 -1
  90. package/.torusguard/workflows/exploit-check.md +1 -1
  91. package/.torusguard/workflows/harden.md +1 -1
  92. package/.torusguard/workflows/init.md +1 -1
  93. package/.torusguard/workflows/memory.md +1 -1
  94. package/.torusguard/workflows/recheck.md +1 -1
  95. package/.torusguard/workflows/report.md +1 -1
  96. package/.torusguard/workflows/status.md +1 -1
  97. package/.torusguard/workflows/verify.md +1 -1
  98. package/.torusguard/workflows/web-validate.md +1 -1
  99. package/README.md +115 -81
  100. package/bin/torusguard.js +244 -38
  101. package/package.json +6 -2
  102. package/skills/torusguard/SKILL.md +1 -1
  103. package/skills/torusguard/__pycache__/bootstrap.cpython-314.pyc +0 -0
  104. package/skills/torusguard/bootstrap.py +52 -8
  105. package/skills/torusguard/payload/.manifest.json +51 -36
  106. package/skills/torusguard/payload/scripts/__pycache__/apply_runner.cpython-314.pyc +0 -0
  107. package/skills/torusguard/payload/scripts/__pycache__/audit_runner.cpython-311.pyc +0 -0
  108. package/skills/torusguard/payload/scripts/__pycache__/audit_runner.cpython-314.pyc +0 -0
  109. package/skills/torusguard/payload/scripts/__pycache__/diff_guard.cpython-311.pyc +0 -0
  110. package/skills/torusguard/payload/scripts/__pycache__/diff_guard.cpython-314.pyc +0 -0
  111. package/skills/torusguard/payload/scripts/__pycache__/finding_scorer.cpython-311.pyc +0 -0
  112. package/skills/torusguard/payload/scripts/__pycache__/finding_scorer.cpython-314.pyc +0 -0
  113. package/skills/torusguard/payload/scripts/__pycache__/harden_runner.cpython-314.pyc +0 -0
  114. package/skills/torusguard/payload/scripts/__pycache__/html_reporter.cpython-311.pyc +0 -0
  115. package/skills/torusguard/payload/scripts/__pycache__/html_reporter.cpython-314.pyc +0 -0
  116. package/skills/torusguard/payload/scripts/__pycache__/manifest_builder.cpython-314.pyc +0 -0
  117. package/skills/torusguard/payload/scripts/__pycache__/memory_engine.cpython-311.pyc +0 -0
  118. package/skills/torusguard/payload/scripts/__pycache__/memory_engine.cpython-314.pyc +0 -0
  119. package/skills/torusguard/payload/scripts/__pycache__/monorepo_detector.cpython-311.pyc +0 -0
  120. package/skills/torusguard/payload/scripts/__pycache__/monorepo_detector.cpython-314.pyc +0 -0
  121. package/skills/torusguard/payload/scripts/__pycache__/recheck_runner.cpython-314.pyc +0 -0
  122. package/skills/torusguard/payload/scripts/__pycache__/report_sync.cpython-311.pyc +0 -0
  123. package/skills/torusguard/payload/scripts/__pycache__/report_sync.cpython-314.pyc +0 -0
  124. package/skills/torusguard/payload/scripts/__pycache__/rules_sync.cpython-311.pyc +0 -0
  125. package/skills/torusguard/payload/scripts/__pycache__/run_manager.cpython-311.pyc +0 -0
  126. package/skills/torusguard/payload/scripts/__pycache__/run_manager.cpython-314.pyc +0 -0
  127. package/skills/torusguard/payload/scripts/__pycache__/sarif_exporter.cpython-314.pyc +0 -0
  128. package/skills/torusguard/payload/scripts/__pycache__/stack_detect.cpython-311.pyc +0 -0
  129. package/skills/torusguard/payload/scripts/__pycache__/stack_detect.cpython-314.pyc +0 -0
  130. package/skills/torusguard/payload/scripts/__pycache__/term_ui.cpython-311.pyc +0 -0
  131. package/skills/torusguard/payload/scripts/__pycache__/term_ui.cpython-314.pyc +0 -0
  132. package/skills/torusguard/payload/scripts/apply_runner.py +108 -9
  133. package/skills/torusguard/payload/scripts/audit_runner.py +336 -24
  134. package/skills/torusguard/payload/scripts/harden_runner.py +124 -70
  135. package/skills/torusguard/payload/scripts/html_reporter.py +3585 -648
  136. package/skills/torusguard/payload/scripts/manifest_builder.py +2 -0
  137. package/skills/torusguard/payload/scripts/recheck_runner.py +5 -4
  138. package/skills/torusguard/payload/scripts/recipes_runner.py +91 -8
  139. package/skills/torusguard/payload/scripts/report_sync.py +496 -73
  140. package/skills/torusguard/payload/scripts/sarif_exporter.py +48 -8
  141. package/skills/torusguard/payload/scripts/stack_detect.py +44 -2
  142. package/skills/torusguard/payload/scripts/status_runner.py +267 -0
  143. package/skills/torusguard/payload/scripts/term_ui.py +1 -1
  144. package/skills/torusguard/payload/skills/torusguard/SKILL.md +1 -1
  145. package/skills/torusguard/payload/skills/torusguard-apply/SKILL.md +1 -1
  146. package/skills/torusguard/payload/skills/torusguard-audit/SKILL.md +1 -1
  147. package/skills/torusguard/payload/skills/torusguard-authorize/SKILL.md +1 -1
  148. package/skills/torusguard/payload/skills/torusguard-exploit-check/SKILL.md +1 -1
  149. package/skills/torusguard/payload/skills/torusguard-full/SKILL.md +1 -1
  150. package/skills/torusguard/payload/skills/torusguard-harden/SKILL.md +1 -1
  151. package/skills/torusguard/payload/skills/torusguard-init/SKILL.md +1 -1
  152. package/skills/torusguard/payload/skills/torusguard-recheck/SKILL.md +1 -1
  153. package/skills/torusguard/payload/skills/torusguard-report/SKILL.md +40 -23
  154. package/skills/torusguard/payload/skills/torusguard-status/SKILL.md +1 -1
  155. package/skills/torusguard/payload/skills/torusguard-verify/SKILL.md +1 -1
  156. package/skills/torusguard/payload/skills/torusguard-web-validate/SKILL.md +1 -1
  157. package/skills/torusguard/payload/workflows/apply.md +9 -1
  158. package/skills/torusguard/payload/workflows/audit.md +8 -2
  159. package/skills/torusguard/payload/workflows/authorize.md +1 -1
  160. package/skills/torusguard/payload/workflows/exploit-check.md +1 -1
  161. package/skills/torusguard/payload/workflows/harden.md +6 -1
  162. package/skills/torusguard/payload/workflows/init.md +7 -1
  163. package/skills/torusguard/payload/workflows/memory.md +1 -1
  164. package/skills/torusguard/payload/workflows/recheck.md +1 -1
  165. package/skills/torusguard/payload/workflows/recipes.md +55 -0
  166. package/skills/torusguard/payload/workflows/report.md +59 -55
  167. package/skills/torusguard/payload/workflows/status.md +4 -1
  168. package/skills/torusguard/payload/workflows/torusguard-apply.md +110 -0
  169. package/skills/torusguard/payload/workflows/torusguard-audit.md +105 -0
  170. package/skills/torusguard/payload/workflows/torusguard-authorize.md +96 -0
  171. package/skills/torusguard/payload/workflows/torusguard-exploit-check.md +98 -0
  172. package/skills/torusguard/payload/workflows/torusguard-harden.md +104 -0
  173. package/skills/torusguard/payload/workflows/torusguard-init.md +106 -0
  174. package/skills/torusguard/payload/workflows/torusguard-recheck.md +101 -0
  175. package/skills/torusguard/payload/workflows/torusguard-recipes.md +55 -0
  176. package/skills/torusguard/payload/workflows/torusguard-report.md +105 -0
  177. package/skills/torusguard/payload/workflows/torusguard-status.md +103 -0
  178. package/skills/torusguard/payload/workflows/torusguard-verify.md +100 -0
  179. package/skills/torusguard/payload/workflows/torusguard-web-validate.md +96 -0
  180. package/skills/torusguard/payload/workflows/torusguard.md +19 -0
  181. package/skills/torusguard/payload/workflows/verify.md +1 -1
  182. package/skills/torusguard/payload/workflows/web-validate.md +1 -1
  183. package/skills/torusguard-apply/SKILL.md +1 -1
  184. package/skills/torusguard-audit/SKILL.md +1 -1
  185. package/skills/torusguard-authorize/SKILL.md +1 -1
  186. package/skills/torusguard-exploit-check/SKILL.md +1 -1
  187. package/skills/torusguard-full/SKILL.md +1 -1
  188. package/skills/torusguard-harden/SKILL.md +1 -1
  189. package/skills/torusguard-init/SKILL.md +1 -1
  190. package/skills/torusguard-recheck/SKILL.md +1 -1
  191. package/skills/torusguard-report/SKILL.md +40 -23
  192. package/skills/torusguard-status/SKILL.md +1 -1
  193. package/skills/torusguard-verify/SKILL.md +1 -1
  194. 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.5)
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
- # Parse individual finding cards: ### [RULE-ID] Title
57
- card_pattern = re.compile(
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 card_pattern.finditer(content):
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
- # Extract history tables if present
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 full living security_report.md document."""
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 Living Security Report",
383
+ "# 🛡️ TorusGuard Security Report",
151
384
  "",
152
- "> **Notice:** This document is the living, single-source-of-truth ledger for security findings,",
153
- "> candidate patches, and verified closures across this repository. Updated automatically by",
154
- "> TorusGuard CLI commands (`audit`, `harden`, `apply`, `recheck`) and AI chat workflows.",
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
- "## 📊 Security Posture Overview",
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
- f"- **Health Score:** `{score}/100` — **{status_text}**",
161
- f"- **Last Updated:** `{now_str}`",
162
- f"- **Total Tracked Findings:** `{total}`",
163
- f"- **Status Breakdown:** `🔴 {open_count} Open` · `🟠 {verified_count} Verified` · `🟡 {candidate_count} Candidate` · `🔵 {applied_count} Applied` · `🟢 {resolved_count} Resolved` · `❌ {regressed_count} Regressed` · `⚪ {fp_count} Suppressed`",
164
- "",
165
- "| Severity | Total | Open / Regressed | Resolved | Candidate / Applied |",
166
- "| :--- | :---: | :---: | :---: | :---: |",
167
- f"| **Critical** | {crit_count} | {sum(1 for f in findings if f['severity'].lower() == 'critical' and ('OPEN' in f['status'] or 'REGRESSED' in f['status']))} | {sum(1 for f in findings if f['severity'].lower() == 'critical' and 'RESOLVED' in f['status'])} | {sum(1 for f in findings if f['severity'].lower() == 'critical' and ('CANDIDATE' in f['status'] or 'APPLIED' in f['status']))} |",
168
- f"| **High** | {high_count} | {sum(1 for f in findings if f['severity'].lower() == 'high' and ('OPEN' in f['status'] or 'REGRESSED' in f['status']))} | {sum(1 for f in findings if f['severity'].lower() == 'high' and 'RESOLVED' in f['status'])} | {sum(1 for f in findings if f['severity'].lower() == 'high' and ('CANDIDATE' in f['status'] or 'APPLIED' in f['status']))} |",
169
- f"| **Medium** | {med_count} | {sum(1 for f in findings if f['severity'].lower() == 'medium' and ('OPEN' in f['status'] or 'REGRESSED' in f['status']))} | {sum(1 for f in findings if f['severity'].lower() == 'medium' and 'RESOLVED' in f['status'])} | {sum(1 for f in findings if f['severity'].lower() == 'medium' and ('CANDIDATE' in f['status'] or 'APPLIED' in f['status']))} |",
170
- f"| **Low** | {low_count} | {sum(1 for f in findings if f['severity'].lower() == 'low' and ('OPEN' in f['status'] or 'REGRESSED' in f['status']))} | {sum(1 for f in findings if f['severity'].lower() == 'low' and 'RESOLVED' in f['status'])} | {sum(1 for f in findings if f['severity'].lower() == 'low' and ('CANDIDATE' in f['status'] or 'APPLIED' in f['status']))} |",
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
- "## 🔍 Tracked Findings Detail",
175
- ""
176
- ]
443
+ ])
177
444
 
178
- # Sort findings: Open/Regressed first, then by severity (Critical -> High -> Medium -> Low), then rule_id
179
- sev_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
180
- def sort_key(f):
181
- is_active = 0 if ("OPEN" in f["status"] or "REGRESSED" in f["status"]) else 1
182
- s_rank = sev_rank.get(f["severity"].lower(), 9)
183
- return (is_active, s_rank, f["rule_id"])
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
- sorted_findings = sorted(findings, key=sort_key)
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
- for f in sorted_findings:
188
- lines.append(f"### [{f['rule_id']}] {f['title']}")
189
- lines.append(f"- **Finding ID:** `{f['finding_id']}`")
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
- lines.append(f"**What is wrong:** \n{f.get('what_is_wrong', f.get('description', 'Security violation detected by TorusGuard scanner.'))}")
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
- lines.append(f"**Why it matters:** \n{f.get('why_it_matters', 'Violates TorusGuard strict production safety and isolation invariant.')}")
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
- if f.get("what_should_change"):
201
- lines.append(f"**Governed Remediation:** \n{f['what_should_change']}")
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.write_text(rendered, encoding="utf-8")
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.write_text(rendered, encoding="utf-8")
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.write_text(rendered, encoding="utf-8")
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.write_text(rendered, encoding="utf-8")
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