torusguard 1.3.5 → 1.3.6

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 (152) hide show
  1. package/.torusguard/.manifest.json +35 -35
  2. package/.torusguard/runs/report-latest.html +2685 -303
  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 +12 -0
  23. package/.torusguard/runs/run-20260918-105940-audit/summary.md +5 -0
  24. package/.torusguard/scripts/__pycache__/apply_runner.cpython-314.pyc +0 -0
  25. package/.torusguard/scripts/__pycache__/audit_runner.cpython-314.pyc +0 -0
  26. package/.torusguard/scripts/__pycache__/diff_guard.cpython-314.pyc +0 -0
  27. package/.torusguard/scripts/__pycache__/finding_scorer.cpython-314.pyc +0 -0
  28. package/.torusguard/scripts/__pycache__/harden_runner.cpython-314.pyc +0 -0
  29. package/.torusguard/scripts/__pycache__/html_reporter.cpython-314.pyc +0 -0
  30. package/.torusguard/scripts/__pycache__/manifest_builder.cpython-314.pyc +0 -0
  31. package/.torusguard/scripts/__pycache__/memory_engine.cpython-314.pyc +0 -0
  32. package/.torusguard/scripts/__pycache__/monorepo_detector.cpython-314.pyc +0 -0
  33. package/.torusguard/scripts/__pycache__/recheck_runner.cpython-314.pyc +0 -0
  34. package/.torusguard/scripts/__pycache__/report_sync.cpython-314.pyc +0 -0
  35. package/.torusguard/scripts/__pycache__/run_manager.cpython-314.pyc +0 -0
  36. package/.torusguard/scripts/__pycache__/sarif_exporter.cpython-314.pyc +0 -0
  37. package/.torusguard/scripts/__pycache__/stack_detect.cpython-314.pyc +0 -0
  38. package/.torusguard/scripts/__pycache__/term_ui.cpython-314.pyc +0 -0
  39. package/.torusguard/scripts/apply_runner.py +11 -5
  40. package/.torusguard/scripts/audit_runner.py +222 -15
  41. package/.torusguard/scripts/harden_runner.py +3 -3
  42. package/.torusguard/scripts/html_reporter.py +2668 -648
  43. package/.torusguard/scripts/recheck_runner.py +3 -3
  44. package/.torusguard/scripts/recipes_runner.py +3 -3
  45. package/.torusguard/scripts/report_sync.py +370 -74
  46. package/.torusguard/scripts/sarif_exporter.py +48 -8
  47. package/.torusguard/scripts/stack_detect.py +44 -2
  48. package/.torusguard/scripts/term_ui.py +1 -1
  49. package/.torusguard/skills/torusguard/SKILL.md +75 -72
  50. package/.torusguard/skills/torusguard-apply/SKILL.md +84 -80
  51. package/.torusguard/skills/torusguard-audit/SKILL.md +87 -83
  52. package/.torusguard/skills/torusguard-authorize/SKILL.md +7 -2
  53. package/.torusguard/skills/torusguard-exploit-check/SKILL.md +7 -2
  54. package/.torusguard/skills/torusguard-full/SKILL.md +7 -2
  55. package/.torusguard/skills/torusguard-harden/SKILL.md +98 -94
  56. package/.torusguard/skills/torusguard-init/SKILL.md +68 -64
  57. package/.torusguard/skills/torusguard-recheck/SKILL.md +81 -77
  58. package/.torusguard/skills/torusguard-report/SKILL.md +93 -72
  59. package/.torusguard/skills/torusguard-status/SKILL.md +62 -58
  60. package/.torusguard/skills/torusguard-verify/SKILL.md +7 -2
  61. package/.torusguard/skills/torusguard-web-validate/SKILL.md +7 -2
  62. package/.torusguard/workflows/apply.md +1 -1
  63. package/.torusguard/workflows/audit.md +1 -1
  64. package/.torusguard/workflows/authorize.md +1 -1
  65. package/.torusguard/workflows/exploit-check.md +1 -1
  66. package/.torusguard/workflows/harden.md +1 -1
  67. package/.torusguard/workflows/init.md +1 -1
  68. package/.torusguard/workflows/memory.md +1 -1
  69. package/.torusguard/workflows/recheck.md +1 -1
  70. package/.torusguard/workflows/report.md +1 -1
  71. package/.torusguard/workflows/status.md +1 -1
  72. package/.torusguard/workflows/verify.md +1 -1
  73. package/.torusguard/workflows/web-validate.md +1 -1
  74. package/README.md +14 -8
  75. package/bin/torusguard.js +56 -20
  76. package/package.json +1 -1
  77. package/skills/torusguard/SKILL.md +1 -1
  78. package/skills/torusguard/bootstrap.py +7 -2
  79. package/skills/torusguard/payload/.manifest.json +35 -35
  80. package/skills/torusguard/payload/scripts/__pycache__/apply_runner.cpython-314.pyc +0 -0
  81. package/skills/torusguard/payload/scripts/__pycache__/audit_runner.cpython-311.pyc +0 -0
  82. package/skills/torusguard/payload/scripts/__pycache__/audit_runner.cpython-314.pyc +0 -0
  83. package/skills/torusguard/payload/scripts/__pycache__/diff_guard.cpython-311.pyc +0 -0
  84. package/skills/torusguard/payload/scripts/__pycache__/diff_guard.cpython-314.pyc +0 -0
  85. package/skills/torusguard/payload/scripts/__pycache__/finding_scorer.cpython-311.pyc +0 -0
  86. package/skills/torusguard/payload/scripts/__pycache__/finding_scorer.cpython-314.pyc +0 -0
  87. package/skills/torusguard/payload/scripts/__pycache__/harden_runner.cpython-314.pyc +0 -0
  88. package/skills/torusguard/payload/scripts/__pycache__/html_reporter.cpython-311.pyc +0 -0
  89. package/skills/torusguard/payload/scripts/__pycache__/html_reporter.cpython-314.pyc +0 -0
  90. package/skills/torusguard/payload/scripts/__pycache__/manifest_builder.cpython-314.pyc +0 -0
  91. package/skills/torusguard/payload/scripts/__pycache__/memory_engine.cpython-311.pyc +0 -0
  92. package/skills/torusguard/payload/scripts/__pycache__/memory_engine.cpython-314.pyc +0 -0
  93. package/skills/torusguard/payload/scripts/__pycache__/monorepo_detector.cpython-311.pyc +0 -0
  94. package/skills/torusguard/payload/scripts/__pycache__/monorepo_detector.cpython-314.pyc +0 -0
  95. package/skills/torusguard/payload/scripts/__pycache__/recheck_runner.cpython-314.pyc +0 -0
  96. package/skills/torusguard/payload/scripts/__pycache__/report_sync.cpython-311.pyc +0 -0
  97. package/skills/torusguard/payload/scripts/__pycache__/report_sync.cpython-314.pyc +0 -0
  98. package/skills/torusguard/payload/scripts/__pycache__/rules_sync.cpython-311.pyc +0 -0
  99. package/skills/torusguard/payload/scripts/__pycache__/run_manager.cpython-311.pyc +0 -0
  100. package/skills/torusguard/payload/scripts/__pycache__/run_manager.cpython-314.pyc +0 -0
  101. package/skills/torusguard/payload/scripts/__pycache__/sarif_exporter.cpython-314.pyc +0 -0
  102. package/skills/torusguard/payload/scripts/__pycache__/stack_detect.cpython-311.pyc +0 -0
  103. package/skills/torusguard/payload/scripts/__pycache__/stack_detect.cpython-314.pyc +0 -0
  104. package/skills/torusguard/payload/scripts/__pycache__/term_ui.cpython-311.pyc +0 -0
  105. package/skills/torusguard/payload/scripts/__pycache__/term_ui.cpython-314.pyc +0 -0
  106. package/skills/torusguard/payload/scripts/apply_runner.py +11 -5
  107. package/skills/torusguard/payload/scripts/audit_runner.py +222 -15
  108. package/skills/torusguard/payload/scripts/harden_runner.py +3 -3
  109. package/skills/torusguard/payload/scripts/html_reporter.py +2668 -648
  110. package/skills/torusguard/payload/scripts/recheck_runner.py +3 -3
  111. package/skills/torusguard/payload/scripts/recipes_runner.py +3 -3
  112. package/skills/torusguard/payload/scripts/report_sync.py +370 -74
  113. package/skills/torusguard/payload/scripts/sarif_exporter.py +48 -8
  114. package/skills/torusguard/payload/scripts/stack_detect.py +44 -2
  115. package/skills/torusguard/payload/scripts/term_ui.py +1 -1
  116. package/skills/torusguard/payload/skills/torusguard/SKILL.md +1 -1
  117. package/skills/torusguard/payload/skills/torusguard-apply/SKILL.md +1 -1
  118. package/skills/torusguard/payload/skills/torusguard-audit/SKILL.md +1 -1
  119. package/skills/torusguard/payload/skills/torusguard-authorize/SKILL.md +1 -1
  120. package/skills/torusguard/payload/skills/torusguard-exploit-check/SKILL.md +1 -1
  121. package/skills/torusguard/payload/skills/torusguard-full/SKILL.md +1 -1
  122. package/skills/torusguard/payload/skills/torusguard-harden/SKILL.md +1 -1
  123. package/skills/torusguard/payload/skills/torusguard-init/SKILL.md +1 -1
  124. package/skills/torusguard/payload/skills/torusguard-recheck/SKILL.md +1 -1
  125. package/skills/torusguard/payload/skills/torusguard-report/SKILL.md +40 -23
  126. package/skills/torusguard/payload/skills/torusguard-status/SKILL.md +1 -1
  127. package/skills/torusguard/payload/skills/torusguard-verify/SKILL.md +1 -1
  128. package/skills/torusguard/payload/skills/torusguard-web-validate/SKILL.md +1 -1
  129. package/skills/torusguard/payload/workflows/apply.md +1 -1
  130. package/skills/torusguard/payload/workflows/audit.md +1 -1
  131. package/skills/torusguard/payload/workflows/authorize.md +1 -1
  132. package/skills/torusguard/payload/workflows/exploit-check.md +1 -1
  133. package/skills/torusguard/payload/workflows/harden.md +1 -1
  134. package/skills/torusguard/payload/workflows/init.md +1 -1
  135. package/skills/torusguard/payload/workflows/memory.md +1 -1
  136. package/skills/torusguard/payload/workflows/recheck.md +1 -1
  137. package/skills/torusguard/payload/workflows/report.md +1 -1
  138. package/skills/torusguard/payload/workflows/status.md +1 -1
  139. package/skills/torusguard/payload/workflows/verify.md +1 -1
  140. package/skills/torusguard/payload/workflows/web-validate.md +1 -1
  141. package/skills/torusguard-apply/SKILL.md +1 -1
  142. package/skills/torusguard-audit/SKILL.md +1 -1
  143. package/skills/torusguard-authorize/SKILL.md +1 -1
  144. package/skills/torusguard-exploit-check/SKILL.md +1 -1
  145. package/skills/torusguard-full/SKILL.md +1 -1
  146. package/skills/torusguard-harden/SKILL.md +1 -1
  147. package/skills/torusguard-init/SKILL.md +1 -1
  148. package/skills/torusguard-recheck/SKILL.md +1 -1
  149. package/skills/torusguard-report/SKILL.md +40 -23
  150. package/skills/torusguard-status/SKILL.md +1 -1
  151. package/skills/torusguard-verify/SKILL.md +1 -1
  152. package/skills/torusguard-web-validate/SKILL.md +1 -1
@@ -1,648 +1,2668 @@
1
- #!/usr/bin/env python3
2
- """
3
- TorusGuard Visual HTML Report Emitter (v1.2.0)
4
- Compiles a self-contained, zero-external-CDN, interactive dark-mode dashboard.
5
- Embeds SVG gauges, 7-stage pipeline radar, Golden Fix Recipes, and attack surface analytics.
6
- Zero external runtime dependencies (100% Python 3.10+ standard library).
7
- """
8
-
9
- import sys
10
- import json
11
- import html
12
- import datetime
13
- import argparse
14
- from pathlib import Path
15
- from typing import Any
16
-
17
- # Windows console UTF-8 support
18
- if sys.stdout and hasattr(sys.stdout, "reconfigure"):
19
- try:
20
- getattr(sys.stdout, "reconfigure")(encoding="utf-8", errors="replace")
21
- except (AttributeError, OSError):
22
- pass
23
- if sys.stderr and hasattr(sys.stderr, "reconfigure"):
24
- try:
25
- getattr(sys.stderr, "reconfigure")(encoding="utf-8", errors="replace")
26
- except (AttributeError, OSError):
27
- pass
28
-
29
-
30
- def find_project_root(start_dir: str | Path | None = None) -> Path:
31
- """Detect project root directory."""
32
- current = Path(start_dir or Path.cwd()).resolve()
33
- markers = [".git", "package.json", "pyproject.toml", ".torusguard"]
34
- for m in markers:
35
- if (current / m).exists():
36
- return current
37
- for parent in current.parents:
38
- for m in markers:
39
- if (parent / m).exists():
40
- return parent
41
- return current
42
-
43
-
44
- def load_report_telemetry(root_dir: Path) -> dict[str, Any]:
45
- """Gather all project telemetry, rules catalog, and memory state."""
46
- tg_dir = root_dir / ".torusguard"
47
- memory_dir = tg_dir / "memory"
48
- config_file = tg_dir / "config" / "torusguard.json"
49
-
50
- cfg: dict[str, Any] = {}
51
- if config_file.exists():
52
- try:
53
- cfg = json.loads(config_file.read_text(encoding="utf-8"))
54
- except (json.JSONDecodeError, OSError):
55
- cfg = {}
56
-
57
- profile_file = memory_dir / "profile.json"
58
- patterns_file = memory_dir / "patterns.json"
59
- context_file = memory_dir / "context.json"
60
-
61
- profile: dict[str, Any] = {}
62
- patterns: list[dict[str, Any]] = []
63
- context: dict[str, Any] = {}
64
-
65
- if profile_file.exists():
66
- try:
67
- profile = json.loads(profile_file.read_text(encoding="utf-8"))
68
- except (json.JSONDecodeError, OSError):
69
- profile = {}
70
-
71
- if patterns_file.exists():
72
- try:
73
- patterns = json.loads(patterns_file.read_text(encoding="utf-8"))
74
- except (json.JSONDecodeError, OSError):
75
- patterns = []
76
-
77
- if context_file.exists():
78
- try:
79
- context = json.loads(context_file.read_text(encoding="utf-8"))
80
- except (json.JSONDecodeError, OSError):
81
- context = {}
82
-
83
- stack_info = cfg.get("detected_stack", {})
84
- if not stack_info:
85
- try:
86
- import stack_detect # type: ignore
87
- stack_info = stack_detect.detect_stack(root_dir)
88
- except Exception:
89
- stack_info = {"language": "Universal", "framework": "None", "data_layer": "None"}
90
-
91
- ist_tz = datetime.timezone(datetime.timedelta(hours=5, minutes=30), name="IST")
92
- now_ist = datetime.datetime.now(ist_tz)
93
- return {
94
- "project_name": root_dir.name or "Project",
95
- "root_dir": str(root_dir),
96
- "generated_at": now_ist.strftime("%Y-%m-%d %H:%M:%S IST"),
97
- "config": cfg,
98
- "profile": profile,
99
- "patterns": patterns,
100
- "context": context,
101
- "detected_stack": stack_info
102
- }
103
-
104
-
105
- def compute_posture_score(telemetry: dict[str, Any]) -> int:
106
- """Calculate an overall health score (0-100) based on verified fixes and regressions."""
107
- profile = telemetry.get("profile", {})
108
- patterns = telemetry.get("patterns", [])
109
-
110
- base_score = 85
111
- fix_rate = profile.get("fix_rate_percentage")
112
- if fix_rate is not None:
113
- base_score = int(fix_rate)
114
-
115
- regressions = sum(1 for p in patterns if p.get("pattern_type") == "regression_watch")
116
- recipes = sum(1 for p in patterns if p.get("pattern_type") == "golden_fix_recipe")
117
-
118
- score = base_score - (regressions * 5) + (recipes * 3)
119
- return min(max(score, 10), 100)
120
-
121
-
122
- def generate_html_dashboard(telemetry: dict[str, Any]) -> str:
123
- """Build a complete, standalone, single-file HTML dashboard."""
124
- score = compute_posture_score(telemetry)
125
- profile = telemetry.get("profile", {})
126
- patterns = telemetry.get("patterns", [])
127
- cfg = telemetry.get("config", {})
128
- detected = cfg.get("detected_stack", {})
129
- stack = profile.get("stack", [])
130
- if not stack and detected.get("language") and detected.get("language") != "Unknown":
131
- stack = [detected["language"]]
132
- if detected.get("framework") and detected["framework"] != "None":
133
- stack.append(detected["framework"])
134
- if stack:
135
- stack_label = ", ".join(stack)
136
- else:
137
- # Avoid hardcoding "Universal Polyglot Application" if stack is empty
138
- detected_lang = telemetry.get("detected_stack", {}).get("language", "Unknown")
139
- stack_label = detected_lang if detected_lang != "Unknown" else "Polyglot Application"
140
-
141
- recipes = [p for p in patterns if p.get("pattern_type") == "golden_fix_recipe"]
142
- regressions = [p for p in patterns if p.get("pattern_type") == "regression_watch"]
143
- false_positives = [p for p in patterns if p.get("pattern_type") == "false_positive_class"]
144
- recurring = [p for p in patterns if p.get("pattern_type") in ("recurring_fix", "common_vulnerability")]
145
-
146
- # SVG Circle Math for Gauge (radius 40, circ = 251.2)
147
- circumference = 251.2
148
- dash_offset = circumference - (circumference * (score / 100.0))
149
- score_color = "#3fb950" if score >= 80 else ("#d29922" if score >= 60 else "#f85149")
150
-
151
- # Generate recipe cards HTML
152
- recipe_cards_html = ""
153
- if recipes:
154
- for r in recipes[:6]:
155
- rule = html.escape(r.get("rule_id", "TG-SEC"))
156
- desc = html.escape(r.get("description", "Verified fix"))
157
- rdata = r.get("recipe_data", {})
158
- diff = html.escape(rdata.get("diff_snippet", ""))
159
- verified = rdata.get("verified_count", 1)
160
- metrics = rdata.get("ponytail_metrics", {})
161
- adds = metrics.get("additions", 0)
162
- dels = metrics.get("deletions", 0)
163
-
164
- recipe_cards_html += f"""
165
- <div class="card recipe-card">
166
- <div class="card-header">
167
- <span class="badge badge-success">{rule}</span>
168
- <span class="meta-label">Verified {verified}x &middot; +{adds}/-{dels} Ponytail</span>
169
- </div>
170
- <p class="recipe-desc">{desc}</p>
171
- <pre class="diff-block"><code>{diff}</code></pre>
172
- </div>
173
- """
174
- else:
175
- recipe_cards_html = """
176
- <div class="empty-state">
177
- <p>No golden fix recipes captured yet. Run <code>npx torusguard apply</code> (CLI) or <code>/torusguard-apply</code> (AI Chat) on verified remediations to distill reusable recipes.</p>
178
- </div>
179
- """
180
-
181
- # Generate regression watch & suppression rows
182
- regression_rows_html = ""
183
- combined_watches: list[dict[str, str]] = []
184
- for reg in regressions:
185
- combined_watches.append({
186
- "badge_class": "badge-danger",
187
- "rule": reg.get("rule_id", "TG-REG"),
188
- "desc": reg.get("description", "Active watch"),
189
- "target": ", ".join(reg.get("affected_files", [])) or "Monitored files"
190
- })
191
- for fp in false_positives:
192
- combined_watches.append({
193
- "badge_class": "badge-warning",
194
- "rule": fp.get("rule_id", "TG-FP"),
195
- "desc": fp.get("description", "False positive suppression"),
196
- "target": fp.get("reason") or "Suppressed rule pattern"
197
- })
198
-
199
- if combined_watches:
200
- for item in combined_watches:
201
- rule = html.escape(item["rule"])
202
- desc = html.escape(item["desc"])
203
- target = html.escape(item["target"])
204
- bclass = item["badge_class"]
205
- regression_rows_html += f"""
206
- <tr class="reg-row">
207
- <td><span class="badge {bclass}">{rule}</span></td>
208
- <td>{desc}</td>
209
- <td><code>{target}</code></td>
210
- </tr>
211
- """
212
- else:
213
- regression_rows_html = "<tr><td colspan='3' class='empty-cell'>Zero active security regressions or suppressions. Posture clean.</td></tr>"
214
-
215
- stack_info = telemetry.get("detected_stack", {})
216
- primary_lang = stack_info.get("language", "Unknown")
217
- if primary_lang in ("Unknown", "None", "", None):
218
- root_path = Path(telemetry.get("root_dir", "."))
219
- if (root_path / "tsconfig.json").is_file() or list(root_path.glob("*.ts")) or list((root_path / "src").glob("*.ts*")):
220
- primary_lang = "TypeScript"
221
- elif (root_path / "package.json").is_file():
222
- primary_lang = "JavaScript / TypeScript"
223
- elif (root_path / "pyproject.toml").is_file() or (root_path / "requirements.txt").is_file() or list(root_path.glob("*.py")):
224
- primary_lang = "Python"
225
- elif (root_path / "go.mod").is_file():
226
- primary_lang = "Go"
227
- else:
228
- primary_lang = "Unknown"
229
-
230
- primary_fw = stack_info.get("framework", "None")
231
- primary_orm = stack_info.get("data_layer", "None")
232
- sub_stacks = stack_info.get("sub_stacks", [])
233
-
234
- chips = [f'<span class="chip chip-primary">&#9679; {html.escape(primary_lang)}</span>']
235
- if primary_fw and primary_fw != "None":
236
- chips.append(f'<span class="chip">&#9881; {html.escape(primary_fw)}</span>')
237
- if primary_orm and primary_orm != "None":
238
- chips.append(f'<span class="chip">&#128451; {html.escape(primary_orm)}</span>')
239
- for pkg in sub_stacks[:8]:
240
- pkg_p = html.escape(pkg.get("path", ""))
241
- pkg_l = html.escape(pkg.get("language", ""))
242
- pkg_f = html.escape(pkg.get("framework", ""))
243
- chips.append(f'<span class="chip" style="border-color: rgba(63, 185, 80, 0.4); color: var(--accent-green);">&#128230; {pkg_p} ({pkg_l} / {pkg_f})</span>')
244
- stack_chips_html = "\n".join(chips)
245
-
246
- return f"""<!DOCTYPE html>
247
- <html lang="en">
248
- <head>
249
- <meta charset="UTF-8">
250
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
251
- <title>TorusGuard Security Posture &middot; {html.escape(telemetry['project_name'])}</title>
252
- <style>
253
- :root {{
254
- --bg-dark: #0d1117;
255
- --bg-surface: #161b22;
256
- --bg-card: #21262d;
257
- --border-subtle: #30363d;
258
- --text-main: #f0f6fc;
259
- --text-muted: #8b949e;
260
- --accent-blue: #58a6ff;
261
- --accent-green: #3fb950;
262
- --accent-yellow: #d29922;
263
- --accent-red: #f85149;
264
- --font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji";
265
- }}
266
- * {{ box-sizing: border-box; margin: 0; padding: 0; }}
267
- body {{
268
- background: var(--bg-dark);
269
- color: var(--text-main);
270
- font-family: var(--font-stack);
271
- line-height: 1.5;
272
- padding: 24px;
273
- }}
274
- .container {{ max-width: 1200px; margin: 0 auto; }}
275
- header {{
276
- display: flex;
277
- justify-content: space-between;
278
- align-items: center;
279
- padding-bottom: 20px;
280
- border-bottom: 1px solid var(--border-subtle);
281
- margin-bottom: 24px;
282
- }}
283
- .logo-group h1 {{
284
- font-size: 24px;
285
- font-weight: 700;
286
- display: flex;
287
- align-items: center;
288
- gap: 8px;
289
- }}
290
- .shield-icon {{ font-size: 26px; }}
291
- .project-meta {{ color: var(--text-muted); font-size: 14px; margin-top: 4px; }}
292
- .status-badge {{
293
- background: rgba(63, 185, 80, 0.15);
294
- border: 1px solid var(--accent-green);
295
- color: var(--accent-green);
296
- padding: 6px 14px;
297
- border-radius: 20px;
298
- font-size: 13px;
299
- font-weight: 600;
300
- }}
301
- .grid-top {{
302
- display: grid;
303
- grid-template-columns: 280px 1fr 1fr 1fr;
304
- gap: 16px;
305
- margin-bottom: 24px;
306
- }}
307
- @media (max-width: 900px) {{
308
- .grid-top {{ grid-template-columns: 1fr 1fr; }}
309
- }}
310
- @media (max-width: 600px) {{
311
- .grid-top {{ grid-template-columns: 1fr; }}
312
- }}
313
- .card {{
314
- background: var(--bg-surface);
315
- border: 1px solid var(--border-subtle);
316
- border-radius: 8px;
317
- padding: 20px;
318
- }}
319
- .gauge-card {{
320
- display: flex;
321
- flex-direction: column;
322
- align-items: center;
323
- justify-content: center;
324
- text-align: center;
325
- }}
326
- .gauge-svg {{ width: 120px; height: 120px; transform: rotate(-90deg); }}
327
- .gauge-bg {{ fill: none; stroke: var(--bg-card); stroke-width: 8; }}
328
- .gauge-fill {{
329
- fill: none;
330
- stroke: {score_color};
331
- stroke-width: 8;
332
- stroke-linecap: round;
333
- stroke-dasharray: 251.2;
334
- stroke-dashoffset: {dash_offset};
335
- transition: stroke-dashoffset 0.8s ease;
336
- }}
337
- .gauge-value {{
338
- position: absolute;
339
- font-size: 32px;
340
- font-weight: 700;
341
- color: var(--text-main);
342
- }}
343
- .stat-label {{ font-size: 13px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }}
344
- .stat-value {{ font-size: 28px; font-weight: 700; color: var(--text-main); }}
345
- .stat-sub {{ font-size: 12px; color: var(--text-muted); margin-top: 6px; }}
346
- .pipeline-section {{ margin-bottom: 24px; }}
347
- .pipeline-tracker {{
348
- display: flex;
349
- justify-content: space-between;
350
- position: relative;
351
- margin-top: 16px;
352
- }}
353
- .pipeline-step {{
354
- display: flex;
355
- flex-direction: column;
356
- align-items: center;
357
- flex: 1;
358
- text-align: center;
359
- position: relative;
360
- z-index: 2;
361
- }}
362
- .step-circle {{
363
- width: 32px;
364
- height: 32px;
365
- border-radius: 50%;
366
- background: var(--bg-card);
367
- border: 2px solid var(--accent-blue);
368
- display: flex;
369
- align-items: center;
370
- justify-content: center;
371
- font-size: 13px;
372
- font-weight: 700;
373
- color: var(--accent-blue);
374
- margin-bottom: 8px;
375
- }}
376
- .step-circle.active {{ background: var(--accent-blue); color: var(--bg-dark); }}
377
- .step-name {{ font-size: 12px; font-weight: 600; color: var(--text-main); }}
378
- .step-cmd {{ font-size: 11px; color: var(--text-muted); font-family: monospace; margin-top: 2px; }}
379
- .pipeline-line {{
380
- position: absolute;
381
- top: 16px;
382
- left: 6%;
383
- right: 6%;
384
- height: 2px;
385
- background: var(--border-subtle);
386
- z-index: 1;
387
- }}
388
- .polyglot-chips {{
389
- display: flex;
390
- flex-wrap: wrap;
391
- gap: 8px;
392
- margin-top: 10px;
393
- }}
394
- .chip {{
395
- background: var(--bg-card);
396
- border: 1px solid var(--border-subtle);
397
- border-radius: 16px;
398
- padding: 4px 12px;
399
- font-size: 12px;
400
- font-weight: 500;
401
- color: var(--text-main);
402
- display: inline-flex;
403
- align-items: center;
404
- gap: 6px;
405
- }}
406
- .chip-primary {{
407
- border-color: var(--accent-blue);
408
- color: var(--accent-blue);
409
- background: rgba(88, 166, 255, 0.1);
410
- }}
411
- .grid-bottom {{
412
- display: grid;
413
- grid-template-columns: 1fr 1fr;
414
- gap: 24px;
415
- margin-top: 24px;
416
- }}
417
- @media (max-width: 800px) {{
418
- .grid-bottom {{ grid-template-columns: 1fr; }}
419
- }}
420
- .section-title {{
421
- font-size: 18px;
422
- font-weight: 600;
423
- margin-bottom: 16px;
424
- display: flex;
425
- align-items: center;
426
- gap: 8px;
427
- }}
428
- .badge {{
429
- display: inline-block;
430
- padding: 3px 8px;
431
- border-radius: 12px;
432
- font-size: 11px;
433
- font-weight: 600;
434
- font-family: monospace;
435
- }}
436
- .badge-success {{ background: rgba(63, 185, 80, 0.15); color: var(--accent-green); border: 1px solid rgba(63, 185, 80, 0.3); }}
437
- .badge-danger {{ background: rgba(248, 81, 73, 0.15); color: var(--accent-red); border: 1px solid rgba(248, 81, 73, 0.3); }}
438
- .recipe-card {{
439
- background: var(--bg-card);
440
- border: 1px solid var(--border-subtle);
441
- border-radius: 6px;
442
- padding: 12px;
443
- margin-bottom: 12px;
444
- }}
445
- .card-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }}
446
- .meta-label {{ font-size: 11px; color: var(--text-muted); }}
447
- .recipe-desc {{ font-size: 13px; color: var(--text-main); margin-bottom: 8px; }}
448
- .diff-block {{
449
- background: var(--bg-dark);
450
- border: 1px solid var(--border-subtle);
451
- border-radius: 4px;
452
- padding: 8px 12px;
453
- font-family: monospace;
454
- font-size: 12px;
455
- overflow-x: auto;
456
- color: #e6edf3;
457
- }}
458
- table {{ width: 100%; border-collapse: collapse; }}
459
- th, td {{ padding: 10px 12px; text-align: left; font-size: 13px; }}
460
- th {{ color: var(--text-muted); border-bottom: 1px solid var(--border-subtle); font-weight: 600; font-size: 12px; text-transform: uppercase; }}
461
- td {{ border-bottom: 1px solid rgba(48, 54, 61, 0.5); }}
462
- .empty-state {{ text-align: center; padding: 24px; color: var(--text-muted); font-size: 13px; }}
463
- .empty-cell {{ text-align: center; color: var(--text-muted); padding: 16px; font-style: italic; }}
464
- footer {{
465
- margin-top: 40px;
466
- text-align: center;
467
- color: var(--text-muted);
468
- font-size: 12px;
469
- border-top: 1px solid var(--border-subtle);
470
- padding-top: 20px;
471
- }}
472
- </style>
473
- </head>
474
- <body>
475
- <div class="container">
476
- <header>
477
- <div class="logo-group">
478
- <h1><span class="shield-icon">&#128737;</span> TorusGuard Security Posture</h1>
479
- <div class="project-meta">Workspace: <strong>{html.escape(telemetry['project_name'])}</strong> &middot; Stack: <strong>{html.escape(stack_label)}</strong> &middot; {telemetry['generated_at']}</div>
480
- </div>
481
- <div class="status-badge">&check; Local Governance Active</div>
482
- </header>
483
-
484
- <!-- Top KPI Grid -->
485
- <div class="grid-top">
486
- <div class="card gauge-card">
487
- <div style="position: relative; display: flex; align-items: center; justify-content: center;">
488
- <svg class="gauge-svg" viewBox="0 0 100 100">
489
- <circle class="gauge-bg" cx="50" cy="50" r="40" />
490
- <circle class="gauge-fill" cx="50" cy="50" r="40" />
491
- </svg>
492
- <div class="gauge-value">{score}</div>
493
- </div>
494
- <div class="stat-label" style="margin-top: 12px;">Security Posture Score</div>
495
- </div>
496
-
497
- <div class="card">
498
- <div class="stat-label">Verified Golden Recipes</div>
499
- <div class="stat-value" style="color: var(--accent-green);">{len(recipes)}</div>
500
- <div class="stat-sub">Distilled Ponytail fixes (<=35 add, <=25 del)</div>
501
- </div>
502
-
503
- <div class="card">
504
- <div class="stat-label">Active Memory Patterns</div>
505
- <div class="stat-value">{len(patterns)}</div>
506
- <div class="stat-sub">{len(false_positives)} suppressed &middot; {len(recurring)} recurring</div>
507
- </div>
508
-
509
- <div class="card">
510
- <div class="stat-label">Security Rules Catalog</div>
511
- <div class="stat-value" style="color: var(--accent-blue);">71</div>
512
- <div class="stat-sub">11 rule families &middot; Multi-agent verified</div>
513
- </div>
514
- </div>
515
-
516
- <!-- Polyglot Architecture & Workspace Intelligence -->
517
- <div class="card" style="margin-bottom: 24px;">
518
- <div class="section-title">&#9670; Polyglot Architecture &amp; Workspace Intelligence</div>
519
- <div style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px;">
520
- Primary Language: <strong style="color: var(--text-main);">{html.escape(primary_lang)}</strong>
521
- &middot; Framework: <strong style="color: var(--text-main);">{html.escape(primary_fw)}</strong>
522
- &middot; Data Layer: <strong style="color: var(--text-main);">{html.escape(primary_orm)}</strong>
523
- </div>
524
- <div class="polyglot-chips">
525
- {stack_chips_html}
526
- </div>
527
- </div>
528
-
529
- <!-- 7-Stage Canonical Pipeline Tracker -->
530
- <div class="card pipeline-section">
531
- <div class="section-title">&#9658; Canonical 7-Stage Closed-Loop Governance Flow</div>
532
- <div class="pipeline-tracker">
533
- <div class="pipeline-line"></div>
534
- <div class="pipeline-step">
535
- <div class="step-circle active">1</div>
536
- <div class="step-name">Scan</div>
537
- <div class="step-cmd">audit &middot; /audit</div>
538
- </div>
539
- <div class="pipeline-step">
540
- <div class="step-circle active">2</div>
541
- <div class="step-name">Score</div>
542
- <div class="step-cmd">0-100 Score</div>
543
- </div>
544
- <div class="pipeline-step">
545
- <div class="step-circle active">3</div>
546
- <div class="step-name">Harden</div>
547
- <div class="step-cmd">harden &middot; /harden</div>
548
- </div>
549
- <div class="pipeline-step">
550
- <div class="step-circle active">4</div>
551
- <div class="step-name">Authorize</div>
552
- <div class="step-cmd">Human Gate</div>
553
- </div>
554
- <div class="pipeline-step">
555
- <div class="step-circle active">5</div>
556
- <div class="step-name">Apply</div>
557
- <div class="step-cmd">apply &middot; .bak</div>
558
- </div>
559
- <div class="pipeline-step">
560
- <div class="step-circle active">6</div>
561
- <div class="step-name">Recheck</div>
562
- <div class="step-cmd">recheck &middot; verify</div>
563
- </div>
564
- <div class="pipeline-step">
565
- <div class="step-circle active">7</div>
566
- <div class="step-name">Report</div>
567
- <div class="step-cmd">report &middot; SARIF</div>
568
- </div>
569
- </div>
570
- </div>
571
-
572
- <!-- Bottom Details Grid -->
573
- <div class="grid-bottom">
574
- <!-- Golden Recipes Catalog -->
575
- <div class="card">
576
- <div class="section-title">&#10024; Active Golden Fix Recipes ({len(recipes)})</div>
577
- <div class="recipes-list">
578
- {recipe_cards_html}
579
- </div>
580
- </div>
581
-
582
- <!-- Regression Watches & False Positives -->
583
- <div class="card">
584
- <div class="section-title">&#128065; Regression Watches & Suppressions</div>
585
- <table>
586
- <thead>
587
- <tr>
588
- <th>Rule ID</th>
589
- <th>Description / Reason</th>
590
- <th>Target Path</th>
591
- </tr>
592
- </thead>
593
- <tbody>
594
- {regression_rows_html}
595
- </tbody>
596
- </table>
597
- </div>
598
- </div>
599
-
600
- <footer>
601
- TorusGuard v1.3.0 &middot; Autonomous Security Engine for AI-Built Applications &middot; 100% Local-First &middot; Zero Cloud Telemetry Leakage
602
- </footer>
603
- </div>
604
- </body>
605
- </html>
606
- """
607
-
608
-
609
- def emit_html_report(
610
- target_path: str | Path | None = None,
611
- root_dir: str | Path | None = None
612
- ) -> dict[str, Any]:
613
- """Emit the standalone HTML report to disk."""
614
- root = Path(root_dir or find_project_root()).resolve()
615
- telemetry = load_report_telemetry(root)
616
- html_content = generate_html_dashboard(telemetry)
617
-
618
- out_file = Path(target_path) if target_path else (root / ".torusguard" / "runs" / "report-latest.html")
619
- out_file.parent.mkdir(parents=True, exist_ok=True)
620
- out_file.write_text(html_content, encoding="utf-8")
621
-
622
- return {
623
- "status": "success",
624
- "report_path": str(out_file),
625
- "file_size_bytes": len(html_content.encode("utf-8")),
626
- "posture_score": compute_posture_score(telemetry)
627
- }
628
-
629
-
630
- def main():
631
- parser = argparse.ArgumentParser(description="TorusGuard Visual HTML Report Emitter")
632
- parser.add_argument("--out", help="Output HTML file path (default: .torusguard/runs/report-latest.html)")
633
- parser.add_argument("--root", help="Project root override")
634
- parser.add_argument("--json", action="store_true", help="Output raw JSON summary")
635
-
636
- args = parser.parse_args()
637
- root = Path(args.root).resolve() if args.root else None
638
-
639
- res = emit_html_report(target_path=args.out, root_dir=root)
640
- if args.json:
641
- print(json.dumps(res, indent=2))
642
- else:
643
- print(f"[SUCCESS] Emitted TorusGuard visual dashboard: {res['report_path']}")
644
- print(f"Posture Score: {res['posture_score']}/100 · Size: {res['file_size_bytes']} bytes")
645
-
646
-
647
- if __name__ == "__main__":
648
- main()
1
+ #!/usr/bin/env python3
2
+ """
3
+ TorusGuard Visual HTML Report Emitter (v2.3.0)
4
+ Compiles a self-contained, zero-external-CDN, interactive dashboard
5
+ focused on ACTUAL SECURITY POSTURE & GOVERNED REMEDIATION:
6
+ 1. Safe Defenses vs. Harmed Exposure (5-Layer Architectural Invariant Matrix)
7
+ 2. Severity Breakdown (Dynamic Circular SVG Gauge with Glow & Count-Up, KPI Cards)
8
+ 3. Interactive Findings Drawers (Vulnerable Snippets, Invariant Rules, Fix Commands)
9
+ 4. Interactive "What-If" Posture Score Simulator (Live Dynamic Gauge Recalculation)
10
+ 5. Interactive Directory Attack Surface Heatmap (Click-to-Filter)
11
+ 6. OWASP Top 10 (2021/2026) Compliance Radar
12
+ 7. Enterprise Compliance Framework Mapping (SOC 2 Type II, ISO/IEC 27001:2022, HIPAA)
13
+ 8. Root-Cause Architectural Clusters & Blast Radius
14
+ 9. Golden Recipe Explorer (Distilled Verified Fixes, Diffs & Before/After Views)
15
+ 10. Prescriptive "Next Best Defenses" Advisory (Stack-Aware Gaps)
16
+ 11. In-Browser Artifact Exporters (Zero-Network SARIF v2.1.0 & CSV Downloaders)
17
+ 12. Zero-CDN Executive Dark / Light Mode Switcher with LocalStorage Persistence
18
+ 13. High-Contrast Print & PDF Export Styling (@media print)
19
+
20
+ Pure Python 3.10+ standard library (100% zero external dependencies, zero CDNs).
21
+ """
22
+
23
+ import sys
24
+ import json
25
+ import html
26
+ import datetime
27
+ import argparse
28
+ from pathlib import Path
29
+ from typing import Any, TypedDict
30
+
31
+
32
+ class ComplianceControl(TypedDict):
33
+ code: str
34
+ families: str
35
+ rules: list[str]
36
+
37
+
38
+ class ComplianceFramework(TypedDict):
39
+ name: str
40
+ subtitle: str
41
+ controls: list[ComplianceControl]
42
+
43
+
44
+ # Windows console UTF-8 support
45
+ if sys.stdout and hasattr(sys.stdout, "reconfigure"):
46
+ try:
47
+ getattr(sys.stdout, "reconfigure")(encoding="utf-8", errors="replace")
48
+ except (AttributeError, OSError):
49
+ pass
50
+ if sys.stderr and hasattr(sys.stderr, "reconfigure"):
51
+ try:
52
+ getattr(sys.stderr, "reconfigure")(encoding="utf-8", errors="replace")
53
+ except (AttributeError, OSError):
54
+ pass
55
+
56
+
57
+ FAMILY_METADATA: dict[str, dict[str, str]] = {
58
+ "TG-AGENT": {
59
+ "layer_id": "layer-app",
60
+ "layer_name": "Application & LLM Defense",
61
+ "domain": "AI Agent & LLM Injection Defense",
62
+ "guarantee": "Structural user prompt isolation, inert delimiters, and tool call schema validation. Prevents direct and indirect prompt injection attacks.",
63
+ "owasp": "OWASP LLM01: Prompt Injection",
64
+ "compliance": "ISO 27001 A.8.28 (Secure Coding)"
65
+ },
66
+ "TG-INPUT": {
67
+ "layer_id": "layer-app",
68
+ "layer_name": "Application & LLM Defense",
69
+ "domain": "Input Sanitization & Traversal",
70
+ "guarantee": "Strict path normalization, command argument escaping, and safe DOM assignment. Prevents path traversal, OS command injection, and Reflected/DOM XSS.",
71
+ "owasp": "OWASP A03:2021 - Injection",
72
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.28"
73
+ },
74
+ "TG-AUTH": {
75
+ "layer_id": "layer-auth",
76
+ "layer_name": "Identity, Auth & Real-Time",
77
+ "domain": "Authentication & Session Integrity",
78
+ "guarantee": "Timing-safe secret compares, strong argon2id/bcrypt password hashing, explicit JWT algorithm pinning, and secure cookie session attributes.",
79
+ "owasp": "OWASP A07:2021 - Identification & Authentication Failures",
80
+ "compliance": "SOC 2 CC6.1 / ISO 27001 A.8.24 / HIPAA § 164.312(a)(1)"
81
+ },
82
+ "TG-CSRF": {
83
+ "layer_id": "layer-auth",
84
+ "layer_name": "Identity, Auth & Real-Time",
85
+ "domain": "Cross-Site Request Forgery",
86
+ "guarantee": "Cryptographic anti-CSRF token verification on state-changing requests and SameSite=Lax/Strict cookie isolation.",
87
+ "owasp": "OWASP A01:2021 - Broken Access Control",
88
+ "compliance": "SOC 2 CC6.1 / ISO 27001 A.8.28"
89
+ },
90
+ "TG-WS": {
91
+ "layer_id": "layer-auth",
92
+ "layer_name": "Identity, Auth & Real-Time",
93
+ "domain": "WebSocket & Real-Time Security",
94
+ "guarantee": "Handshake origin validation, connection ticket verification, and inbound frame size bounds to eliminate CSWSH and socket exhaustion.",
95
+ "owasp": "OWASP A01:2021 - Broken Access Control / A05: Misconfiguration",
96
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.20"
97
+ },
98
+ "TG-BIZ": {
99
+ "layer_id": "layer-auth",
100
+ "layer_name": "Identity, Auth & Real-Time",
101
+ "domain": "Business Logic & Workflow Limits",
102
+ "guarantee": "Strict positive financial amount constraints, atomic database transaction locks, and coupon stacking bounds to prevent race conditions.",
103
+ "owasp": "OWASP A04:2021 - Insecure Design",
104
+ "compliance": "HIPAA § 164.312(c)(1) Data Integrity / SOC 2 CC7.1"
105
+ },
106
+ "TG-DB": {
107
+ "layer_id": "layer-db",
108
+ "layer_name": "Database, GraphQL & Cache",
109
+ "domain": "Database Isolation & Injection",
110
+ "guarantee": "Zero unparameterized SQL concatenation. Enforces prepared statements with bound parameters and mandatory tenant partition scoping.",
111
+ "owasp": "OWASP A03:2021 - Injection / A01: Broken Access Control",
112
+ "compliance": "SOC 2 CC6.1 / ISO 27001 A.8.28 / HIPAA § 164.312(a)(1)"
113
+ },
114
+ "TG-GQL": {
115
+ "layer_id": "layer-db",
116
+ "layer_name": "Database, GraphQL & Cache",
117
+ "domain": "GraphQL Safety & Introspection",
118
+ "guarantee": "Query depth limiting (max depth <= 7), circular reference blocking, field complexity cost analysis, and production schema introspection suppression.",
119
+ "owasp": "OWASP A05:2021 - Security Misconfiguration",
120
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.28"
121
+ },
122
+ "TG-CACHE": {
123
+ "layer_id": "layer-db",
124
+ "layer_name": "Database, GraphQL & Cache",
125
+ "domain": "Cache Poisoning & Cache Timing",
126
+ "guarantee": "Mandatory Cache-Control: no-store on authenticated sensitive responses and unkeyed HTTP header sanitization to prevent web cache poisoning.",
127
+ "owasp": "OWASP A05:2021 - Security Misconfiguration",
128
+ "compliance": "SOC 2 CC6.7 / ISO 27001 A.8.20"
129
+ },
130
+ "TG-SSRF": {
131
+ "layer_id": "layer-net",
132
+ "layer_name": "Network, SSRF & Webhooks",
133
+ "domain": "Server-Side Request Forgery",
134
+ "guarantee": "Outbound URL hostname whitelisting, RFC1918 private IP range blocking (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.1) and cloud metadata blocking (169.254.169.254).",
135
+ "owasp": "OWASP A10:2021 - Server-Side Request Forgery (SSRF)",
136
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.20"
137
+ },
138
+ "TG-WEBHOOK": {
139
+ "layer_id": "layer-net",
140
+ "layer_name": "Network, SSRF & Webhooks",
141
+ "domain": "Inbound Webhook Verification",
142
+ "guarantee": "Mandatory cryptographic HMAC-SHA256 signature verification over raw request buffers and replay attack protection with <= 300s timestamp limits.",
143
+ "owasp": "OWASP A08:2021 - Software and Data Integrity Failures",
144
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.20 / HIPAA § 164.312(c)(1)"
145
+ },
146
+ "TG-RATE": {
147
+ "layer_id": "layer-net",
148
+ "layer_name": "Network, SSRF & Webhooks",
149
+ "domain": "Rate Limiting & Resource Protection",
150
+ "guarantee": "Sliding-window IP rate limiting on auth and write endpoints, bounded JSON payload parsers (<= 1MB), and slowloris connection timeouts.",
151
+ "owasp": "OWASP A04:2021 - Insecure Design / A07: Auth Failures",
152
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.20"
153
+ },
154
+ "TG-PLATFORM": {
155
+ "layer_id": "layer-platform",
156
+ "layer_name": "Platform, Supply Chain & Secrets",
157
+ "domain": "Server Hardening & Security Headers",
158
+ "guarantee": "Mandatory production security headers (CSP, HSTS max-age>=31536000, X-Content-Type-Options: nosniff, X-Frame-Options: DENY) and debug mode suppression.",
159
+ "owasp": "OWASP A05:2021 - Security Misconfiguration",
160
+ "compliance": "SOC 2 CC6.7 / ISO 27001 A.8.20 / HIPAA § 164.312(e)(1)"
161
+ },
162
+ "TG-SUPPLY": {
163
+ "layer_id": "layer-platform",
164
+ "layer_name": "Platform, Supply Chain & Secrets",
165
+ "domain": "Supply Chain & Dependency Health",
166
+ "guarantee": "Lockfile cryptographic integrity checks, known high/critical CVE build gates, verified registry origins, and build script execution bounds.",
167
+ "owasp": "OWASP A06:2021 - Vulnerable and Outdated Components",
168
+ "compliance": "SOC 2 CC7.1 / ISO 27001 A.8.28"
169
+ },
170
+ "TG-CLIENT": {
171
+ "layer_id": "layer-platform",
172
+ "layer_name": "Platform, Supply Chain & Secrets",
173
+ "domain": "Client Bundle & Frontend Secrets",
174
+ "guarantee": "Zero server role keys, database master credentials, or private API secrets leaked into frontend bundles or public environment variables.",
175
+ "owasp": "OWASP A01:2021 - Broken Access Control / A05: Misconfiguration",
176
+ "compliance": "SOC 2 CC6.1 / ISO 27001 A.8.24 / HIPAA § 164.312(e)(1)"
177
+ },
178
+ "TG-EDGE": {
179
+ "layer_id": "layer-platform",
180
+ "layer_name": "Platform, Supply Chain & Secrets",
181
+ "domain": "Edge Computing & Serverless Limits",
182
+ "guarantee": "Subrequest fan-out limits (<= 50 per invocation), serverless wall-clock execution timeouts (<= 30s), and memory bounds for edge functions.",
183
+ "owasp": "OWASP A04:2021 - Insecure Design",
184
+ "compliance": "SOC 2 CC6.6 / ISO 27001 A.8.20"
185
+ },
186
+ "TG-DIFF": {
187
+ "layer_id": "layer-platform",
188
+ "layer_name": "Platform, Supply Chain & Secrets",
189
+ "domain": "Polyglot Bypass & Churn Bounds",
190
+ "guarantee": "Strict Ponytail Protocol enforcement (<= 35 additions, <= 25 deletions per patch) and zero security suppression pragmas (nosec pragmas, InsecureSkipVerify).",
191
+ "owasp": "OWASP A08:2021 - Software Integrity",
192
+ "compliance": "SOC 2 CC7.1 / ISO 27001 A.8.28 / HIPAA § 164.312(c)(1)"
193
+ },
194
+ "TG-SEC": {
195
+ "layer_id": "layer-platform",
196
+ "layer_name": "Platform, Supply Chain & Secrets",
197
+ "domain": "Core Secrets & API Tokens",
198
+ "guarantee": "Zero hardcoded API keys, private certificates, AWS/GCP/Stripe tokens, or JWT secrets in tracked source code. Mandatory externalized environment configuration.",
199
+ "owasp": "OWASP A02:2021 - Cryptographic Failures",
200
+ "compliance": "SOC 2 CC6.7 / ISO 27001 A.8.24 / HIPAA § 164.312(e)(1)"
201
+ }
202
+ }
203
+
204
+ ARCHITECTURAL_LAYERS: list[dict[str, Any]] = [
205
+ {
206
+ "id": "layer-app",
207
+ "name": "Application & LLM Defense",
208
+ "icon": "🛡️",
209
+ "families": ["TG-AGENT", "TG-INPUT"],
210
+ "description": "User prompt isolation, inert delimiters, traversal defense, and input escaping."
211
+ },
212
+ {
213
+ "id": "layer-auth",
214
+ "name": "Identity, Auth & Real-Time",
215
+ "icon": "🔑",
216
+ "families": ["TG-AUTH", "TG-CSRF", "TG-WS", "TG-BIZ"],
217
+ "description": "Constant-time comparisons, password hashing, algorithm pinning, SameSite cookies, and business flow bounds."
218
+ },
219
+ {
220
+ "id": "layer-db",
221
+ "name": "Database, GraphQL & Cache",
222
+ "icon": "💾",
223
+ "families": ["TG-DB", "TG-GQL", "TG-CACHE"],
224
+ "description": "Parameterized SQL queries, multi-tenant row partitioning, GraphQL query depth limits, and cache timing guards."
225
+ },
226
+ {
227
+ "id": "layer-net",
228
+ "name": "Network, SSRF & Webhooks",
229
+ "icon": "🌐",
230
+ "families": ["TG-SSRF", "TG-WEBHOOK", "TG-RATE"],
231
+ "description": "Private IP / metadata blocking, cryptographic HMAC webhook authentication, and sliding-window rate limiters."
232
+ },
233
+ {
234
+ "id": "layer-platform",
235
+ "name": "Platform, Supply Chain & Secrets",
236
+ "icon": "🏗️",
237
+ "families": ["TG-PLATFORM", "TG-SUPPLY", "TG-CLIENT", "TG-EDGE", "TG-DIFF", "TG-SEC"],
238
+ "description": "Security headers, dependency lockfile integrity, zero client bundle secrets, serverless limits, and Ponytail bounds."
239
+ }
240
+ ]
241
+
242
+
243
+ def find_project_root(start_dir: str | Path | None = None) -> Path:
244
+ """Detect project root directory."""
245
+ current = Path(start_dir or Path.cwd()).resolve()
246
+ markers = [".git", "package.json", "pyproject.toml", ".torusguard"]
247
+ for m in markers:
248
+ if (current / m).exists():
249
+ return current
250
+ for parent in current.parents:
251
+ for m in markers:
252
+ if (parent / m).exists():
253
+ return parent
254
+ return current
255
+
256
+
257
+ def load_all_rule_catalog() -> list[dict[str, Any]]:
258
+ """Load complete 74-rule catalog from audit_runner."""
259
+ try:
260
+ scripts_dir = Path(__file__).resolve().parent
261
+ if str(scripts_dir) not in sys.path:
262
+ sys.path.insert(0, str(scripts_dir))
263
+ import audit_runner # type: ignore
264
+ catalog = []
265
+ for r in getattr(audit_runner, "RULE_PATTERNS", []):
266
+ rid = r.get("rule_id", "TG-GEN")
267
+ fam = rid.rsplit("-", 1)[0]
268
+ meta = FAMILY_METADATA.get(fam, {})
269
+ catalog.append({
270
+ "rule_id": rid,
271
+ "title": r.get("title", "Security Invariant"),
272
+ "severity": r.get("severity", "Medium"),
273
+ "category": r.get("category", "general"),
274
+ "cluster": r.get("cluster", "cluster-general"),
275
+ "layer_id": meta.get("layer_id", "layer-platform"),
276
+ "layer_name": meta.get("layer_name", "Platform & Security"),
277
+ "guarantee": meta.get("guarantee", "Enforces security guardrails without bypasses."),
278
+ "owasp": meta.get("owasp", "OWASP Security Standard"),
279
+ "compliance": meta.get("compliance", "Enterprise Security Baseline")
280
+ })
281
+ if catalog:
282
+ return catalog
283
+ except Exception:
284
+ pass
285
+
286
+ # Fallback built-in catalog if audit_runner cannot be loaded
287
+ return [
288
+ {"rule_id": "TG-SEC-001", "title": "Hardcoded Secret or API Key", "severity": "Critical", "category": "secrets", "layer_id": "layer-platform", "layer_name": "Platform, Supply Chain & Secrets", "guarantee": "Zero hardcoded secrets in source.", "owasp": "OWASP A02:2021", "compliance": "SOC 2 CC6.7"},
289
+ {"rule_id": "TG-SEC-002", "title": "Public Client Environment Secret", "severity": "Critical", "category": "secrets", "layer_id": "layer-platform", "layer_name": "Platform, Supply Chain & Secrets", "guarantee": "Zero private keys in frontend bundles.", "owasp": "OWASP A01:2021", "compliance": "SOC 2 CC6.1"},
290
+ {"rule_id": "TG-AUTH-001", "title": "Weak Password Storage", "severity": "Critical", "category": "auth", "layer_id": "layer-auth", "layer_name": "Identity, Auth & Real-Time", "guarantee": "Enforces strong password hashing.", "owasp": "OWASP A07:2021", "compliance": "SOC 2 CC6.1"},
291
+ {"rule_id": "TG-DB-001", "title": "Raw SQL Concatenation", "severity": "Critical", "category": "database", "layer_id": "layer-db", "layer_name": "Database, GraphQL & Cache", "guarantee": "Enforces parameterized queries.", "owasp": "OWASP A03:2021", "compliance": "SOC 2 CC6.1"},
292
+ {"rule_id": "TG-INPUT-001", "title": "Missing Server Input Validation", "severity": "High", "category": "input", "layer_id": "layer-app", "layer_name": "Application & LLM Defense", "guarantee": "Enforces boundary schema validation.", "owasp": "OWASP A03:2021", "compliance": "ISO 27001 A.8.28"},
293
+ {"rule_id": "TG-RATE-001", "title": "Unlimited Auth Endpoint", "severity": "High", "category": "rate", "layer_id": "layer-net", "layer_name": "Network, SSRF & Webhooks", "guarantee": "Enforces auth rate limiting.", "owasp": "OWASP A04:2021", "compliance": "SOC 2 CC6.6"},
294
+ {"rule_id": "TG-SSRF-001", "title": "Unvalidated Outbound HTTP Request", "severity": "High", "category": "ssrf", "layer_id": "layer-net", "layer_name": "Network, SSRF & Webhooks", "guarantee": "Blocks private VPC and metadata IPs.", "owasp": "OWASP A10:2021", "compliance": "SOC 2 CC6.6"},
295
+ {"rule_id": "TG-PLATFORM-001", "title": "Missing Security Headers", "severity": "Medium", "category": "platform", "layer_id": "layer-platform", "layer_name": "Platform, Supply Chain & Secrets", "guarantee": "Enforces HTTP hardening headers.", "owasp": "OWASP A05:2021", "compliance": "SOC 2 CC6.7"}
296
+ ]
297
+
298
+
299
+ def load_report_telemetry(root_dir: Path) -> dict[str, Any]:
300
+ """Gather all project telemetry, findings, rules catalog, and memory state."""
301
+ tg_dir = root_dir / ".torusguard"
302
+ memory_dir = tg_dir / "memory"
303
+ config_file = tg_dir / "config" / "torusguard.json"
304
+
305
+ cfg: dict[str, Any] = {}
306
+ if config_file.exists():
307
+ try:
308
+ cfg = json.loads(config_file.read_text(encoding="utf-8"))
309
+ except (json.JSONDecodeError, OSError):
310
+ cfg = {}
311
+
312
+ profile_file = memory_dir / "profile.json"
313
+ patterns_file = memory_dir / "patterns.json"
314
+ context_file = memory_dir / "context.json"
315
+
316
+ profile: dict[str, Any] = {}
317
+ patterns: list[dict[str, Any]] = []
318
+ context: dict[str, Any] = {}
319
+
320
+ if profile_file.exists():
321
+ try:
322
+ profile = json.loads(profile_file.read_text(encoding="utf-8"))
323
+ except (json.JSONDecodeError, OSError):
324
+ profile = {}
325
+
326
+ if patterns_file.exists():
327
+ try:
328
+ patterns = json.loads(patterns_file.read_text(encoding="utf-8"))
329
+ except (json.JSONDecodeError, OSError):
330
+ patterns = []
331
+
332
+ if context_file.exists():
333
+ try:
334
+ context = json.loads(context_file.read_text(encoding="utf-8"))
335
+ except (json.JSONDecodeError, OSError):
336
+ context = {}
337
+
338
+ stack_info = cfg.get("detected_stack", {})
339
+ if not stack_info:
340
+ try:
341
+ import stack_detect # type: ignore
342
+ stack_info = stack_detect.detect_stack(root_dir)
343
+ except Exception:
344
+ stack_info = {"language": "Universal", "framework": "None", "data_layer": "None"}
345
+
346
+ # Load actual findings from latest audit run
347
+ runs_dir = tg_dir / "runs"
348
+ latest_findings: list[dict[str, Any]] = []
349
+ latest_run_name = "none"
350
+ if runs_dir.is_dir():
351
+ run_folders = sorted(
352
+ [d for d in runs_dir.iterdir() if d.is_dir() and (d / "findings.json").is_file()],
353
+ key=lambda p: p.stat().st_mtime,
354
+ reverse=True
355
+ )
356
+ if run_folders:
357
+ findings_file = run_folders[0] / "findings.json"
358
+ latest_run_name = run_folders[0].name
359
+ try:
360
+ latest_findings = json.loads(findings_file.read_text(encoding="utf-8"))
361
+ except (json.JSONDecodeError, OSError):
362
+ latest_findings = []
363
+
364
+ # Rule catalog & Safe vs. Harmed computation
365
+ all_rules = load_all_rule_catalog()
366
+ violated_ids = {f.get("rule_id") for f in latest_findings if not f.get("is_canary", False)}
367
+ safe_rules = [r for r in all_rules if r["rule_id"] not in violated_ids]
368
+ violated_rules = [r for r in all_rules if r["rule_id"] in violated_ids]
369
+
370
+ ist_tz = datetime.timezone(datetime.timedelta(hours=5, minutes=30), name="IST")
371
+ now_ist = datetime.datetime.now(ist_tz)
372
+ return {
373
+ "project_name": root_dir.name or "Project",
374
+ "root_dir": str(root_dir),
375
+ "generated_at": now_ist.strftime("%Y-%m-%d %H:%M:%S IST"),
376
+ "config": cfg,
377
+ "profile": profile,
378
+ "patterns": patterns,
379
+ "context": context,
380
+ "detected_stack": stack_info,
381
+ "findings": latest_findings,
382
+ "latest_run": latest_run_name,
383
+ "all_rules": all_rules,
384
+ "safe_rules": safe_rules,
385
+ "violated_rules": violated_rules,
386
+ }
387
+
388
+
389
+ def compute_posture_score(telemetry: dict[str, Any]) -> int:
390
+ """Calculate posture score from actual findings, aligned with report_sync.py."""
391
+ findings = telemetry.get("findings", [])
392
+ if not findings:
393
+ return 100
394
+
395
+ # Only non-canary findings count toward production penalty
396
+ critical = sum(1 for f in findings if f.get("severity") == "Critical" and not f.get("is_canary", False))
397
+ high = sum(1 for f in findings if f.get("severity") == "High" and not f.get("is_canary", False))
398
+ medium = sum(1 for f in findings if f.get("severity") == "Medium" and not f.get("is_canary", False))
399
+ low = sum(1 for f in findings if f.get("severity") == "Low" and not f.get("is_canary", False))
400
+
401
+ penalty = (critical * 25) + (high * 15) + (medium * 5) + (low * 2)
402
+ return max(0, min(100, 100 - penalty))
403
+
404
+
405
+ def get_recommended_defenses(detected_stack: dict[str, Any], violated_rule_ids: set[str]) -> list[dict[str, Any]]:
406
+ """Generate prescriptive next best defense recommendations tailored to the stack."""
407
+ recs = []
408
+ lang = (detected_stack.get("language") or "Universal")
409
+ fw = (detected_stack.get("framework") or "Universal")
410
+ stack_tag = f"{lang}" + (f" / {fw}" if fw != "None" else "")
411
+
412
+ # Platform headers recommendation
413
+ if "TG-PLATFORM-002" not in violated_rule_ids and "TG-PLATFORM-001" not in violated_rule_ids:
414
+ recs.append({
415
+ "rule_id": "TG-PLATFORM-002",
416
+ "priority": "P0 Critical",
417
+ "title": "Enforce HTTP Security Headers (CSP, HSTS, X-Frame-Options)",
418
+ "mitigates": "OWASP A05: Security Misconfiguration",
419
+ "stack_tag": stack_tag,
420
+ "rationale": "Protects against clickjacking, MIME-confusion, and cross-site scripting attacks.",
421
+ "invariant_guarantee": "Zero unhardened HTTP endpoints. Requires HSTS max-age >= 31536000 and CSP nonce isolation.",
422
+ "command": "npx torusguard harden --rule TG-PLATFORM-002"
423
+ })
424
+
425
+ # Rate limiting on auth
426
+ recs.append({
427
+ "rule_id": "TG-RATE-001",
428
+ "priority": "P0 Critical",
429
+ "title": "Auth Endpoint Rate Limiting & Resource Throttling",
430
+ "mitigates": "OWASP A07: Identification and Authentication Failures",
431
+ "stack_tag": stack_tag,
432
+ "rationale": "Prevents brute-force credential stuffing, password spraying, and resource exhaustion.",
433
+ "invariant_guarantee": "Max 5 failed attempts per IP window on auth routes with exponential backoff.",
434
+ "command": "npx torusguard harden --rule TG-RATE-001"
435
+ })
436
+
437
+ # Timing-safe auth compare
438
+ recs.append({
439
+ "rule_id": "TG-AUTH-002",
440
+ "priority": "P1 Recommended",
441
+ "title": "Timing-Safe Secret & Signature Verification",
442
+ "mitigates": "OWASP A02: Cryptographic Failures",
443
+ "stack_tag": stack_tag,
444
+ "rationale": "Eliminates side-channel timing attacks on API keys, HMAC signatures, and tokens.",
445
+ "invariant_guarantee": "Constant-time evaluation across all cryptographic comparison call-sites.",
446
+ "command": "npx torusguard harden --rule TG-AUTH-002"
447
+ })
448
+
449
+ # Tenant database isolation
450
+ if "TG-DB-004" not in violated_rule_ids:
451
+ recs.append({
452
+ "rule_id": "TG-DB-004",
453
+ "priority": "P0 Critical",
454
+ "title": "Enforce Multi-Tenant Database Query Partition Scoping",
455
+ "mitigates": "OWASP A01: Broken Access Control",
456
+ "stack_tag": stack_tag,
457
+ "rationale": "Guarantees cross-tenant data isolation and eliminates BOLA/IDOR vulnerabilities.",
458
+ "invariant_guarantee": "All database queries MUST be explicitly scoped by authenticated tenant ID.",
459
+ "command": "npx torusguard harden --rule TG-DB-004"
460
+ })
461
+
462
+ # SSRF Private IP protection
463
+ recs.append({
464
+ "rule_id": "TG-SSRF-002",
465
+ "priority": "P1 Recommended",
466
+ "title": "Block Outbound Requests to Cloud Metadata (169.254.169.254)",
467
+ "mitigates": "OWASP A10: Server-Side Request Forgery",
468
+ "stack_tag": stack_tag,
469
+ "rationale": "Stops attackers from querying internal cloud metadata instances or private VPC ranges.",
470
+ "invariant_guarantee": "Strict hostname resolution with CIDR blocking for 169.254.169.254 and RFC1918 subnets.",
471
+ "command": "npx torusguard harden --rule TG-SSRF-002"
472
+ })
473
+
474
+ # AI Agent structural defense
475
+ if "TG-AGENT-001" not in violated_rule_ids:
476
+ recs.append({
477
+ "rule_id": "TG-AGENT-001",
478
+ "priority": "P2 Hardening",
479
+ "title": "Structural AI Prompt Sandboxing & Inert Delimiters",
480
+ "mitigates": "OWASP LLM01: Prompt Injection",
481
+ "stack_tag": "AI / LLM Stack",
482
+ "rationale": "Enforces user prompt boundary isolation to stop direct and indirect jailbreaks.",
483
+ "invariant_guarantee": "User input MUST be wrapped in inert XML tags and never directly concatenated into system prompt.",
484
+ "command": "npx torusguard harden --rule TG-AGENT-001"
485
+ })
486
+
487
+ return recs[:5]
488
+
489
+
490
+ def format_diff_html(diff_text: str) -> str:
491
+ """Format unified diff with colorized line styling."""
492
+ if not diff_text:
493
+ return '<span class="diff-line-ctx">// No diff representation recorded</span>'
494
+ formatted_lines = []
495
+ for line in diff_text.splitlines():
496
+ esc = html.escape(line)
497
+ if line.startswith("+++") or line.startswith("---"):
498
+ formatted_lines.append(f'<span class="diff-line-header">{esc}</span>')
499
+ elif line.startswith("@@"):
500
+ formatted_lines.append(f'<span class="diff-line-hunk">{esc}</span>')
501
+ elif line.startswith("+"):
502
+ formatted_lines.append(f'<span class="diff-line-add">{esc}</span>')
503
+ elif line.startswith("-"):
504
+ formatted_lines.append(f'<span class="diff-line-del">{esc}</span>')
505
+ else:
506
+ formatted_lines.append(f'<span class="diff-line-ctx">{esc}</span>')
507
+ return "\n".join(formatted_lines)
508
+
509
+
510
+ def get_curated_golden_recipes(patterns: list[dict[str, Any]]) -> list[dict[str, Any]]:
511
+ """Return memory recipes unpacked with rich diffs, Before/After snippets, and Ponytail metrics."""
512
+ memory_recipes = [p for p in patterns if p.get("pattern_type") == "golden_fix_recipe"]
513
+ unpacked: list[dict[str, Any]] = []
514
+
515
+ for idx, r in enumerate(memory_recipes):
516
+ rd = r.get("recipe_data") or {}
517
+ rule_id = rd.get("rule_id") or r.get("rule_id") or "TG-GEN"
518
+ desc = rd.get("description") or r.get("description") or "Verified fix pattern"
519
+ diff = rd.get("diff_snippet") or ""
520
+ before = rd.get("before_snippet") or ""
521
+ after = rd.get("after_snippet") or ""
522
+ metrics = rd.get("ponytail_metrics") or {"additions": 1, "deletions": 1}
523
+ ftype = rd.get("file_type") or r.get("file_type") or ".ts"
524
+ verified = rd.get("verified_count") or 1
525
+ rec_id = rd.get("recipe_id") or r.get("recipe_id") or f"recipe-{rule_id}-{idx}"
526
+
527
+ # Determine human category
528
+ if "SEC" in rule_id:
529
+ cat = "Secrets"
530
+ elif "CLIENT" in rule_id:
531
+ cat = "Client"
532
+ elif "DB" in rule_id or "GQL" in rule_id:
533
+ cat = "Database"
534
+ elif "INPUT" in rule_id or "AGENT" in rule_id:
535
+ cat = "Input"
536
+ elif "PLATFORM" in rule_id or "SUPPLY" in rule_id or "EDGE" in rule_id or "DIFF" in rule_id:
537
+ cat = "Platform"
538
+ elif "RATE" in rule_id:
539
+ cat = "Rate Limiting"
540
+ elif "SSRF" in rule_id or "WEBHOOK" in rule_id:
541
+ cat = "SSRF"
542
+ else:
543
+ cat = "General"
544
+
545
+ unpacked.append({
546
+ "recipe_id": rec_id,
547
+ "rule_id": rule_id,
548
+ "title": desc,
549
+ "description": desc,
550
+ "diff_snippet": diff,
551
+ "before_snippet": before,
552
+ "after_snippet": after,
553
+ "solution_pattern": after or diff,
554
+ "code_pattern": diff,
555
+ "ponytail_metrics": metrics,
556
+ "file_type": ftype,
557
+ "verified_count": verified,
558
+ "category": cat
559
+ })
560
+
561
+ if unpacked:
562
+ return unpacked
563
+
564
+ # Fallback built-in reference Golden Recipes if patterns is empty
565
+ return [
566
+ {
567
+ "recipe_id": "recipe-TG-DB-001-default",
568
+ "rule_id": "TG-DB-001",
569
+ "title": "Parameterized SQL Query Pattern",
570
+ "description": "Replaces vulnerable raw string interpolation with parameterized bound parameters.",
571
+ "diff_snippet": "--- a/db/queries.py\n+++ b/db/queries.py\n@@ -10,3 +10,3 @@\n-db.execute(f'SELECT * FROM users WHERE id = {user_id}')\n+db.execute('SELECT * FROM users WHERE id = :id', {'id': user_id})",
572
+ "before_snippet": "db.execute(f'SELECT * FROM users WHERE id = {user_id}')",
573
+ "after_snippet": "db.execute('SELECT * FROM users WHERE id = :id', {'id': user_id})",
574
+ "solution_pattern": "db.execute('SELECT * FROM users WHERE id = :id', {'id': user_id})",
575
+ "code_pattern": "- db.execute(f'SELECT * FROM users WHERE id = {user_id}')\n+ db.execute('SELECT * FROM users WHERE id = :id', {'id': user_id})",
576
+ "ponytail_metrics": {"additions": 1, "deletions": 1},
577
+ "file_type": ".py",
578
+ "verified_count": 1,
579
+ "category": "Database"
580
+ },
581
+ {
582
+ "recipe_id": "recipe-TG-AUTH-002-default",
583
+ "rule_id": "TG-AUTH-002",
584
+ "title": "Timing-Safe Cryptographic Comparison",
585
+ "description": "Replaces standard equality (===) with timing-safe constant-time evaluation.",
586
+ "diff_snippet": "--- a/auth/verify.ts\n+++ b/auth/verify.ts\n@@ -22,3 +22,3 @@\n-if (token === expectedSecret) {\n+if (crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedSecret))) {",
587
+ "before_snippet": "if (token === expectedSecret) { ... }",
588
+ "after_snippet": "if (crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedSecret))) { ... }",
589
+ "solution_pattern": "if (crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedSecret))) { ... }",
590
+ "code_pattern": "- if (token === expectedSecret) { ... }\n+ if (crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedSecret))) { ... }",
591
+ "ponytail_metrics": {"additions": 1, "deletions": 1},
592
+ "file_type": ".ts",
593
+ "verified_count": 1,
594
+ "category": "Auth"
595
+ },
596
+ {
597
+ "recipe_id": "recipe-TG-SSRF-002-default",
598
+ "rule_id": "TG-SSRF-002",
599
+ "title": "Outbound Private IP Range Filtering",
600
+ "description": "Validates outbound target IP against private VPC ranges and cloud metadata IPs.",
601
+ "diff_snippet": "--- a/net/proxy.py\n+++ b/net/proxy.py\n@@ -15,4 +15,6 @@\n-response = requests.get(target_url)\n+ip = socket.gethostbyname(urlparse(target_url).hostname)\n+if ipaddress.ip_address(ip).is_private: raise SecurityError('SSRF blocked')\n+response = requests.get(target_url)",
602
+ "before_snippet": "response = requests.get(target_url)",
603
+ "after_snippet": "ip = socket.gethostbyname(urlparse(target_url).hostname)\nif ipaddress.ip_address(ip).is_private: raise SecurityError('SSRF blocked')\nresponse = requests.get(target_url)",
604
+ "solution_pattern": "ip = socket.gethostbyname(urlparse(target_url).hostname)\nif ipaddress.ip_address(ip).is_private: raise SecurityError('SSRF blocked')\nresponse = requests.get(target_url)",
605
+ "code_pattern": "- response = requests.get(target_url)\n+ ip = socket.gethostbyname(urlparse(target_url).hostname)\n+ if ipaddress.ip_address(ip).is_private: raise SecurityError('SSRF blocked')",
606
+ "ponytail_metrics": {"additions": 3, "deletions": 1},
607
+ "file_type": ".py",
608
+ "verified_count": 1,
609
+ "category": "SSRF"
610
+ }
611
+ ]
612
+
613
+
614
+ def generate_html_dashboard(telemetry: dict[str, Any]) -> str:
615
+ """Build a complete, standalone, single-file interactive HTML security dashboard."""
616
+ score = compute_posture_score(telemetry)
617
+ findings = telemetry.get("findings", [])
618
+ patterns = telemetry.get("patterns", [])
619
+ cfg = telemetry.get("config", {})
620
+ detected = cfg.get("detected_stack", {})
621
+ profile = telemetry.get("profile", {})
622
+ all_rules = telemetry.get("all_rules", [])
623
+ safe_rules = telemetry.get("safe_rules", [])
624
+ violated_rules = telemetry.get("violated_rules", [])
625
+
626
+ # Polyglot stack information
627
+ primary_lang = detected.get("language") or "Universal"
628
+ primary_fw = detected.get("framework") or "None"
629
+ primary_db = detected.get("data_layer") or "None"
630
+
631
+ stack = profile.get("stack", [])
632
+ if not stack and primary_lang != "Unknown":
633
+ stack = [primary_lang]
634
+ if primary_fw != "None":
635
+ stack.append(primary_fw)
636
+ if primary_db != "None":
637
+ stack.append(primary_db)
638
+ stack_label = ", ".join(stack) if stack else "Polyglot Project"
639
+
640
+ # Severity counts
641
+ crit_count = sum(1 for f in findings if f.get("severity") == "Critical")
642
+ high_count = sum(1 for f in findings if f.get("severity") == "High")
643
+ med_count = sum(1 for f in findings if f.get("severity") in ("Medium", "Low"))
644
+ total_count = len(findings)
645
+ total_rules = len(all_rules) if all_rules else 74
646
+ defended_count = len(safe_rules) if safe_rules else (total_rules - len(violated_rules))
647
+ coverage_pct = int((defended_count / total_rules) * 100) if total_rules > 0 else 100
648
+
649
+ # Recipes
650
+ recipes = get_curated_golden_recipes(patterns)
651
+
652
+ # Recommendations
653
+ violated_ids = {f.get("rule_id") for f in findings if not f.get("is_canary", False)}
654
+ recommendations = get_recommended_defenses(detected, violated_ids)
655
+
656
+ # Open vs resolved counts
657
+ open_count = sum(1 for f in findings if f.get("status", "open") not in ("fixed", "resolved", "confirmed_fixed"))
658
+ resolved_count = total_count - open_count
659
+
660
+ # Gauge values & Dynamic Theme Accents
661
+ circumference = 251.2
662
+ dash_offset = circumference - (circumference * (score / 100.0))
663
+ if score >= 90:
664
+ grad_start = "#3fb950"
665
+ grad_end = "#2ea043"
666
+ glow_color = "#3fb950"
667
+ status_label = "🟢 HARDENED & SECURE"
668
+ status_badge_class = "status-clean"
669
+ elif score >= 75:
670
+ grad_start = "#58a6ff"
671
+ grad_end = "#1f6feb"
672
+ glow_color = "#58a6ff"
673
+ status_label = "🔵 ELEVATED RESILIENCE"
674
+ status_badge_class = "status-elevated"
675
+ elif score >= 50:
676
+ grad_start = "#d29922"
677
+ grad_end = "#bb8009"
678
+ glow_color = "#d29922"
679
+ status_label = "🟡 WARNINGS DETECTED"
680
+ status_badge_class = "status-warn"
681
+ else:
682
+ grad_start = "#f85149"
683
+ grad_end = "#da3633"
684
+ glow_color = "#f85149"
685
+ status_label = "🔴 CRITICAL RISK"
686
+ status_badge_class = "status-critical"
687
+
688
+ # ── Findings Table HTML (with Expandable Drawers & Simulator Checkboxes) ──
689
+ findings_rows = ""
690
+ sev_order = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3}
691
+ sorted_f = sorted(findings, key=lambda x: (sev_order.get(x.get("severity", "Low"), 9), -x.get("confidence_score", 0)))
692
+
693
+ if sorted_f:
694
+ for idx, f in enumerate(sorted_f[:50]):
695
+ fid = html.escape(f.get("finding_id", f"fid-{idx}"))
696
+ drawer_id = f"drawer-{idx}"
697
+ sev = f.get("severity", "Medium")
698
+ sev_class = "badge-critical" if sev == "Critical" else ("badge-high" if sev == "High" else "badge-medium")
699
+ rule = html.escape(f.get("rule_id", "TG-GEN"))
700
+ title = html.escape(f.get("title", "Finding"))
701
+ fpath = html.escape(f.get("file_path", "unknown"))
702
+ line = f.get("line_number", 0)
703
+ conf = f.get("confidence_score", 70)
704
+ desc = html.escape(f.get("description", "Security invariant check failed."))
705
+ snippet = html.escape(f.get("snippet", f.get("matched_text", "// No preview snippet available")))
706
+ is_canary = f.get("is_canary", False)
707
+ canary_badge = ' <span class="badge badge-canary">Canary Test</span>' if is_canary else ''
708
+ f_status = "resolved" if f.get("status") in ("fixed", "resolved", "confirmed_fixed") else "open"
709
+ status_badge = ' <span class="badge badge-success">&#10004; Resolved</span>' if f_status == "resolved" else ""
710
+
711
+ search_blob = html.escape(f"{rule} {title} {fpath} {sev} {f_status}".lower())
712
+
713
+ findings_rows += f"""
714
+ <tr class="finding-row" data-severity="{sev.lower()}" data-status="{f_status}" data-search="{search_blob}" data-path="{fpath}" data-drawer-id="{drawer_id}" onclick="toggleDrawer(this)">
715
+ <td style="text-align: center;" onclick="event.stopPropagation()">
716
+ <label class="sim-checkbox-label" title="Simulate fixing this finding in What-If Posture Simulator">
717
+ <input type="checkbox" class="sim-fix-chk" data-finding-id="{fid}" data-severity="{sev}" onchange="updateSimulatedScore()">
718
+ </label>
719
+ </td>
720
+ <td><span class="badge {sev_class}">{sev}</span>{canary_badge}{status_badge}</td>
721
+ <td><code class="rule-id">{rule}</code></td>
722
+ <td class="finding-title">{title}</td>
723
+ <td><code>{fpath}:{line}</code></td>
724
+ <td><span class="conf-score">{conf}%</span></td>
725
+ <td style="text-align: right;"><span class="drawer-chevron">&#9656;</span></td>
726
+ </tr>
727
+ <tr id="{drawer_id}" class="drawer-row" style="display: none;">
728
+ <td colspan="7">
729
+ <div class="drawer-content">
730
+ <div class="drawer-header">
731
+ <div>
732
+ <strong>Finding ID:</strong> <code>{fid}</code> &middot;
733
+ <strong>File:</strong> <code>{fpath}:{line}</code> &middot;
734
+ <strong>Confidence:</strong> {conf}/100
735
+ </div>
736
+ </div>
737
+ <div class="drawer-desc">{desc}</div>
738
+ <div class="code-preview">
739
+ <pre><code>{snippet}</code></pre>
740
+ </div>
741
+ <div class="drawer-actions">
742
+ <div class="cmd-box">
743
+ <code>npx torusguard harden --finding {fid}</code>
744
+ <button class="btn-copy" onclick="copyText('npx torusguard harden --finding {fid}', this); event.stopPropagation();">&#128203; Copy Command</button>
745
+ </div>
746
+ </div>
747
+ </div>
748
+ </td>
749
+ </tr>"""
750
+ if len(sorted_f) > 50:
751
+ findings_rows += f'<tr><td colspan="7" class="overflow-row">... and {len(sorted_f) - 50} more findings cataloged in run artifacts</td></tr>'
752
+ else:
753
+ findings_rows = '<tr><td colspan="7" class="empty-cell">&#10004; Zero security invariant violations detected. Posture is 100% clean and defended.</td></tr>'
754
+
755
+ # ── 5-Layer Architectural Invariant Matrix ──
756
+ # Group all rules by architectural layer
757
+ layers_html = ""
758
+ for layer in ARCHITECTURAL_LAYERS:
759
+ lid = layer["id"]
760
+ lname = layer["name"]
761
+ licon = layer["icon"]
762
+ ldesc = layer["description"]
763
+ lfams = layer["families"]
764
+
765
+ layer_rules = [r for r in all_rules if any(r.get("rule_id", "").startswith(fam) for fam in lfams)]
766
+ l_total = len(layer_rules)
767
+ l_defended = sum(1 for r in layer_rules if r["rule_id"] not in violated_ids)
768
+ l_status_class = "status-clean" if l_defended == l_total else ("status-warn" if l_defended >= l_total * 0.75 else "status-critical")
769
+
770
+ chips_html = ""
771
+ for r in layer_rules:
772
+ rid = r["rule_id"]
773
+ rtitle = r["title"]
774
+ rsev = r.get("severity", "Medium")
775
+ is_defended = rid not in violated_ids
776
+ chip_cls = "inv-chip-safe" if is_defended else "inv-chip-violated"
777
+ dot_color = "var(--accent-green)" if is_defended else "var(--accent-red)"
778
+ status_text = "Defended" if is_defended else "Violated"
779
+ search_data = f"{rid} {rtitle} {rsev} {status_text} {lname}".lower()
780
+
781
+ chips_html += f"""
782
+ <div class="inv-chip {chip_cls}" data-rule="{rid}" data-layer="{lid}" data-search="{html.escape(search_data)}" onclick="inspectInvariant('{rid}')" title="Click to inspect security invariant guarantee for {rid}">
783
+ <span class="inv-dot" style="background: {dot_color};"></span>
784
+ <code class="inv-code">{rid}</code>
785
+ <span class="inv-title">{html.escape(rtitle)}</span>
786
+ </div>"""
787
+
788
+ layers_html += f"""
789
+ <div class="layer-card" data-layer-id="{lid}">
790
+ <div class="layer-header">
791
+ <div class="layer-title-group">
792
+ <span class="layer-icon">{licon}</span>
793
+ <div>
794
+ <div class="layer-name">{lname}</div>
795
+ <div class="layer-desc">{ldesc}</div>
796
+ </div>
797
+ </div>
798
+ <div class="layer-badge {l_status_class}">{l_defended}/{l_total} Enforced</div>
799
+ </div>
800
+ <div class="layer-chips-grid">
801
+ {chips_html}
802
+ </div>
803
+ </div>"""
804
+
805
+ # ── OWASP Top 10 Compliance Radar ──
806
+ owasp_defs = [
807
+ ("A01: Broken Access Control", ["TG-DB-001", "TG-DB-004", "TG-AUTH-006", "TG-WS-002", "TG-BIZ-001", "TG-BIZ-002"]),
808
+ ("A02: Cryptographic Failures", ["TG-SEC-001", "TG-SEC-002", "TG-SEC-003", "TG-SEC-004", "TG-SEC-005", "TG-AUTH-002"]),
809
+ ("A03: Injection Defense", ["TG-INPUT-001", "TG-INPUT-002", "TG-INPUT-003", "TG-INPUT-004", "TG-DB-002", "TG-DB-003", "TG-GQL-001"]),
810
+ ("A05: Security Configuration", ["TG-PLATFORM-001", "TG-PLATFORM-002", "TG-PLATFORM-003", "TG-PLATFORM-004", "TG-CLIENT-001", "TG-CLIENT-002"]),
811
+ ("A06: Supply Chain Health", ["TG-SUPPLY-001", "TG-SUPPLY-002", "TG-SUPPLY-003", "TG-SUPPLY-004", "TG-SUPPLY-005"]),
812
+ ("A07: Identity & Auth", ["TG-AUTH-001", "TG-AUTH-003", "TG-AUTH-004", "TG-AUTH-005", "TG-AUTH-007", "TG-CSRF-001", "TG-CSRF-002"]),
813
+ ("A08: Software Integrity", ["TG-WEBHOOK-001", "TG-WEBHOOK-002", "TG-WEBHOOK-003", "TG-DIFF-001", "TG-DIFF-002"]),
814
+ ("A10: SSRF Defense", ["TG-SSRF-001", "TG-SSRF-002", "TG-SSRF-003", "TG-SSRF-004"])
815
+ ]
816
+
817
+ owasp_cards_html = ""
818
+ for category_name, rule_codes in owasp_defs:
819
+ matched = [f for f in findings if f.get("rule_id") in rule_codes and not f.get("is_canary", False)]
820
+ cat_count = len(matched)
821
+ if cat_count == 0:
822
+ c_score = 100
823
+ c_color = "var(--accent-green)"
824
+ c_badge = "100% Compliant"
825
+ elif cat_count == 1:
826
+ c_score = 80
827
+ c_color = "var(--accent-yellow)"
828
+ c_badge = "1 Issue"
829
+ else:
830
+ c_score = max(20, 100 - (cat_count * 20))
831
+ c_color = "var(--accent-red)"
832
+ c_badge = f"{cat_count} Issues"
833
+
834
+ owasp_cards_html += f"""
835
+ <div class="owasp-card">
836
+ <div class="owasp-top">
837
+ <span class="owasp-title">{category_name}</span>
838
+ <span class="owasp-badge" style="color: {c_color}; border-color: {c_color};">{c_badge}</span>
839
+ </div>
840
+ <div class="owasp-bar-bg">
841
+ <div class="owasp-bar" style="width: {c_score}%; background: {c_color};"></div>
842
+ </div>
843
+ </div>"""
844
+
845
+ # ── Enterprise Compliance Framework Matrix ──
846
+ compliance_frameworks: list[ComplianceFramework] = [
847
+ {
848
+ "name": "SOC 2 Type II",
849
+ "subtitle": "AICPA Trust Services Criteria (Security & Boundary)",
850
+ "controls": [
851
+ {"code": "CC6.1 Logical Access", "families": "TG-AUTH, TG-CLIENT", "rules": ["TG-AUTH-001", "TG-AUTH-002", "TG-AUTH-003", "TG-AUTH-004", "TG-CLIENT-001"]},
852
+ {"code": "CC6.6 Network Boundary", "families": "TG-SSRF, TG-RATE, TG-WS", "rules": ["TG-SSRF-001", "TG-SSRF-002", "TG-RATE-001", "TG-WS-001"]},
853
+ {"code": "CC6.7 Transmission Security", "families": "TG-PLATFORM, TG-SEC", "rules": ["TG-PLATFORM-001", "TG-PLATFORM-002", "TG-SEC-001", "TG-SEC-002"]},
854
+ {"code": "CC7.1 Vulnerability & Churn", "families": "TG-DIFF, TG-SUPPLY", "rules": ["TG-DIFF-001", "TG-DIFF-002", "TG-SUPPLY-001", "TG-SUPPLY-002"]}
855
+ ]
856
+ },
857
+ {
858
+ "name": "ISO/IEC 27001:2022",
859
+ "subtitle": "Annex A Controls for Technological & Application Security",
860
+ "controls": [
861
+ {"code": "A.8.20 Network Security", "families": "TG-SSRF, TG-WEBHOOK, TG-RATE", "rules": ["TG-SSRF-001", "TG-WEBHOOK-001", "TG-RATE-001"]},
862
+ {"code": "A.8.24 Cryptography Usage", "families": "TG-SEC, TG-AUTH", "rules": ["TG-SEC-001", "TG-SEC-003", "TG-AUTH-002"]},
863
+ {"code": "A.8.28 Secure Coding", "families": "TG-INPUT, TG-DB, TG-GQL, TG-AGENT", "rules": ["TG-INPUT-001", "TG-DB-001", "TG-GQL-001", "TG-AGENT-001"]}
864
+ ]
865
+ },
866
+ {
867
+ "name": "HIPAA Security Rule",
868
+ "subtitle": "45 CFR Part 164 Technical Safeguards for ePHI",
869
+ "controls": [
870
+ {"code": "§ 164.312(a)(1) Access Control", "families": "TG-AUTH, TG-DB (Tenant Isolation)", "rules": ["TG-AUTH-001", "TG-DB-004", "TG-AUTH-006"]},
871
+ {"code": "§ 164.312(c)(1) ePHI Integrity", "families": "TG-WEBHOOK, TG-BIZ, TG-DIFF", "rules": ["TG-WEBHOOK-001", "TG-BIZ-001", "TG-DIFF-001"]},
872
+ {"code": "§ 164.312(e)(1) Transmission Security", "families": "TG-PLATFORM, TG-SEC, TG-CLIENT", "rules": ["TG-PLATFORM-001", "TG-SEC-001", "TG-CLIENT-001"]}
873
+ ]
874
+ }
875
+ ]
876
+
877
+ compliance_cards_html = ""
878
+ for fw in compliance_frameworks:
879
+ fw_name = fw["name"]
880
+ fw_sub = fw["subtitle"]
881
+ controls = fw["controls"]
882
+ all_fw_rules = [rid for c in controls for rid in c["rules"]]
883
+ fw_violated = [rid for rid in all_fw_rules if rid in violated_ids]
884
+ fw_pct = int(((len(all_fw_rules) - len(fw_violated)) / len(all_fw_rules)) * 100) if all_fw_rules else 100
885
+ badge_cls = "badge-success" if fw_pct == 100 else ("badge-medium" if fw_pct >= 80 else "badge-critical")
886
+
887
+ ctrl_rows_html = ""
888
+ for ctrl in controls:
889
+ c_code = ctrl["code"]
890
+ c_fams = ctrl["families"]
891
+ c_rules = ctrl["rules"]
892
+ c_viol = [r for r in c_rules if r in violated_ids]
893
+ c_status = '<span class="badge badge-success">&#10004; Passing</span>' if not c_viol else f'<span class="badge badge-critical">{len(c_viol)} Violated</span>'
894
+
895
+ ctrl_rows_html += f"""
896
+ <div class="comp-ctrl-row">
897
+ <div>
898
+ <div class="comp-ctrl-code">{c_code}</div>
899
+ <div class="comp-ctrl-fams">{c_fams}</div>
900
+ </div>
901
+ <div>{c_status}</div>
902
+ </div>"""
903
+
904
+ compliance_cards_html += f"""
905
+ <div class="card comp-card">
906
+ <div class="comp-card-header">
907
+ <div>
908
+ <div class="comp-title">{fw_name}</div>
909
+ <div class="comp-sub">{fw_sub}</div>
910
+ </div>
911
+ <span class="badge {badge_cls}">{fw_pct}% Met</span>
912
+ </div>
913
+ <div class="comp-ctrl-list">
914
+ {ctrl_rows_html}
915
+ </div>
916
+ </div>"""
917
+
918
+ # ── Root-Cause Architectural Clusters ──
919
+ cluster_groups: dict[str, list[dict[str, Any]]] = {}
920
+ for f in findings:
921
+ c_key = f.get("cluster") or f.get("category") or "General Invariants"
922
+ if c_key not in cluster_groups:
923
+ cluster_groups[c_key] = []
924
+ cluster_groups[c_key].append(f)
925
+
926
+ sorted_clusters = sorted(cluster_groups.items(), key=lambda x: len(x[1]), reverse=True)
927
+ clusters_html = ""
928
+ if sorted_clusters:
929
+ for c_name, c_list in sorted_clusters[:5]:
930
+ c_count = len(c_list)
931
+ unique_files = len(set(item.get("file_path", "") for item in c_list))
932
+ clusters_html += f"""
933
+ <div class="cluster-item">
934
+ <div class="cluster-header">
935
+ <strong>{html.escape(c_name.replace('-', ' ').title())}</strong>
936
+ <span class="badge badge-high">{c_count} findings</span>
937
+ </div>
938
+ <div class="cluster-sub">Blast Radius: {unique_files} file(s) affected</div>
939
+ </div>"""
940
+ else:
941
+ clusters_html = '<div class="empty-state">&#10004; No architectural vulnerability clusters identified. Systemic invariants enforced.</div>'
942
+
943
+ # ── Interactive Attack Surface Heatmap by Directory ──
944
+ dir_counts: dict[str, int] = {}
945
+ for f in findings:
946
+ fp = f.get("file_path", "")
947
+ parts = fp.replace("\\", "/").split("/")
948
+ d_name = f"{parts[0]}/" if len(parts) > 1 else "root"
949
+ dir_counts[d_name] = dir_counts.get(d_name, 0) + 1
950
+
951
+ sorted_dirs = sorted(dir_counts.items(), key=lambda x: x[1], reverse=True)
952
+ max_dir_count = sorted_dirs[0][1] if sorted_dirs else 1
953
+ heatmap_rows_html = ""
954
+ if sorted_dirs:
955
+ for d_name, count in sorted_dirs[:6]:
956
+ pct = int((count / max_dir_count) * 100)
957
+ bar_color = "#f85149" if count > 5 else ("#d29922" if count > 2 else "#58a6ff")
958
+ heatmap_rows_html += f"""
959
+ <div class="heatmap-row" onclick="filterByDirectory('{html.escape(d_name)}')" title="Click to filter findings in {html.escape(d_name)}">
960
+ <span class="heatmap-label">{html.escape(d_name)}</span>
961
+ <div class="heatmap-bar-bg">
962
+ <div class="heatmap-bar" style="width: {pct}%; background: {bar_color};"></div>
963
+ </div>
964
+ <span class="heatmap-count">{count}</span>
965
+ </div>"""
966
+ else:
967
+ heatmap_rows_html = '<div class="empty-state">&#10004; Zero directory surface exposure detected across repository modules.</div>'
968
+
969
+ # ── Golden Fix Recipes (Dual-View Diff / Split, Churn Metrics, Category Filter) ──
970
+ # Category counts for filter pills
971
+ recipe_cats: dict[str, int] = {}
972
+ for r in recipes:
973
+ c = r.get("category", "General")
974
+ recipe_cats[c] = recipe_cats.get(c, 0) + 1
975
+
976
+ recipe_cat_pills_html = f'<span class="pill active" data-recipe-cat-filter="all" onclick="filterRecipeCategory(\'all\')">All ({len(recipes)})</span>'
977
+ for c_name, c_cnt in sorted(recipe_cats.items()):
978
+ c_slug = c_name.lower().replace(" ", "-")
979
+ recipe_cat_pills_html += f'<span class="pill" data-recipe-cat-filter="{c_slug}" onclick="filterRecipeCategory(\'{c_slug}\')">{c_name} ({c_cnt})</span>'
980
+
981
+ recipes_html = ""
982
+ for r in recipes:
983
+ rid = html.escape(r.get("rule_id", "TG-FIX"))
984
+ rtitle = html.escape(r.get("title", "Verified fix pattern"))
985
+ rcat = html.escape(r.get("category", "General").lower().replace(" ", "-"))
986
+ rec_key = html.escape(r.get("recipe_id", f"recipe-{rid}").replace(" ", "-"))
987
+ metrics = r.get("ponytail_metrics") or {"additions": 1, "deletions": 1}
988
+ adds = metrics.get("additions", 1)
989
+ dels = metrics.get("deletions", 1)
990
+ ftype = html.escape(r.get("file_type", ".ts"))
991
+ vcount = r.get("verified_count", 1)
992
+
993
+ diff_str = r.get("diff_snippet") or ""
994
+ before_str = r.get("before_snippet") or "// Vulnerable snippet"
995
+ after_str = r.get("after_snippet") or r.get("solution_pattern") or "// Hardened pattern"
996
+
997
+ formatted_diff = format_diff_html(diff_str)
998
+ escaped_before = html.escape(before_str)
999
+ escaped_after = html.escape(after_str)
1000
+ copy_payload = html.escape((after_str or diff_str).replace("\n", " ").replace('"', '&quot;'))
1001
+
1002
+ recipes_html += f"""
1003
+ <div class="recipe-card" data-category="{rcat}">
1004
+ <div class="recipe-header">
1005
+ <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
1006
+ <span class="badge badge-success">{rid}</span>
1007
+ <strong>{rtitle}</strong>
1008
+ </div>
1009
+ <div class="recipe-meta-badges">
1010
+ <span class="chip chip-filetype">{ftype}</span>
1011
+ <span class="ponytail-badge" title="Ponytail line churn budget (&le; 35 additions, &le; 25 deletions)">⚡ +{adds} / -{dels} lines</span>
1012
+ <span class="verified-badge">&#10004; Verified {vcount}x</span>
1013
+ </div>
1014
+ </div>
1015
+ <div class="recipe-view-switcher">
1016
+ <button class="recipe-tab-btn active" id="btn-view-diff-{rec_key}" onclick="setRecipeView('{rec_key}', 'diff')">Unified Diff</button>
1017
+ <button class="recipe-tab-btn" id="btn-view-split-{rec_key}" onclick="setRecipeView('{rec_key}', 'split')">Before / After</button>
1018
+ </div>
1019
+ <div id="recipe-view-diff-{rec_key}" class="code-preview recipe-diff-container">
1020
+ <pre><code>{formatted_diff}</code></pre>
1021
+ </div>
1022
+ <div id="recipe-view-split-{rec_key}" class="recipe-split-container" style="display: none;">
1023
+ <div class="split-pane">
1024
+ <div class="split-label split-del">&#10008; Before (Vulnerable)</div>
1025
+ <div class="code-preview"><pre><code>{escaped_before}</code></pre></div>
1026
+ </div>
1027
+ <div class="split-pane">
1028
+ <div class="split-label split-add">&#10004; After (Hardened)</div>
1029
+ <div class="code-preview"><pre><code>{escaped_after}</code></pre></div>
1030
+ </div>
1031
+ </div>
1032
+ <div class="recipe-actions">
1033
+ <div class="cmd-box">
1034
+ <code>npx torusguard harden --rule {rid}</code>
1035
+ <button class="btn-copy" onclick="copyText('npx torusguard harden --rule {rid}', this); event.stopPropagation();">&#128203; Copy Command</button>
1036
+ </div>
1037
+ <button class="btn btn-sm" onclick="copyText('{copy_payload}', this); event.stopPropagation();">&#128203; Copy Pattern</button>
1038
+ </div>
1039
+ </div>"""
1040
+
1041
+ # ── Prescriptive "Next Best Defenses" Advisory ──
1042
+ advisory_html = ""
1043
+ for adv in recommendations:
1044
+ arid = html.escape(adv["rule_id"])
1045
+ aprio = html.escape(adv.get("priority", "P1 Recommended"))
1046
+ atitle = html.escape(adv["title"])
1047
+ amitig = html.escape(adv.get("mitigates", "OWASP Invariant"))
1048
+ astack = html.escape(adv.get("stack_tag", "Polyglot"))
1049
+ arationale = html.escape(adv["rationale"])
1050
+ aguarantee = html.escape(adv.get("invariant_guarantee", "Guarantees zero architectural bypasses."))
1051
+ acmd = html.escape(adv["command"])
1052
+
1053
+ prio_cls = "badge-critical" if "P0" in aprio else ("badge-high" if "P1" in aprio else "badge-canary")
1054
+
1055
+ advisory_html += f"""
1056
+ <div class="advisory-item">
1057
+ <div class="advisory-top">
1058
+ <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
1059
+ <span class="badge {prio_cls}">{aprio}</span>
1060
+ <span class="badge badge-primary">{arid}</span>
1061
+ <strong>{atitle}</strong>
1062
+ </div>
1063
+ <span class="chip chip-filetype">{astack}</span>
1064
+ </div>
1065
+ <div class="advisory-mitigates"><strong>Mitigates:</strong> {amitig}</div>
1066
+ <div class="advisory-desc">{arationale}</div>
1067
+ <div class="advisory-guarantee"><strong>Invariant Guarantee:</strong> {aguarantee}</div>
1068
+ <div class="cmd-box" style="margin-top: 8px;">
1069
+ <code>{acmd}</code>
1070
+ <button class="btn-copy" onclick="copyText('{acmd}', this); event.stopPropagation();">&#128203; Copy</button>
1071
+ </div>
1072
+ </div>"""
1073
+
1074
+ # ── Client-side JSON Payloads ──
1075
+ findings_json_str = json.dumps(findings).replace("</script>", "<\\/script>")
1076
+ sarif_rules_json_str = json.dumps([{"id": r["rule_id"], "shortDescription": {"text": r["title"]}} for r in all_rules[:74]])
1077
+ rule_catalog_json_str = json.dumps({r["rule_id"]: r for r in all_rules}).replace("</script>", "<\\/script>")
1078
+ violated_ids_json_str = json.dumps(list(violated_ids))
1079
+
1080
+ return f"""<!DOCTYPE html>
1081
+ <html lang="en">
1082
+ <head>
1083
+ <meta charset="UTF-8">
1084
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1085
+ <title>TorusGuard Security Report &middot; {html.escape(telemetry.get('project_name', 'Project'))}</title>
1086
+ <style>
1087
+ :root {{
1088
+ --bg-dark: #0d1117;
1089
+ --bg-surface: #161b22;
1090
+ --bg-card: #21262d;
1091
+ --border-subtle: #30363d;
1092
+ --text-main: #f0f6fc;
1093
+ --text-muted: #8b949e;
1094
+ --accent-blue: #58a6ff;
1095
+ --accent-green: #3fb950;
1096
+ --accent-yellow: #d29922;
1097
+ --accent-red: #f85149;
1098
+ --accent-orange: #db6d28;
1099
+ --accent-purple: #bc8cff;
1100
+ --font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
1101
+ }}
1102
+ body.light-theme {{
1103
+ --bg-dark: #f6f8fa;
1104
+ --bg-surface: #ffffff;
1105
+ --bg-card: #f0f3f6;
1106
+ --border-subtle: #d0d7de;
1107
+ --text-main: #1f2328;
1108
+ --text-muted: #656d76;
1109
+ }}
1110
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
1111
+ body {{
1112
+ background: var(--bg-dark);
1113
+ color: var(--text-main);
1114
+ font-family: var(--font-stack);
1115
+ line-height: 1.5;
1116
+ padding: 24px;
1117
+ transition: background 0.2s ease, color 0.2s ease;
1118
+ }}
1119
+ .container {{ max-width: 1240px; margin: 0 auto; }}
1120
+
1121
+ /* Header */
1122
+ header {{
1123
+ display: flex;
1124
+ justify-content: space-between;
1125
+ align-items: center;
1126
+ padding-bottom: 20px;
1127
+ border-bottom: 1px solid var(--border-subtle);
1128
+ margin-bottom: 24px;
1129
+ flex-wrap: wrap;
1130
+ gap: 12px;
1131
+ }}
1132
+ .logo-group h1 {{
1133
+ font-size: 22px;
1134
+ font-weight: 700;
1135
+ display: flex;
1136
+ align-items: center;
1137
+ gap: 8px;
1138
+ }}
1139
+ .shield-icon {{ font-size: 24px; }}
1140
+ .project-meta {{ color: var(--text-muted); font-size: 13px; margin-top: 4px; }}
1141
+ .header-actions {{ display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }}
1142
+ .status-badge {{
1143
+ padding: 6px 14px;
1144
+ border-radius: 20px;
1145
+ font-size: 12px;
1146
+ font-weight: 600;
1147
+ }}
1148
+ .status-clean {{ background: rgba(63, 185, 80, 0.15); border: 1px solid var(--accent-green); color: var(--accent-green); }}
1149
+ .status-elevated {{ background: rgba(88, 166, 255, 0.15); border: 1px solid var(--accent-blue); color: var(--accent-blue); }}
1150
+ .status-warn {{ background: rgba(210, 153, 34, 0.15); border: 1px solid var(--accent-yellow); color: var(--accent-yellow); }}
1151
+ .status-critical {{ background: rgba(248, 81, 73, 0.15); border: 1px solid var(--accent-red); color: var(--accent-red); }}
1152
+
1153
+ .btn {{
1154
+ background: var(--bg-card);
1155
+ border: 1px solid var(--border-subtle);
1156
+ color: var(--text-main);
1157
+ padding: 6px 12px;
1158
+ border-radius: 6px;
1159
+ font-size: 12px;
1160
+ font-weight: 500;
1161
+ cursor: pointer;
1162
+ display: inline-flex;
1163
+ align-items: center;
1164
+ gap: 6px;
1165
+ transition: background 0.2s, border-color 0.2s;
1166
+ }}
1167
+ .btn:hover {{ background: #30363d; border-color: var(--text-muted); }}
1168
+ body.light-theme .btn:hover {{ background: #e1e4e8; }}
1169
+ .btn-sm {{ padding: 3px 8px; font-size: 11px; }}
1170
+
1171
+ /* KPI Grid */
1172
+ .grid-kpi {{
1173
+ display: grid;
1174
+ grid-template-columns: 220px repeat(4, 1fr);
1175
+ gap: 14px;
1176
+ margin-bottom: 24px;
1177
+ }}
1178
+ @media (max-width: 950px) {{ .grid-kpi {{ grid-template-columns: 1fr 1fr; }} }}
1179
+ @media (max-width: 600px) {{ .grid-kpi {{ grid-template-columns: 1fr; }} }}
1180
+
1181
+ .card {{
1182
+ background: var(--bg-surface);
1183
+ border: 1px solid var(--border-subtle);
1184
+ border-radius: 8px;
1185
+ padding: 18px;
1186
+ }}
1187
+ .gauge-card {{
1188
+ display: flex;
1189
+ flex-direction: column;
1190
+ align-items: center;
1191
+ justify-content: center;
1192
+ text-align: center;
1193
+ }}
1194
+ .gauge-svg {{ width: 110px; height: 110px; transform: rotate(-90deg); filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.3)); }}
1195
+ .gauge-bg {{ fill: none; stroke: var(--bg-card); stroke-width: 8; }}
1196
+ .gauge-fill {{
1197
+ fill: none;
1198
+ stroke-width: 8;
1199
+ stroke-linecap: round;
1200
+ stroke-dasharray: 251.2;
1201
+ stroke-dashoffset: 251.2;
1202
+ transition: stroke-dashoffset 0.9s cubic-bezier(0.4, 0, 0.2, 1);
1203
+ }}
1204
+ .gauge-value {{
1205
+ position: absolute;
1206
+ font-size: 30px;
1207
+ font-weight: 700;
1208
+ color: var(--text-main);
1209
+ letter-spacing: -0.5px;
1210
+ }}
1211
+ .sev-card {{ text-align: center; cursor: pointer; transition: transform 0.15s, border-color 0.2s; }}
1212
+ .sev-card:hover {{ transform: translateY(-2px); border-color: var(--accent-blue); }}
1213
+ .sev-card .sev-count {{ font-size: 34px; font-weight: 800; }}
1214
+ .sev-card .sev-label {{ font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-top: 4px; }}
1215
+
1216
+ /* What-If Posture Simulator Card */
1217
+ .simulator-card {{
1218
+ margin-bottom: 24px;
1219
+ background: var(--bg-surface);
1220
+ border: 1px solid var(--border-subtle);
1221
+ border-left: 4px solid var(--accent-blue);
1222
+ display: flex;
1223
+ justify-content: space-between;
1224
+ align-items: center;
1225
+ flex-wrap: wrap;
1226
+ gap: 12px;
1227
+ padding: 14px 18px;
1228
+ }}
1229
+ .simulator-clean {{ border-left-color: var(--accent-green); }}
1230
+ .sim-stats {{ display: flex; align-items: center; gap: 10px; font-size: 13px; }}
1231
+ .sim-actions {{ display: flex; gap: 6px; flex-wrap: wrap; }}
1232
+ .sim-checkbox-label {{
1233
+ display: inline-flex;
1234
+ align-items: center;
1235
+ gap: 4px;
1236
+ cursor: pointer;
1237
+ font-size: 11px;
1238
+ color: var(--text-muted);
1239
+ }}
1240
+ .sim-fix-chk {{ cursor: pointer; accent-color: var(--accent-blue); }}
1241
+
1242
+ /* Invariant Matrix */
1243
+ .defended-section {{
1244
+ background: var(--bg-surface);
1245
+ border: 1px solid var(--border-subtle);
1246
+ border-radius: 8px;
1247
+ padding: 18px;
1248
+ margin-bottom: 24px;
1249
+ }}
1250
+ .defended-header {{
1251
+ display: flex;
1252
+ justify-content: space-between;
1253
+ align-items: center;
1254
+ margin-bottom: 14px;
1255
+ cursor: pointer;
1256
+ }}
1257
+ .coverage-bar-container {{
1258
+ background: var(--bg-card);
1259
+ border: 1px solid var(--border-subtle);
1260
+ border-radius: 6px;
1261
+ padding: 10px 14px;
1262
+ margin-bottom: 16px;
1263
+ }}
1264
+ .coverage-stats {{
1265
+ display: flex;
1266
+ justify-content: space-between;
1267
+ align-items: center;
1268
+ font-size: 12px;
1269
+ color: var(--text-muted);
1270
+ margin-bottom: 6px;
1271
+ }}
1272
+ .coverage-progress-bg {{
1273
+ height: 8px;
1274
+ background: rgba(255, 255, 255, 0.05);
1275
+ border-radius: 4px;
1276
+ overflow: hidden;
1277
+ }}
1278
+ .coverage-progress-fill {{
1279
+ height: 100%;
1280
+ background: var(--accent-green);
1281
+ border-radius: 4px;
1282
+ transition: width 0.6s ease;
1283
+ }}
1284
+ .inv-search-bar {{
1285
+ margin-bottom: 16px;
1286
+ display: flex;
1287
+ gap: 10px;
1288
+ align-items: center;
1289
+ }}
1290
+ .layers-container {{
1291
+ display: flex;
1292
+ flex-direction: column;
1293
+ gap: 12px;
1294
+ }}
1295
+ .layer-card {{
1296
+ background: var(--bg-card);
1297
+ border: 1px solid var(--border-subtle);
1298
+ border-radius: 6px;
1299
+ padding: 12px 14px;
1300
+ }}
1301
+ .layer-header {{
1302
+ display: flex;
1303
+ justify-content: space-between;
1304
+ align-items: center;
1305
+ margin-bottom: 10px;
1306
+ }}
1307
+ .layer-title-group {{ display: flex; align-items: center; gap: 8px; }}
1308
+ .layer-icon {{ font-size: 16px; }}
1309
+ .layer-name {{ font-size: 13px; font-weight: 700; color: var(--text-main); }}
1310
+ .layer-desc {{ font-size: 11px; color: var(--text-muted); }}
1311
+ .layer-badge {{
1312
+ font-size: 11px;
1313
+ font-weight: 600;
1314
+ padding: 2px 8px;
1315
+ border-radius: 10px;
1316
+ }}
1317
+ .layer-chips-grid {{
1318
+ display: flex;
1319
+ flex-wrap: wrap;
1320
+ gap: 6px;
1321
+ }}
1322
+ .inv-chip {{
1323
+ background: var(--bg-surface);
1324
+ border: 1px solid var(--border-subtle);
1325
+ border-radius: 4px;
1326
+ padding: 4px 8px;
1327
+ font-size: 11px;
1328
+ display: inline-flex;
1329
+ align-items: center;
1330
+ gap: 6px;
1331
+ cursor: pointer;
1332
+ transition: all 0.15s ease;
1333
+ }}
1334
+ .inv-chip:hover {{
1335
+ border-color: var(--accent-blue);
1336
+ transform: translateY(-1px);
1337
+ }}
1338
+ .inv-chip-safe {{ border-left: 2px solid var(--accent-green); }}
1339
+ .inv-chip-violated {{ border-left: 2px solid var(--accent-red); background: rgba(248, 81, 73, 0.08); }}
1340
+ .inv-dot {{ width: 6px; height: 6px; border-radius: 50%; }}
1341
+ .inv-code {{ font-family: monospace; font-weight: 600; color: var(--accent-blue); font-size: 11px; }}
1342
+ .inv-title {{ color: var(--text-main); max-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
1343
+
1344
+ /* Modal / Drawer for Invariant Inspection */
1345
+ .modal-backdrop {{
1346
+ position: fixed;
1347
+ top: 0;
1348
+ left: 0;
1349
+ width: 100vw;
1350
+ height: 100vh;
1351
+ background: rgba(0, 0, 0, 0.7);
1352
+ backdrop-filter: blur(2px);
1353
+ z-index: 9999;
1354
+ display: flex;
1355
+ align-items: center;
1356
+ justify-content: center;
1357
+ padding: 20px;
1358
+ }}
1359
+ .modal-card {{
1360
+ background: var(--bg-surface);
1361
+ border: 1px solid var(--border-subtle);
1362
+ border-radius: 8px;
1363
+ max-width: 600px;
1364
+ width: 100%;
1365
+ padding: 22px;
1366
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
1367
+ }}
1368
+ .modal-header {{
1369
+ display: flex;
1370
+ justify-content: space-between;
1371
+ align-items: flex-start;
1372
+ margin-bottom: 14px;
1373
+ }}
1374
+ .modal-close-btn {{
1375
+ background: transparent;
1376
+ border: none;
1377
+ color: var(--text-muted);
1378
+ font-size: 18px;
1379
+ cursor: pointer;
1380
+ }}
1381
+ .modal-close-btn:hover {{ color: var(--text-main); }}
1382
+ .modal-body {{ font-size: 13px; color: var(--text-main); display: flex; flex-direction: column; gap: 12px; }}
1383
+
1384
+ /* Filter & Search Bar */
1385
+ .filter-bar {{
1386
+ display: flex;
1387
+ justify-content: space-between;
1388
+ align-items: center;
1389
+ margin-bottom: 8px;
1390
+ flex-wrap: wrap;
1391
+ gap: 10px;
1392
+ }}
1393
+ .filter-pills {{ display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }}
1394
+ .pill-group-label {{
1395
+ font-size: 11px;
1396
+ font-weight: 600;
1397
+ text-transform: uppercase;
1398
+ letter-spacing: 0.5px;
1399
+ color: var(--text-muted);
1400
+ margin-right: 2px;
1401
+ }}
1402
+ .pill {{
1403
+ background: var(--bg-card);
1404
+ border: 1px solid var(--border-subtle);
1405
+ color: var(--text-muted);
1406
+ padding: 4px 10px;
1407
+ border-radius: 14px;
1408
+ font-size: 12px;
1409
+ font-weight: 500;
1410
+ cursor: pointer;
1411
+ transition: all 0.15s ease;
1412
+ user-select: none;
1413
+ }}
1414
+ .pill:hover {{ color: var(--text-main); border-color: var(--text-muted); }}
1415
+ .pill.active {{
1416
+ background: var(--accent-blue);
1417
+ border-color: var(--accent-blue);
1418
+ color: #fff;
1419
+ }}
1420
+ .dir-filter-chip {{
1421
+ display: none;
1422
+ background: rgba(88, 166, 255, 0.15);
1423
+ border: 1px solid var(--accent-blue);
1424
+ color: var(--accent-blue);
1425
+ font-size: 11px;
1426
+ padding: 3px 8px;
1427
+ border-radius: 12px;
1428
+ cursor: pointer;
1429
+ }}
1430
+ .btn-reset-filters {{
1431
+ display: none;
1432
+ background: rgba(248, 81, 73, 0.12);
1433
+ border: 1px solid rgba(248, 81, 73, 0.35);
1434
+ color: var(--accent-red);
1435
+ font-size: 11px;
1436
+ font-weight: 600;
1437
+ padding: 3px 8px;
1438
+ border-radius: 12px;
1439
+ cursor: pointer;
1440
+ margin-left: 4px;
1441
+ transition: all 0.15s;
1442
+ }}
1443
+ .btn-reset-filters:hover {{ background: rgba(248, 81, 73, 0.25); }}
1444
+ .search-wrapper {{
1445
+ position: relative;
1446
+ display: inline-flex;
1447
+ align-items: center;
1448
+ }}
1449
+ .search-input {{
1450
+ background: var(--bg-card);
1451
+ border: 1px solid var(--border-subtle);
1452
+ color: var(--text-main);
1453
+ padding: 7px 28px 7px 12px;
1454
+ border-radius: 6px;
1455
+ font-size: 13px;
1456
+ width: 280px;
1457
+ outline: none;
1458
+ transition: border-color 0.15s;
1459
+ }}
1460
+ .search-input:focus {{ border-color: var(--accent-blue); }}
1461
+ .search-clear-btn {{
1462
+ position: absolute;
1463
+ right: 8px;
1464
+ background: none;
1465
+ border: none;
1466
+ color: var(--text-muted);
1467
+ cursor: pointer;
1468
+ font-size: 14px;
1469
+ line-height: 1;
1470
+ padding: 2px 4px;
1471
+ display: none;
1472
+ }}
1473
+ .search-clear-btn:hover {{ color: var(--text-main); }}
1474
+ .filter-stats-bar {{
1475
+ font-size: 12px;
1476
+ color: var(--text-muted);
1477
+ margin-bottom: 14px;
1478
+ padding-left: 2px;
1479
+ display: flex;
1480
+ align-items: center;
1481
+ justify-content: space-between;
1482
+ }}
1483
+
1484
+ /* Table & Drawers */
1485
+ .table-container {{
1486
+ background: var(--bg-surface);
1487
+ border: 1px solid var(--border-subtle);
1488
+ border-radius: 8px;
1489
+ overflow: hidden;
1490
+ margin-bottom: 24px;
1491
+ }}
1492
+ table {{ width: 100%; border-collapse: collapse; text-align: left; font-size: 13px; }}
1493
+ th {{
1494
+ background: var(--bg-card);
1495
+ padding: 10px 14px;
1496
+ border-bottom: 1px solid var(--border-subtle);
1497
+ font-weight: 600;
1498
+ color: var(--text-muted);
1499
+ }}
1500
+ td {{ padding: 12px 14px; border-bottom: 1px solid var(--border-subtle); vertical-align: middle; }}
1501
+ tr.finding-row {{ cursor: pointer; transition: background 0.1s; }}
1502
+ tr.finding-row:hover {{ background: rgba(255, 255, 255, 0.02); }}
1503
+ body.light-theme tr.finding-row:hover {{ background: rgba(0, 0, 0, 0.02); }}
1504
+ tr.finding-row.expanded {{ background: rgba(88, 166, 255, 0.05); }}
1505
+ .drawer-chevron {{ display: inline-block; transition: transform 0.2s; font-size: 14px; color: var(--text-muted); }}
1506
+ tr.finding-row.expanded .drawer-chevron {{ transform: rotate(90deg); color: var(--accent-blue); }}
1507
+
1508
+ /* Badges */
1509
+ .badge {{
1510
+ display: inline-block;
1511
+ padding: 2px 8px;
1512
+ border-radius: 12px;
1513
+ font-size: 11px;
1514
+ font-weight: 600;
1515
+ text-transform: uppercase;
1516
+ letter-spacing: 0.3px;
1517
+ }}
1518
+ .badge-critical {{ background: rgba(248, 81, 73, 0.2); color: var(--accent-red); border: 1px solid var(--accent-red); }}
1519
+ .badge-high {{ background: rgba(219, 109, 40, 0.2); color: var(--accent-orange); border: 1px solid var(--accent-orange); }}
1520
+ .badge-medium {{ background: rgba(210, 153, 34, 0.2); color: var(--accent-yellow); border: 1px solid var(--accent-yellow); }}
1521
+ .badge-success {{ background: rgba(63, 185, 80, 0.2); color: var(--accent-green); border: 1px solid var(--accent-green); }}
1522
+ .badge-primary {{ background: rgba(88, 166, 255, 0.2); color: var(--accent-blue); border: 1px solid var(--accent-blue); }}
1523
+ .badge-canary {{ background: rgba(188, 140, 255, 0.2); color: var(--accent-purple); border: 1px solid var(--accent-purple); }}
1524
+
1525
+ .rule-id {{ color: var(--accent-blue); font-family: monospace; font-weight: 600; }}
1526
+ .conf-score {{ color: var(--text-muted); font-size: 12px; }}
1527
+
1528
+ /* Drawer Content */
1529
+ .drawer-content {{
1530
+ padding: 16px;
1531
+ background: var(--bg-dark);
1532
+ border-left: 3px solid var(--accent-blue);
1533
+ margin: 8px 12px;
1534
+ border-radius: 4px;
1535
+ }}
1536
+ body.light-theme .drawer-content {{ background: var(--bg-card); }}
1537
+ .drawer-header {{
1538
+ display: flex;
1539
+ justify-content: space-between;
1540
+ color: var(--text-muted);
1541
+ font-size: 12px;
1542
+ margin-bottom: 8px;
1543
+ }}
1544
+ .drawer-desc {{ font-size: 13px; color: var(--text-main); margin-bottom: 12px; }}
1545
+ .code-preview {{
1546
+ background: #000;
1547
+ border: 1px solid var(--border-subtle);
1548
+ border-radius: 4px;
1549
+ padding: 10px;
1550
+ font-family: monospace;
1551
+ font-size: 12px;
1552
+ overflow-x: auto;
1553
+ margin-bottom: 12px;
1554
+ }}
1555
+ body.light-theme .code-preview {{ background: #f6f8fa; border-color: var(--border-subtle); }}
1556
+ .code-preview code {{ color: #7ee787; font-family: monospace; }}
1557
+ body.light-theme .code-preview code {{ color: #1f2328; }}
1558
+ .drawer-actions {{ display: flex; justify-content: flex-end; gap: 8px; }}
1559
+ .cmd-box {{
1560
+ background: var(--bg-card);
1561
+ border: 1px solid var(--border-subtle);
1562
+ padding: 4px 8px;
1563
+ border-radius: 4px;
1564
+ display: flex;
1565
+ align-items: center;
1566
+ gap: 8px;
1567
+ font-size: 12px;
1568
+ font-family: monospace;
1569
+ }}
1570
+ .btn-copy {{
1571
+ background: transparent;
1572
+ border: none;
1573
+ color: var(--accent-blue);
1574
+ cursor: pointer;
1575
+ font-size: 11px;
1576
+ }}
1577
+ .btn-copy:hover {{ text-decoration: underline; }}
1578
+ .btn-copy.copied {{ color: var(--accent-green) !important; }}
1579
+
1580
+ /* Section Titles */
1581
+ .section-title {{
1582
+ font-size: 14px;
1583
+ font-weight: 700;
1584
+ text-transform: uppercase;
1585
+ letter-spacing: 0.5px;
1586
+ color: var(--text-muted);
1587
+ margin-bottom: 12px;
1588
+ display: flex;
1589
+ align-items: center;
1590
+ gap: 8px;
1591
+ }}
1592
+
1593
+ /* OWASP Grid */
1594
+ .grid-owasp {{
1595
+ display: grid;
1596
+ grid-template-columns: repeat(4, 1fr);
1597
+ gap: 12px;
1598
+ margin-bottom: 24px;
1599
+ }}
1600
+ @media (max-width: 950px) {{ .grid-owasp {{ grid-template-columns: repeat(2, 1fr); }} }}
1601
+ @media (max-width: 550px) {{ .grid-owasp {{ grid-template-columns: 1fr; }} }}
1602
+ .owasp-card {{
1603
+ background: var(--bg-surface);
1604
+ border: 1px solid var(--border-subtle);
1605
+ border-radius: 6px;
1606
+ padding: 12px;
1607
+ }}
1608
+ .owasp-top {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }}
1609
+ .owasp-title {{ font-size: 11px; font-weight: 600; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
1610
+ .owasp-badge {{ font-size: 10px; font-weight: 700; border: 1px solid; border-radius: 8px; padding: 1px 6px; }}
1611
+ .owasp-bar-bg {{ width: 100%; height: 6px; background: var(--bg-card); border-radius: 3px; overflow: hidden; }}
1612
+ .owasp-bar {{ height: 100%; border-radius: 3px; }}
1613
+
1614
+ /* Compliance Framework Grid */
1615
+ .grid-compliance {{
1616
+ display: grid;
1617
+ grid-template-columns: repeat(3, 1fr);
1618
+ gap: 14px;
1619
+ margin-bottom: 24px;
1620
+ }}
1621
+ @media (max-width: 900px) {{ .grid-compliance {{ grid-template-columns: 1fr; }} }}
1622
+ .comp-card {{ padding: 14px; }}
1623
+ .comp-card-header {{
1624
+ display: flex;
1625
+ justify-content: space-between;
1626
+ align-items: flex-start;
1627
+ margin-bottom: 12px;
1628
+ border-bottom: 1px solid var(--border-subtle);
1629
+ padding-bottom: 10px;
1630
+ }}
1631
+ .comp-title {{ font-size: 13px; font-weight: 700; color: var(--text-main); }}
1632
+ .comp-sub {{ font-size: 11px; color: var(--text-muted); margin-top: 2px; }}
1633
+ .comp-ctrl-list {{ display: flex; flex-direction: column; gap: 8px; }}
1634
+ .comp-ctrl-row {{ display: flex; justify-content: space-between; align-items: center; font-size: 11px; }}
1635
+ .comp-ctrl-code {{ font-weight: 600; color: var(--text-main); }}
1636
+ .comp-ctrl-fams {{ color: var(--text-muted); font-size: 10px; }}
1637
+
1638
+ /* Dual Grids */
1639
+ .grid-bottom {{
1640
+ display: grid;
1641
+ grid-template-columns: 1fr 1fr;
1642
+ gap: 20px;
1643
+ margin-bottom: 24px;
1644
+ }}
1645
+ @media (max-width: 850px) {{ .grid-bottom {{ grid-template-columns: 1fr; }} }}
1646
+
1647
+ .cluster-item {{
1648
+ background: var(--bg-card);
1649
+ border: 1px solid var(--border-subtle);
1650
+ border-radius: 6px;
1651
+ padding: 10px 12px;
1652
+ margin-bottom: 8px;
1653
+ }}
1654
+ .cluster-header {{ display: flex; justify-content: space-between; align-items: center; font-size: 13px; }}
1655
+ .cluster-sub {{ font-size: 11px; color: var(--text-muted); margin-top: 4px; }}
1656
+
1657
+ /* Heatmap */
1658
+ .heatmap-row {{ display: flex; align-items: center; gap: 10px; margin-bottom: 8px; cursor: pointer; padding: 4px 6px; border-radius: 4px; }}
1659
+ .heatmap-row:hover {{ background: rgba(255, 255, 255, 0.03); }}
1660
+ body.light-theme .heatmap-row:hover {{ background: rgba(0, 0, 0, 0.03); }}
1661
+ .heatmap-label {{ font-size: 12px; font-family: monospace; color: var(--text-main); min-width: 110px; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
1662
+ .heatmap-bar-bg {{ flex: 1; height: 16px; background: var(--bg-card); border-radius: 4px; overflow: hidden; }}
1663
+ .heatmap-bar {{ height: 100%; border-radius: 4px; }}
1664
+ .heatmap-count {{ font-size: 12px; font-weight: 600; color: var(--text-muted); min-width: 25px; }}
1665
+
1666
+ /* Golden Recipes & Diff Engine */
1667
+ .recipe-card {{
1668
+ background: var(--bg-card);
1669
+ border: 1px solid var(--border-subtle);
1670
+ border-radius: 6px;
1671
+ padding: 14px;
1672
+ margin-bottom: 12px;
1673
+ }}
1674
+ .recipe-header {{
1675
+ display: flex;
1676
+ justify-content: space-between;
1677
+ align-items: center;
1678
+ margin-bottom: 8px;
1679
+ flex-wrap: wrap;
1680
+ gap: 8px;
1681
+ font-size: 13px;
1682
+ }}
1683
+ .recipe-meta-badges {{ display: flex; align-items: center; gap: 6px; }}
1684
+ .chip-filetype {{ background: rgba(88, 166, 255, 0.1); border: 1px solid var(--accent-blue); color: var(--accent-blue); font-size: 10px; font-family: monospace; padding: 2px 6px; border-radius: 4px; }}
1685
+ .ponytail-badge {{ background: rgba(210, 153, 34, 0.15); border: 1px solid var(--accent-yellow); color: var(--accent-yellow); font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; }}
1686
+ .verified-badge {{ background: rgba(63, 185, 80, 0.15); border: 1px solid var(--accent-green); color: var(--accent-green); font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; }}
1687
+ .recipe-view-switcher {{
1688
+ display: flex;
1689
+ gap: 4px;
1690
+ margin-bottom: 8px;
1691
+ }}
1692
+ .recipe-tab-btn {{
1693
+ background: transparent;
1694
+ border: 1px solid var(--border-subtle);
1695
+ color: var(--text-muted);
1696
+ padding: 2px 8px;
1697
+ border-radius: 4px;
1698
+ font-size: 11px;
1699
+ cursor: pointer;
1700
+ }}
1701
+ .recipe-tab-btn.active {{
1702
+ background: var(--accent-blue);
1703
+ border-color: var(--accent-blue);
1704
+ color: #fff;
1705
+ }}
1706
+ .diff-line-header {{ color: var(--text-muted); font-weight: 600; display: block; }}
1707
+ .diff-line-hunk {{ color: var(--accent-blue); background: rgba(88, 166, 255, 0.1); display: block; padding: 1px 4px; border-radius: 2px; }}
1708
+ .diff-line-add {{ color: #7ee787; background: rgba(46, 160, 67, 0.18); display: block; padding: 1px 4px; border-radius: 2px; }}
1709
+ .diff-line-del {{ color: #ffa198; background: rgba(248, 81, 73, 0.18); display: block; padding: 1px 4px; border-radius: 2px; }}
1710
+ .diff-line-ctx {{ color: var(--text-muted); display: block; padding: 1px 4px; }}
1711
+ body.light-theme .diff-line-add {{ color: #1a7f37; background: rgba(46, 160, 67, 0.15); }}
1712
+ body.light-theme .diff-line-del {{ color: #cf222e; background: rgba(207, 34, 46, 0.15); }}
1713
+ body.light-theme .diff-line-hunk {{ color: #0969da; background: rgba(9, 105, 218, 0.1); }}
1714
+
1715
+ .recipe-split-container {{
1716
+ display: grid;
1717
+ grid-template-columns: 1fr 1fr;
1718
+ gap: 10px;
1719
+ margin-bottom: 12px;
1720
+ }}
1721
+ @media (max-width: 650px) {{ .recipe-split-container {{ grid-template-columns: 1fr; }} }}
1722
+ .split-pane .code-preview {{ margin-bottom: 0; min-height: 70px; }}
1723
+ .split-label {{ font-size: 11px; font-weight: 600; margin-bottom: 4px; }}
1724
+ .split-del {{ color: var(--accent-red); }}
1725
+ .split-add {{ color: var(--accent-green); }}
1726
+ .recipe-actions {{ display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }}
1727
+
1728
+ /* Advisory Items */
1729
+ .advisory-item {{
1730
+ background: var(--bg-card);
1731
+ border: 1px solid var(--border-subtle);
1732
+ border-radius: 6px;
1733
+ padding: 12px;
1734
+ margin-bottom: 10px;
1735
+ }}
1736
+ .advisory-top {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }}
1737
+ .advisory-mitigates {{ font-size: 11px; color: var(--accent-blue); margin-bottom: 4px; }}
1738
+ .advisory-desc {{ font-size: 12px; color: var(--text-main); margin-bottom: 4px; }}
1739
+ .advisory-guarantee {{ font-size: 11px; color: var(--text-muted); margin-bottom: 6px; }}
1740
+
1741
+ .empty-state {{ text-align: center; padding: 20px; color: var(--text-muted); font-size: 13px; }}
1742
+ .empty-cell {{ text-align: center; color: var(--accent-green); padding: 24px; font-weight: 600; }}
1743
+
1744
+ footer {{
1745
+ margin-top: 36px;
1746
+ text-align: center;
1747
+ color: var(--text-muted);
1748
+ font-size: 12px;
1749
+ border-top: 1px solid var(--border-subtle);
1750
+ padding-top: 18px;
1751
+ }}
1752
+
1753
+ /* Print & PDF styling */
1754
+ @media print {{
1755
+ body {{ background: #fff !important; color: #000 !important; padding: 10px; }}
1756
+ .card, .owasp-card, .cluster-item, .defended-section, .table-container, .recipe-card, .advisory-item, .comp-card {{
1757
+ border: 1px solid #ccc !important;
1758
+ background: #fff !important;
1759
+ color: #000 !important;
1760
+ box-shadow: none !important;
1761
+ }}
1762
+ .header-actions, .filter-bar, .btn-copy, footer, .drawer-actions, .simulator-card, .recipe-view-switcher, .recipe-actions {{ display: none !important; }}
1763
+ .finding-row {{ background: #fff !important; color: #000 !important; }}
1764
+ .finding-title, .rule-id {{ color: #000 !important; }}
1765
+ .drawer-row {{ display: table-row !important; }}
1766
+ .drawer-content {{ background: #f6f8fa !important; border-left: 3px solid #000 !important; }}
1767
+ .code-preview {{ background: #eee !important; color: #000 !important; }}
1768
+ .code-preview code {{ color: #000 !important; }}
1769
+ .gauge-card {{ display: none !important; }}
1770
+ }}
1771
+ </style>
1772
+ </head>
1773
+ <body>
1774
+ <div class="container">
1775
+ <header>
1776
+ <div class="logo-group">
1777
+ <h1><span class="shield-icon">&#128737;</span> TorusGuard Security Report</h1>
1778
+ <div class="project-meta">
1779
+ <strong>{html.escape(telemetry.get('project_name', 'Project'))}</strong> &middot;
1780
+ {html.escape(stack_label)} &middot;
1781
+ {html.escape(telemetry.get('generated_at', ''))}
1782
+ </div>
1783
+ </div>
1784
+ <div class="header-actions">
1785
+ <button class="btn" id="themeToggleBtn" onclick="toggleTheme()">☀️ Light Mode</button>
1786
+ <button class="btn" onclick="downloadSarif()">&#128229; Export SARIF</button>
1787
+ <button class="btn" onclick="downloadCsv()">&#128202; Export CSV</button>
1788
+ <button class="btn" onclick="window.print()">&#128438; Print / PDF</button>
1789
+ <div class="status-badge {status_badge_class}">{status_label}</div>
1790
+ </div>
1791
+ </header>
1792
+
1793
+ <!-- KPI Cards -->
1794
+ <div class="grid-kpi">
1795
+ <div class="card gauge-card">
1796
+ <div style="position: relative; display: flex; align-items: center; justify-content: center;">
1797
+ <svg class="gauge-svg" viewBox="0 0 100 100">
1798
+ <defs>
1799
+ <linearGradient id="gaugeGradient" x1="0%" y1="0%" x2="100%" y2="100%">
1800
+ <stop offset="0%" stop-color="{grad_start}" />
1801
+ <stop offset="100%" stop-color="{grad_end}" />
1802
+ </linearGradient>
1803
+ <filter id="gaugeGlow" x="-20%" y="-20%" width="140%" height="140%">
1804
+ <feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="{glow_color}" flood-opacity="0.5"/>
1805
+ </filter>
1806
+ </defs>
1807
+ <circle class="gauge-bg" cx="50" cy="50" r="40" />
1808
+ <circle class="gauge-fill" id="gaugeCircleFill" cx="50" cy="50" r="40" stroke="url(#gaugeGradient)" filter="url(#gaugeGlow)" />
1809
+ </svg>
1810
+ <div class="gauge-value" id="postureScoreText">0</div>
1811
+ </div>
1812
+ <div class="stat-label" style="font-size: 12px; color: var(--text-muted); text-transform: uppercase; margin-top: 10px;">Posture Score</div>
1813
+ </div>
1814
+
1815
+ <div class="card sev-card" onclick="filterBySeverity('critical')">
1816
+ <div class="sev-count" style="color: var(--accent-red);">{crit_count}</div>
1817
+ <div class="sev-label">Critical Exposure</div>
1818
+ </div>
1819
+
1820
+ <div class="card sev-card" onclick="filterBySeverity('high')">
1821
+ <div class="sev-count" style="color: var(--accent-orange);">{high_count}</div>
1822
+ <div class="sev-label">High Exposure</div>
1823
+ </div>
1824
+
1825
+ <div class="card sev-card" onclick="filterBySeverity('medium')">
1826
+ <div class="sev-count" style="color: var(--accent-yellow);">{med_count}</div>
1827
+ <div class="sev-label">Medium / Low</div>
1828
+ </div>
1829
+
1830
+ <div class="card sev-card" onclick="toggleDefendedSection()">
1831
+ <div class="sev-count" style="color: var(--accent-green);">{defended_count}<span style="font-size: 16px; color: var(--text-muted);">/{total_rules}</span></div>
1832
+ <div class="sev-label">Guards Defended</div>
1833
+ </div>
1834
+ </div>
1835
+
1836
+ <!-- What-If Posture Score Simulator -->
1837
+ <div class="card simulator-card {'simulator-clean' if len(sorted_f) == 0 else ''}">
1838
+ <div>
1839
+ <div style="display: flex; align-items: center; gap: 8px;">
1840
+ <strong style="font-size: 13px;">⚡ Interactive "What-If" Posture Score Simulator</strong>
1841
+ <span class="badge badge-primary">Dynamic Recalculation</span>
1842
+ </div>
1843
+ <div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">
1844
+ {'All 74 security invariants are currently defended. Simulator is ready for live remediation modeling during audit cycles.' if len(sorted_f) == 0 else 'Simulate applying governed patches to preview projected posture improvement in real time:'}
1845
+ </div>
1846
+ </div>
1847
+ <div class="sim-stats">
1848
+ <div>Current: <strong id="simCurrentVal">{score}</strong>/100</div>
1849
+ <div>&rarr;</div>
1850
+ <div>Projected: <strong id="simProjectedVal" style="color: var(--accent-blue);">{score}</strong>/100 <span id="simDeltaBadge" class="badge badge-success">+0 pts</span></div>
1851
+ </div>
1852
+ <div class="sim-actions">
1853
+ <button class="btn btn-sm" onclick="simulateFixAll('Critical')">&#9889; Simulate Criticals</button>
1854
+ <button class="btn btn-sm" onclick="simulateFixAll('all')">&#9889; Simulate All Fixes</button>
1855
+ <button class="btn btn-sm" onclick="resetSimulatedFixes()">Reset Simulator</button>
1856
+ </div>
1857
+ </div>
1858
+
1859
+ <!-- Active Defenses & Protected Invariants (5-Layer Architectural Invariant Matrix) -->
1860
+ <div class="defended-section" id="defendedSection">
1861
+ <div class="defended-header" onclick="toggleDefendedVisibility()">
1862
+ <div class="section-title" style="margin-bottom: 0;">
1863
+ &#128737; Active Defenses &amp; Protected Invariants ({defended_count} Passing)
1864
+ </div>
1865
+ <span id="defendedToggleIcon" style="color: var(--text-muted); font-size: 12px;">&#9660; Collapse Matrix</span>
1866
+ </div>
1867
+ <div id="defendedMatrixBody">
1868
+ <div class="coverage-bar-container">
1869
+ <div class="coverage-stats">
1870
+ <span><strong>{defended_count} of {total_rules} Invariants Enforced</strong> ({coverage_pct}% Defense Coverage)</span>
1871
+ <span class="badge {'badge-success' if coverage_pct >= 90 else 'badge-high'}">{coverage_pct}% Enforced</span>
1872
+ </div>
1873
+ <div class="coverage-progress-bg">
1874
+ <div class="coverage-progress-fill" style="width: {coverage_pct}%;"></div>
1875
+ </div>
1876
+ </div>
1877
+ <div class="inv-search-bar">
1878
+ <div class="search-wrapper" style="width: 100%;">
1879
+ <input type="text" id="invSearchInput" class="search-input" style="width: 100%;" placeholder="Search 74 invariants by ID, title, layer, or keyword..." oninput="filterInvariants()">
1880
+ <button class="search-clear-btn" id="invSearchClearBtn" onclick="clearInvSearch()">&times;</button>
1881
+ </div>
1882
+ <div id="invMatchCount" style="font-size: 11px; color: var(--accent-blue); white-space: nowrap;"></div>
1883
+ </div>
1884
+ <div class="layers-container" id="layersList">
1885
+ {layers_html}
1886
+ </div>
1887
+ </div>
1888
+ </div>
1889
+
1890
+ <!-- Filter & Search Bar for Findings -->
1891
+ <div class="filter-bar">
1892
+ <div class="filter-pills">
1893
+ <span class="pill-group-label">Severity:</span>
1894
+ <span class="pill active" data-sev-filter="all" onclick="filterBySeverity('all')">All</span>
1895
+ <span class="pill" data-sev-filter="critical" onclick="filterBySeverity('critical')">&#128308; Critical ({crit_count})</span>
1896
+ <span class="pill" data-sev-filter="high" onclick="filterBySeverity('high')">&#128992; High ({high_count})</span>
1897
+ <span class="pill" data-sev-filter="medium" onclick="filterBySeverity('medium')">&#128993; Med/Low ({med_count})</span>
1898
+
1899
+ <span class="pill-group-label" style="margin-left: 8px;">Status:</span>
1900
+ <span class="pill active" data-status-filter="all" onclick="filterByStatus('all')">All</span>
1901
+ <span class="pill" data-status-filter="open" onclick="filterByStatus('open')">&#9888; Open ({open_count})</span>
1902
+ <span class="pill" data-status-filter="resolved" onclick="filterByStatus('resolved')">&#10004; Resolved ({resolved_count})</span>
1903
+
1904
+ <span class="dir-filter-chip" id="dirFilterChip" onclick="clearDirectoryFilter()">
1905
+ Folder: <strong id="dirFilterName"></strong> &times;
1906
+ </span>
1907
+ <button class="btn-reset-filters" id="btnResetFilters" onclick="resetAllFilters()">&times; Reset Filters</button>
1908
+ </div>
1909
+ <div class="search-wrapper">
1910
+ <input type="text" id="searchInput" class="search-input" placeholder="Search rules, titles, files..." oninput="searchFindings()">
1911
+ <button class="search-clear-btn" id="searchClearBtn" onclick="clearSearch()">&times;</button>
1912
+ </div>
1913
+ </div>
1914
+ <div class="filter-stats-bar">
1915
+ <div>Showing <span id="visibleCount" style="font-weight: 700; color: var(--text-main);">{min(len(sorted_f), 50)}</span> of <span id="totalCount" style="font-weight: 700; color: var(--text-main);">{len(sorted_f)}</span> cataloged findings</div>
1916
+ <div id="activeFilterSummary" style="font-size: 11px; color: var(--accent-blue);"></div>
1917
+ </div>
1918
+
1919
+ <!-- Findings Table -->
1920
+ <div class="table-container">
1921
+ <table>
1922
+ <thead>
1923
+ <tr>
1924
+ <th style="width: 70px; text-align: center;">Simulate</th>
1925
+ <th style="width: 110px;">Severity</th>
1926
+ <th style="width: 130px;">Rule ID</th>
1927
+ <th>Finding Title</th>
1928
+ <th style="width: 250px;">File &amp; Location</th>
1929
+ <th style="width: 90px;">Confidence</th>
1930
+ <th style="width: 30px;"></th>
1931
+ </tr>
1932
+ </thead>
1933
+ <tbody id="findingsTableBody">
1934
+ {findings_rows}
1935
+ </tbody>
1936
+ </table>
1937
+ </div>
1938
+
1939
+ <!-- OWASP Top 10 Compliance Radar -->
1940
+ <div class="section-title">&#128737; OWASP Top 10 (2021/2026) Compliance Matrix</div>
1941
+ <div class="grid-owasp">
1942
+ {owasp_cards_html}
1943
+ </div>
1944
+
1945
+ <!-- Enterprise Compliance Framework Mapping -->
1946
+ <div class="section-title">&#127963; Enterprise Compliance Framework Mapping (SOC 2 &middot; ISO 27001 &middot; HIPAA)</div>
1947
+ <div class="grid-compliance">
1948
+ {compliance_cards_html}
1949
+ </div>
1950
+
1951
+ <!-- Middle Grid: Heatmap + Clusters -->
1952
+ <div class="grid-bottom">
1953
+ <div class="card">
1954
+ <div class="section-title">&#128293; Directory Attack Surface Heatmap</div>
1955
+ <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 12px;">Click a folder to filter findings for that directory:</div>
1956
+ {heatmap_rows_html}
1957
+ </div>
1958
+
1959
+ <div class="card">
1960
+ <div class="section-title">&#128736; Root-Cause Architectural Clusters</div>
1961
+ {clusters_html}
1962
+ </div>
1963
+ </div>
1964
+
1965
+ <!-- Bottom Grid: Golden Recipes + Next Best Defenses -->
1966
+ <div class="grid-bottom">
1967
+ <div class="card">
1968
+ <div class="section-title">&#10024; Golden Fix Recipes ({len(recipes)})</div>
1969
+ <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 10px;">Verified remediation patterns distilled from applied patches:</div>
1970
+ <div class="filter-pills" style="margin-bottom: 12px;">
1971
+ {recipe_cat_pills_html}
1972
+ </div>
1973
+ <div id="recipeContainer">
1974
+ {recipes_html}
1975
+ </div>
1976
+ </div>
1977
+
1978
+ <div class="card">
1979
+ <div class="section-title">&#128161; Next Best Defenses Advisory</div>
1980
+ <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 12px;">Stack-tailored recommendations to achieve hardened posture:</div>
1981
+ {advisory_html}
1982
+ </div>
1983
+ </div>
1984
+
1985
+ <footer>
1986
+ TorusGuard v1.3.6 &middot; Autonomous Security Engine for AI-Built Applications &middot; 100% Local-First &middot; Zero Cloud Telemetry
1987
+ </footer>
1988
+ </div>
1989
+
1990
+ <!-- Invariant Inspection Modal -->
1991
+ <div id="invariantModal" class="modal-backdrop" style="display: none;" onclick="closeInvariantModal(event)">
1992
+ <div class="modal-card" onclick="event.stopPropagation()">
1993
+ <div class="modal-header">
1994
+ <div>
1995
+ <div style="display: flex; align-items: center; gap: 8px;">
1996
+ <span id="modalInvSev" class="badge badge-medium">Medium</span>
1997
+ <code id="modalInvRule" class="rule-id" style="font-size: 14px;">TG-GEN</code>
1998
+ <span id="modalInvStatus" class="badge badge-success">Defended</span>
1999
+ </div>
2000
+ <h3 id="modalInvTitle" style="font-size: 16px; margin-top: 6px; color: var(--text-main);">Security Invariant</h3>
2001
+ </div>
2002
+ <button class="modal-close-btn" onclick="closeInvariantModal(event)">&times;</button>
2003
+ </div>
2004
+ <div class="modal-body">
2005
+ <div>
2006
+ <div style="font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 600;">Architectural Layer &amp; Category</div>
2007
+ <div id="modalInvLayer" style="margin-top: 2px; font-weight: 500;">Application &amp; LLM Defense</div>
2008
+ </div>
2009
+ <div>
2010
+ <div style="font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 600;">Invariant Guarantee</div>
2011
+ <div id="modalInvGuarantee" style="margin-top: 4px; line-height: 1.4; color: var(--text-main);">
2012
+ Enforces deterministic verification without bypasses.
2013
+ </div>
2014
+ </div>
2015
+ <div>
2016
+ <div style="font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 600;">Ponytail Churn Invariant</div>
2017
+ <div style="margin-top: 2px; color: var(--accent-yellow); font-size: 12px;">
2018
+ &le; 35 additions, &le; 25 deletions per surgical patch bundle.
2019
+ </div>
2020
+ </div>
2021
+ <div>
2022
+ <div style="font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 600; margin-bottom: 4px;">Governed CLI Command</div>
2023
+ <div class="cmd-box" id="modalInvCmd">
2024
+ <code>npx torusguard harden --rule TG-GEN</code>
2025
+ <button class="btn-copy" onclick="copyText('npx torusguard harden --rule TG-GEN', this)">&#128203; Copy</button>
2026
+ </div>
2027
+ </div>
2028
+ </div>
2029
+ </div>
2030
+ </div>
2031
+
2032
+ <script>
2033
+ const RAW_FINDINGS = {findings_json_str};
2034
+ const SARIF_RULES = {sarif_rules_json_str};
2035
+ const INVARIANT_CATALOG = {rule_catalog_json_str};
2036
+ const VIOLATED_RULE_IDS = {violated_ids_json_str};
2037
+
2038
+ let currentSeverity = 'all';
2039
+ let currentStatus = 'all';
2040
+ let currentDirectory = '';
2041
+ let currentRecipeCategory = 'all';
2042
+ let BASE_POSTURE_SCORE = {score};
2043
+
2044
+ // ── Theme Switcher ──
2045
+ function initTheme() {{
2046
+ const saved = localStorage.getItem('tg_theme');
2047
+ const btn = document.getElementById('themeToggleBtn');
2048
+ if (saved === 'light') {{
2049
+ document.body.classList.add('light-theme');
2050
+ if (btn) btn.innerHTML = '&#127769; Dark Mode';
2051
+ }} else {{
2052
+ document.body.classList.remove('light-theme');
2053
+ if (btn) btn.innerHTML = '&#9728;&#65039; Light Mode';
2054
+ }}
2055
+ }}
2056
+
2057
+ function toggleTheme() {{
2058
+ const isLight = document.body.classList.toggle('light-theme');
2059
+ localStorage.setItem('tg_theme', isLight ? 'light' : 'dark');
2060
+ const btn = document.getElementById('themeToggleBtn');
2061
+ if (btn) btn.innerHTML = isLight ? '&#127769; Dark Mode' : '&#9728;&#65039; Light Mode';
2062
+ }}
2063
+
2064
+ document.addEventListener('DOMContentLoaded', initTheme);
2065
+ if (document.readyState === 'complete' || document.readyState === 'interactive') {{
2066
+ initTheme();
2067
+ }}
2068
+
2069
+ // ── What-If Posture Score Simulator ──
2070
+ function updateSimulatedScore() {{
2071
+ const checkboxes = document.querySelectorAll('.sim-fix-chk');
2072
+ const fixedIds = new Set();
2073
+ checkboxes.forEach(cb => {{
2074
+ if (cb.checked) {{
2075
+ fixedIds.add(cb.getAttribute('data-finding-id'));
2076
+ }}
2077
+ }});
2078
+
2079
+ let remCrit = 0, remHigh = 0, remMed = 0, remLow = 0;
2080
+ RAW_FINDINGS.forEach(f => {{
2081
+ if (f.is_canary) return;
2082
+ if (fixedIds.has(f.finding_id)) return; // Simulated as fixed
2083
+ const s = f.severity;
2084
+ if (s === 'Critical') remCrit++;
2085
+ else if (s === 'High') remHigh++;
2086
+ else if (s === 'Medium') remMed++;
2087
+ else if (s === 'Low') remLow++;
2088
+ }});
2089
+
2090
+ const penalty = (remCrit * 25) + (remHigh * 15) + (remMed * 5) + (remLow * 2);
2091
+ const newScore = Math.max(0, Math.min(100, 100 - penalty));
2092
+
2093
+ // Update projected UI labels
2094
+ const projEl = document.getElementById('simProjectedVal');
2095
+ const deltaEl = document.getElementById('simDeltaBadge');
2096
+ if (projEl) projEl.textContent = newScore;
2097
+ const delta = newScore - BASE_POSTURE_SCORE;
2098
+ if (deltaEl) {{
2099
+ deltaEl.textContent = (delta >= 0 ? '+' : '') + delta + ' pts';
2100
+ deltaEl.className = 'badge ' + (delta > 0 ? 'badge-success' : 'badge-primary');
2101
+ }}
2102
+
2103
+ // Animate SVG gauge to simulated score
2104
+ animateGaugeTo(newScore);
2105
+ }}
2106
+
2107
+ function simulateFixAll(sev) {{
2108
+ const checkboxes = document.querySelectorAll('.sim-fix-chk');
2109
+ checkboxes.forEach(cb => {{
2110
+ const cbSev = cb.getAttribute('data-severity');
2111
+ if (sev === 'all' || cbSev === sev) {{
2112
+ cb.checked = true;
2113
+ }}
2114
+ }});
2115
+ updateSimulatedScore();
2116
+ }}
2117
+
2118
+ function resetSimulatedFixes() {{
2119
+ const checkboxes = document.querySelectorAll('.sim-fix-chk');
2120
+ checkboxes.forEach(cb => {{ cb.checked = false; }});
2121
+ updateSimulatedScore();
2122
+ }}
2123
+
2124
+ function animateGaugeTo(targetScore) {{
2125
+ const circumference = 251.2;
2126
+ const targetOffset = circumference - (circumference * (targetScore / 100.0));
2127
+ const gaugeEl = document.getElementById('gaugeCircleFill');
2128
+ const textEl = document.getElementById('postureScoreText');
2129
+
2130
+ if (gaugeEl) {{
2131
+ gaugeEl.style.strokeDashoffset = targetOffset;
2132
+ }}
2133
+ if (textEl) {{
2134
+ textEl.textContent = targetScore;
2135
+ }}
2136
+ }}
2137
+
2138
+ // ── Invariant Matrix Filters & Inspection ──
2139
+ function filterInvariants() {{
2140
+ const input = document.getElementById('invSearchInput');
2141
+ const clearBtn = document.getElementById('invSearchClearBtn');
2142
+ const q = (input ? input.value : '').toLowerCase().trim();
2143
+ if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
2144
+
2145
+ const chips = document.querySelectorAll('.inv-chip');
2146
+ let matchCount = 0;
2147
+ chips.forEach(chip => {{
2148
+ const s = chip.getAttribute('data-search') || '';
2149
+ const visible = !q || s.includes(q);
2150
+ chip.style.display = visible ? 'inline-flex' : 'none';
2151
+ if (visible) matchCount++;
2152
+ }});
2153
+
2154
+ const countEl = document.getElementById('invMatchCount');
2155
+ if (countEl) {{
2156
+ countEl.textContent = q ? '(' + matchCount + ' matching)' : '';
2157
+ }}
2158
+ }}
2159
+
2160
+ function clearInvSearch() {{
2161
+ const input = document.getElementById('invSearchInput');
2162
+ if (input) input.value = '';
2163
+ filterInvariants();
2164
+ }}
2165
+
2166
+ function inspectInvariant(ruleId) {{
2167
+ const item = INVARIANT_CATALOG[ruleId];
2168
+ if (!item) return;
2169
+ const modal = document.getElementById('invariantModal');
2170
+ const isViolated = VIOLATED_RULE_IDS.includes(ruleId);
2171
+
2172
+ document.getElementById('modalInvRule').textContent = item.rule_id;
2173
+ document.getElementById('modalInvTitle').textContent = item.title;
2174
+ document.getElementById('modalInvLayer').textContent = item.layer_name + ' &middot; ' + item.category;
2175
+ document.getElementById('modalInvGuarantee').textContent = item.guarantee;
2176
+
2177
+ const sevEl = document.getElementById('modalInvSev');
2178
+ sevEl.textContent = item.severity;
2179
+ sevEl.className = 'badge ' + (item.severity === 'Critical' ? 'badge-critical' : (item.severity === 'High' ? 'badge-high' : 'badge-medium'));
2180
+
2181
+ const statusEl = document.getElementById('modalInvStatus');
2182
+ statusEl.innerHTML = isViolated ? '&#9888; Violated' : '&#10004; Defended';
2183
+ statusEl.className = 'badge ' + (isViolated ? 'badge-critical' : 'badge-success');
2184
+
2185
+ const cmdBox = document.getElementById('modalInvCmd');
2186
+ cmdBox.innerHTML = `<code>npx torusguard harden --rule ${{item.rule_id}}</code>
2187
+ <button class="btn-copy" onclick="copyText('npx torusguard harden --rule ${{item.rule_id}}', this); event.stopPropagation();">&#128203; Copy</button>`;
2188
+
2189
+ if (modal) modal.style.display = 'flex';
2190
+ }}
2191
+
2192
+ function closeInvariantModal(e) {{
2193
+ const modal = document.getElementById('invariantModal');
2194
+ if (modal) modal.style.display = 'none';
2195
+ }}
2196
+
2197
+ document.addEventListener('keydown', function(e) {{
2198
+ if (e.key === 'Escape') closeInvariantModal();
2199
+ }});
2200
+
2201
+ // ── Golden Recipes View Switcher & Category Filter ──
2202
+ function setRecipeView(recipeId, viewMode) {{
2203
+ const diffView = document.getElementById('recipe-view-diff-' + recipeId);
2204
+ const splitView = document.getElementById('recipe-view-split-' + recipeId);
2205
+ const btnDiff = document.getElementById('btn-view-diff-' + recipeId);
2206
+ const btnSplit = document.getElementById('btn-view-split-' + recipeId);
2207
+
2208
+ if (viewMode === 'diff') {{
2209
+ if (diffView) diffView.style.display = 'block';
2210
+ if (splitView) splitView.style.display = 'none';
2211
+ if (btnDiff) btnDiff.classList.add('active');
2212
+ if (btnSplit) btnSplit.classList.remove('active');
2213
+ }} else {{
2214
+ if (diffView) diffView.style.display = 'none';
2215
+ if (splitView) splitView.style.display = 'grid';
2216
+ if (btnDiff) btnDiff.classList.remove('active');
2217
+ if (btnSplit) btnSplit.classList.add('active');
2218
+ }}
2219
+ }}
2220
+
2221
+ function filterRecipeCategory(cat) {{
2222
+ currentRecipeCategory = cat;
2223
+ document.querySelectorAll('[data-recipe-cat-filter]').forEach(p => {{
2224
+ if (p.getAttribute('data-recipe-cat-filter') === cat) p.classList.add('active');
2225
+ else p.classList.remove('active');
2226
+ }});
2227
+
2228
+ document.querySelectorAll('.recipe-card').forEach(card => {{
2229
+ const cardCat = card.getAttribute('data-category') || '';
2230
+ if (cat === 'all' || cardCat === cat) {{
2231
+ card.style.display = '';
2232
+ }} else {{
2233
+ card.style.display = 'none';
2234
+ }}
2235
+ }});
2236
+ }}
2237
+
2238
+ // ── Findings Filtering & Drawer Lifecycle ──
2239
+ function filterBySeverity(sev) {{
2240
+ currentSeverity = sev;
2241
+ document.querySelectorAll('[data-sev-filter]').forEach(p => {{
2242
+ if (p.getAttribute('data-sev-filter') === sev) p.classList.add('active');
2243
+ else p.classList.remove('active');
2244
+ }});
2245
+ searchFindings();
2246
+ }}
2247
+
2248
+ function filterByStatus(status) {{
2249
+ currentStatus = status;
2250
+ document.querySelectorAll('[data-status-filter]').forEach(p => {{
2251
+ if (p.getAttribute('data-status-filter') === status) p.classList.add('active');
2252
+ else p.classList.remove('active');
2253
+ }});
2254
+ searchFindings();
2255
+ }}
2256
+
2257
+ function filterFindings(sev) {{
2258
+ filterBySeverity(sev);
2259
+ }}
2260
+
2261
+ function filterByDirectory(dir) {{
2262
+ currentDirectory = dir.replace(/\\/$/, '');
2263
+ const chip = document.getElementById('dirFilterChip');
2264
+ const chipName = document.getElementById('dirFilterName');
2265
+ if (chip && chipName) {{
2266
+ chipName.textContent = currentDirectory;
2267
+ chip.style.display = 'inline-flex';
2268
+ }}
2269
+ searchFindings();
2270
+ }}
2271
+
2272
+ function clearDirectoryFilter() {{
2273
+ currentDirectory = '';
2274
+ const chip = document.getElementById('dirFilterChip');
2275
+ if (chip) chip.style.display = 'none';
2276
+ searchFindings();
2277
+ }}
2278
+
2279
+ function clearSearch() {{
2280
+ const searchInput = document.getElementById('searchInput');
2281
+ if (searchInput) searchInput.value = '';
2282
+ searchFindings();
2283
+ }}
2284
+
2285
+ function resetAllFilters() {{
2286
+ currentSeverity = 'all';
2287
+ currentStatus = 'all';
2288
+ currentDirectory = '';
2289
+ const searchInput = document.getElementById('searchInput');
2290
+ if (searchInput) searchInput.value = '';
2291
+
2292
+ document.querySelectorAll('[data-sev-filter]').forEach(p => {{
2293
+ if (p.getAttribute('data-sev-filter') === 'all') p.classList.add('active');
2294
+ else p.classList.remove('active');
2295
+ }});
2296
+ document.querySelectorAll('[data-status-filter]').forEach(p => {{
2297
+ if (p.getAttribute('data-status-filter') === 'all') p.classList.add('active');
2298
+ else p.classList.remove('active');
2299
+ }});
2300
+ const chip = document.getElementById('dirFilterChip');
2301
+ if (chip) chip.style.display = 'none';
2302
+
2303
+ searchFindings();
2304
+ }}
2305
+
2306
+ function toggleDefendedVisibility() {{
2307
+ const body = document.getElementById('defendedMatrixBody');
2308
+ const icon = document.getElementById('defendedToggleIcon');
2309
+ if (!body) return;
2310
+ if (body.style.display === 'none') {{
2311
+ body.style.display = 'block';
2312
+ icon.innerHTML = '&#9660; Collapse Matrix';
2313
+ }} else {{
2314
+ body.style.display = 'none';
2315
+ icon.innerHTML = '&#9654; Expand Matrix';
2316
+ }}
2317
+ }}
2318
+
2319
+ function toggleDefendedSection() {{
2320
+ const sec = document.getElementById('defendedSection');
2321
+ if (sec) sec.scrollIntoView({{ behavior: 'smooth' }});
2322
+ }}
2323
+
2324
+ function searchFindings() {{
2325
+ const searchInput = document.getElementById('searchInput');
2326
+ const clearBtn = document.getElementById('searchClearBtn');
2327
+ const query = (searchInput ? searchInput.value : '').toLowerCase().trim();
2328
+
2329
+ if (clearBtn) clearBtn.style.display = query ? 'block' : 'none';
2330
+
2331
+ const rows = document.querySelectorAll('.finding-row');
2332
+ let visibleCount = 0;
2333
+
2334
+ rows.forEach(r => {{
2335
+ const rSev = r.getAttribute('data-severity') || '';
2336
+ const rStatus = r.getAttribute('data-status') || 'open';
2337
+ const rSearch = r.getAttribute('data-search') || '';
2338
+ const rPath = r.getAttribute('data-path') || '';
2339
+ const drawerId = r.getAttribute('data-drawer-id');
2340
+ const drawer = document.getElementById(drawerId);
2341
+
2342
+ let matchSev = (currentSeverity === 'all') || (rSev === currentSeverity);
2343
+ if (currentSeverity === 'medium') {{
2344
+ matchSev = (rSev === 'medium' || rSev === 'low');
2345
+ }}
2346
+
2347
+ let matchStatus = (currentStatus === 'all') || (rStatus === currentStatus);
2348
+
2349
+ let matchDir = true;
2350
+ if (currentDirectory) {{
2351
+ matchDir = rPath.toLowerCase().startsWith(currentDirectory.toLowerCase());
2352
+ }}
2353
+
2354
+ const matchQuery = !query || rSearch.includes(query);
2355
+ const visible = matchSev && matchStatus && matchDir && matchQuery;
2356
+
2357
+ if (visible) visibleCount++;
2358
+ r.style.display = visible ? '' : 'none';
2359
+ if (drawer && !visible) {{
2360
+ drawer.style.display = 'none';
2361
+ r.classList.remove('expanded');
2362
+ }}
2363
+ }});
2364
+
2365
+ const countEl = document.getElementById('visibleCount');
2366
+ if (countEl) countEl.textContent = visibleCount;
2367
+
2368
+ const resetBtn = document.getElementById('btnResetFilters');
2369
+ const hasActiveFilters = currentSeverity !== 'all' || currentStatus !== 'all' || currentDirectory !== '' || query !== '';
2370
+ if (resetBtn) {{
2371
+ resetBtn.style.display = hasActiveFilters ? 'inline-block' : 'none';
2372
+ }}
2373
+
2374
+ const summaryEl = document.getElementById('activeFilterSummary');
2375
+ if (summaryEl) {{
2376
+ const parts = [];
2377
+ if (currentSeverity !== 'all') parts.push(`Sev: ${{currentSeverity}}`);
2378
+ if (currentStatus !== 'all') parts.push(`Status: ${{currentStatus}}`);
2379
+ if (currentDirectory) parts.push(`Dir: ${{currentDirectory}}`);
2380
+ if (query) parts.push(`Query: "${{query}}"`);
2381
+ summaryEl.textContent = parts.length ? `(${{parts.join(', ')}})` : '';
2382
+ }}
2383
+ }}
2384
+
2385
+ function initPostureGauge() {{
2386
+ const targetScore = {score};
2387
+ const circumference = 251.2;
2388
+ const targetOffset = circumference - (circumference * (targetScore / 100.0));
2389
+ const gaugeEl = document.getElementById('gaugeCircleFill');
2390
+ const textEl = document.getElementById('postureScoreText');
2391
+
2392
+ setTimeout(() => {{
2393
+ if (gaugeEl) {{
2394
+ gaugeEl.style.strokeDashoffset = targetOffset;
2395
+ }}
2396
+ }}, 50);
2397
+
2398
+ if (textEl) {{
2399
+ const duration = 800;
2400
+ const startTime = performance.now();
2401
+ function updateCount(now) {{
2402
+ const elapsed = now - startTime;
2403
+ const progress = Math.min(elapsed / duration, 1.0);
2404
+ const eased = 1 - Math.pow(1 - progress, 3);
2405
+ const val = Math.round(eased * targetScore);
2406
+ textEl.textContent = val;
2407
+ if (progress < 1.0) {{
2408
+ requestAnimationFrame(updateCount);
2409
+ }} else {{
2410
+ textEl.textContent = targetScore;
2411
+ }}
2412
+ }}
2413
+ requestAnimationFrame(updateCount);
2414
+ }}
2415
+ }}
2416
+ document.addEventListener('DOMContentLoaded', initPostureGauge);
2417
+ if (document.readyState === 'complete' || document.readyState === 'interactive') {{
2418
+ initPostureGauge();
2419
+ }}
2420
+
2421
+ function toggleDrawer(row) {{
2422
+ const drawerId = row.getAttribute('data-drawer-id');
2423
+ const drawer = document.getElementById(drawerId);
2424
+ if (!drawer) return;
2425
+
2426
+ const isExpanded = row.classList.contains('expanded');
2427
+ if (isExpanded) {{
2428
+ row.classList.remove('expanded');
2429
+ drawer.style.display = 'none';
2430
+ }} else {{
2431
+ row.classList.add('expanded');
2432
+ drawer.style.display = '';
2433
+ }}
2434
+ }}
2435
+
2436
+ function copyText(text, btn) {{
2437
+ const cleanText = text.replace(/\\\\n/g, '\\n');
2438
+ if (navigator.clipboard && window.isSecureContext) {{
2439
+ navigator.clipboard.writeText(cleanText).then(() => {{
2440
+ showCopied(btn);
2441
+ }}).catch(() => {{
2442
+ fallbackCopy(cleanText, btn);
2443
+ }});
2444
+ }} else {{
2445
+ fallbackCopy(cleanText, btn);
2446
+ }}
2447
+ }}
2448
+
2449
+ function fallbackCopy(text, btn) {{
2450
+ const ta = document.createElement('textarea');
2451
+ ta.value = text;
2452
+ ta.style.position = 'fixed';
2453
+ ta.style.opacity = '0';
2454
+ document.body.appendChild(ta);
2455
+ ta.select();
2456
+ try {{
2457
+ document.execCommand('copy');
2458
+ showCopied(btn);
2459
+ }} catch (e) {{}}
2460
+ document.body.removeChild(ta);
2461
+ }}
2462
+
2463
+ function showCopied(btn) {{
2464
+ const orig = btn.innerHTML;
2465
+ btn.innerHTML = '&#10004; Copied!';
2466
+ btn.classList.add('copied');
2467
+ setTimeout(() => {{
2468
+ btn.innerHTML = orig;
2469
+ btn.classList.remove('copied');
2470
+ }}, 2000);
2471
+ }}
2472
+
2473
+ function downloadSarif() {{
2474
+ const sarifData = {{
2475
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
2476
+ "version": "2.1.0",
2477
+ "runs": [{{
2478
+ "tool": {{
2479
+ "driver": {{
2480
+ "name": "TorusGuard",
2481
+ "semanticVersion": "1.3.6",
2482
+ "informationUri": "https://github.com/githubmofo/TorusGuard",
2483
+ "rules": SARIF_RULES
2484
+ }}
2485
+ }},
2486
+ "results": RAW_FINDINGS.map(f => ({{
2487
+ "ruleId": f.rule_id || "TG-GEN",
2488
+ "message": {{ "text": f.description || f.title || "Violation" }},
2489
+ "level": f.severity === "Critical" ? "error" : (f.severity === "High" ? "error" : "warning"),
2490
+ "locations": [{{
2491
+ "physicalLocation": {{
2492
+ "artifactLocation": {{ "uri": f.file_path || "unknown" }},
2493
+ "region": {{ "startLine": f.line_number || 1 }}
2494
+ }}
2495
+ }}]
2496
+ }}))
2497
+ }}]
2498
+ }};
2499
+
2500
+ const blob = new Blob([JSON.stringify(sarifData, null, 2)], {{ type: "application/json" }});
2501
+ const url = URL.createObjectURL(blob);
2502
+ const a = document.createElement("a");
2503
+ a.href = url;
2504
+ a.download = "torusguard-report.sarif";
2505
+ document.body.appendChild(a);
2506
+ a.click();
2507
+ document.body.removeChild(a);
2508
+ URL.revokeObjectURL(url);
2509
+ }}
2510
+
2511
+ function downloadCsv() {{
2512
+ let csv = "Finding ID,Rule ID,Severity,Title,File Path,Line Number,Confidence\\n";
2513
+ RAW_FINDINGS.forEach(f => {{
2514
+ const row = [
2515
+ `"${{(f.finding_id || '').replace(/"/g, '""')}}"`,
2516
+ `"${{(f.rule_id || '').replace(/"/g, '""')}}"`,
2517
+ `"${{(f.severity || '').replace(/"/g, '""')}}"`,
2518
+ `"${{(f.title || '').replace(/"/g, '""')}}"`,
2519
+ `"${{(f.file_path || '').replace(/"/g, '""')}}"`,
2520
+ f.line_number || 0,
2521
+ f.confidence_score || 70
2522
+ ];
2523
+ csv += row.join(",") + "\\n";
2524
+ }});
2525
+
2526
+ const blob = new Blob([csv], {{ type: "text/csv;charset=utf-8;" }});
2527
+ const url = URL.createObjectURL(blob);
2528
+ const a = document.createElement("a");
2529
+ a.href = url;
2530
+ a.download = "torusguard-report.csv";
2531
+ document.body.appendChild(a);
2532
+ a.click();
2533
+ document.body.removeChild(a);
2534
+ URL.revokeObjectURL(url);
2535
+ }}
2536
+ </script>
2537
+ </body>
2538
+ </html>
2539
+ """
2540
+
2541
+
2542
+ def emit_html_report(
2543
+ target_path: str | Path | None = None,
2544
+ root_dir: str | Path | None = None
2545
+ ) -> dict[str, Any]:
2546
+ """Emit the standalone HTML report to disk using atomic swapping with retry."""
2547
+ root = Path(root_dir or find_project_root()).resolve()
2548
+ telemetry = load_report_telemetry(root)
2549
+ html_content = generate_html_dashboard(telemetry)
2550
+ if target_path:
2551
+ out_file = Path(target_path)
2552
+ if not out_file.is_absolute():
2553
+ out_file = (root / out_file).resolve()
2554
+ else:
2555
+ out_file = root / "report.html"
2556
+
2557
+ out_file.parent.mkdir(parents=True, exist_ok=True)
2558
+ try:
2559
+ from report_sync import atomic_write_text
2560
+ atomic_write_text(out_file, html_content)
2561
+ except Exception:
2562
+ out_file.write_text(html_content, encoding="utf-8")
2563
+
2564
+ # Always mirror to .torusguard/runs/report-latest.html
2565
+ latest_run_html = root / ".torusguard" / "runs" / "report-latest.html"
2566
+ if out_file.resolve() != latest_run_html.resolve():
2567
+ try:
2568
+ latest_run_html.parent.mkdir(parents=True, exist_ok=True)
2569
+ from report_sync import atomic_write_text
2570
+ atomic_write_text(latest_run_html, html_content)
2571
+ except Exception:
2572
+ try:
2573
+ latest_run_html.write_text(html_content, encoding="utf-8")
2574
+ except Exception:
2575
+ pass
2576
+
2577
+ # If writing to a non-standard path and a root report.html exists, also keep it synchronized
2578
+ root_report = root / "report.html"
2579
+ if out_file.resolve() != root_report.resolve() and root_report.is_file():
2580
+ try:
2581
+ from report_sync import atomic_write_text
2582
+ atomic_write_text(root_report, html_content)
2583
+ except Exception:
2584
+ try:
2585
+ root_report.write_text(html_content, encoding="utf-8")
2586
+ except Exception:
2587
+ pass
2588
+
2589
+ all_rules = telemetry.get("all_rules", [])
2590
+ safe_rules = telemetry.get("safe_rules", [])
2591
+ violated_rules = telemetry.get("violated_rules", [])
2592
+ findings = telemetry.get("findings", [])
2593
+
2594
+ return {
2595
+ "status": "success",
2596
+ "report_path": str(out_file),
2597
+ "latest_mirror_path": str(latest_run_html),
2598
+ "file_size_bytes": len(html_content.encode("utf-8")),
2599
+ "posture_score": compute_posture_score(telemetry),
2600
+ "total_rules": len(all_rules),
2601
+ "defended_count": len(safe_rules),
2602
+ "harmed_count": len(violated_rules),
2603
+ "critical_count": sum(1 for f in findings if f.get("severity") == "Critical" and not f.get("is_canary")),
2604
+ "high_count": sum(1 for f in findings if f.get("severity") == "High" and not f.get("is_canary")),
2605
+ "medium_count": sum(1 for f in findings if f.get("severity") in ("Medium", "Low") and not f.get("is_canary")),
2606
+ }
2607
+
2608
+
2609
+ def main():
2610
+ parser = argparse.ArgumentParser(description="TorusGuard Visual HTML Report Emitter")
2611
+ parser.add_argument("--out", help="Output HTML file path (default: report.html at project root)")
2612
+ parser.add_argument("--root", help="Project root override")
2613
+ parser.add_argument("--json", action="store_true", help="Output raw JSON summary")
2614
+
2615
+ args = parser.parse_args()
2616
+ root = Path(args.root).resolve() if args.root else None
2617
+
2618
+ res = emit_html_report(target_path=args.out, root_dir=root)
2619
+ if args.json:
2620
+ print(json.dumps(res, indent=2))
2621
+ else:
2622
+ rep_path = Path(res["report_path"]).resolve()
2623
+ file_url = rep_path.as_uri()
2624
+ score = res["posture_score"]
2625
+ score_label = "Optimal Defense" if score >= 90 else ("Hardened" if score >= 75 else ("Degraded" if score >= 50 else "Critical Exposure"))
2626
+ score_color = "\033[32m" if score >= 90 else ("\033[36m" if score >= 75 else ("\033[33m" if score >= 50 else "\033[31m"))
2627
+ reset = "\033[0m"
2628
+ bold = "\033[1m"
2629
+ white = "\033[97m"
2630
+ cyan = "\033[36m"
2631
+ dim = "\033[2m"
2632
+ green = "\033[32m"
2633
+ red = "\033[31m"
2634
+ yellow = "\033[33m"
2635
+
2636
+ try:
2637
+ import term_ui as tui
2638
+ print()
2639
+ print(tui.card_header("🛡️ TORUSGUARD VISUAL SECURITY REPORT", "Single-File Offline Dashboard", "v2.3.0"))
2640
+ print(f"\n {bold}▸ Report URL:{reset} {cyan}{file_url}{reset}")
2641
+ print(f" {bold}▸ Output File:{reset} {white}{rep_path.name}{reset}")
2642
+ print(f" {bold}▸ Living Ledger:{reset} {green}security_report.md{reset}\n")
2643
+
2644
+ print(tui.card_border_top("Executive Posture"))
2645
+ print(tui.format_box_line(f"Posture Score: {bold}{score_color}{score}/100{reset} ({score_label})"))
2646
+ harmed = res.get("harmed_count", 0)
2647
+ defended = res.get("defended_count", 74)
2648
+ total = res.get("total_rules", 74)
2649
+ harmed_color = red if harmed > 0 else green
2650
+ print(tui.format_box_line(f"Invariants: {green}{defended} Defended{reset} · {harmed_color}{harmed} Harmed{reset} ({total} Rules)"))
2651
+ findings_str = f"{red}{res.get('critical_count', 0)} Critical{reset} · {yellow}{res.get('high_count', 0)} High{reset} · {cyan}{res.get('medium_count', 0)} Medium{reset}"
2652
+ print(tui.format_box_line(f"Exposure Status: {findings_str}"))
2653
+ print(tui.format_box_line(f"Dashboard Size: {white}{res['file_size_bytes']} bytes{reset} (Zero CDN, 100% Offline)"))
2654
+ print(tui.card_divider("Interactive Capabilities"))
2655
+ print(tui.format_box_line("• 5-Layer Architectural Invariant Matrix with modal inspection"))
2656
+ print(tui.format_box_line("• Interactive What-If Posture Simulator with live gauge updates"))
2657
+ print(tui.format_box_line("• Enterprise Compliance Framework Mapping (SOC 2, ISO, HIPAA)"))
2658
+ print(tui.format_box_line("• Golden Recipe Explorer with Unified Diff & Before/After views"))
2659
+ print(tui.format_box_line("• Zero-CDN Executive Dark / Light Mode Theme Switcher"))
2660
+ print(tui.card_border_bottom())
2661
+ print(f"\n {dim}Tip: Open {cyan}{file_url}{dim} directly in any browser to inspect.{reset}\n")
2662
+ except Exception:
2663
+ print(f"[SUCCESS] Emitted TorusGuard visual dashboard: {res['report_path']}")
2664
+ print(f"Posture Score: {res['posture_score']}/100 · Size: {res['file_size_bytes']} bytes")
2665
+
2666
+
2667
+ if __name__ == "__main__":
2668
+ main()