mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c7555c24 | ||
|
|
e246745e4e | ||
|
|
9ba8e0f219 | ||
|
|
68f1d19504 | ||
|
|
dd1dab5d95 | ||
|
|
09ff9aa783 | ||
|
|
47ded9b93c | ||
|
|
01c02d6d97 | ||
|
|
4465617134 | ||
|
|
8f4ca2aedc | ||
|
|
de1c36d7e6 | ||
|
|
8264eee2f5 | ||
|
|
2351bf9323 | ||
|
|
10d6136352 | ||
|
|
8b98457041 | ||
|
|
4505ef451c | ||
|
|
05e8848332 | ||
|
|
f3a956f5df | ||
|
|
ea0c107559 | ||
|
|
cfe1073650 | ||
|
|
2a85e27dc0 | ||
|
|
f91a831761 | ||
|
|
5600650e26 | ||
|
|
9d4033d950 | ||
|
|
d73f319be5 | ||
|
|
2cd953b54e | ||
|
|
7710a970b2 | ||
|
|
54e9c0239b | ||
|
|
dfc672c947 | ||
|
|
e32bfcde4a | ||
|
|
a12b9c634c | ||
|
|
4c337f93ba | ||
|
|
8d9f785dc7 | ||
|
|
5dee36d30a | ||
|
|
f9a966df59 | ||
|
|
391df1b38c | ||
|
|
f7cfd0d47d | ||
|
|
98c2e0be4e | ||
|
|
da9c606c67 | ||
|
|
11c7eef082 | ||
|
|
8bbcafcf1a | ||
|
|
1e4db0098b | ||
|
|
1ce37d7b12 | ||
|
|
b41abc9e58 | ||
|
|
9a9b5cc1c2 | ||
|
|
db29c77b30 | ||
|
|
46e4b16167 |
+12
-4
@@ -1,17 +1,25 @@
|
||||
# Node / local-viewer SPA source (the built bundle in
|
||||
# strix/viewer/viewer_dist/ is committed and shipped; do not ignore it)
|
||||
node_modules/
|
||||
strix/viewer_src/node_modules/
|
||||
strix/viewer_src/.vite/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
# Anchored to the repo root: these are Python build-artifact dir names, but
|
||||
# unanchored they also match nested source dirs (e.g. the viewer's src/lib).
|
||||
/build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
@@ -46,7 +54,7 @@ pip-delete-this-directory.txt
|
||||
.env.production.local
|
||||
|
||||
# MongoDB
|
||||
data/
|
||||
/data/
|
||||
mongod.log
|
||||
*.mongodb
|
||||
*.mongorc.js
|
||||
|
||||
@@ -99,6 +99,20 @@ We welcome feature ideas! Please:
|
||||
- Consider implementation approach
|
||||
- Be open to discussion
|
||||
|
||||
## 🖥️ Local viewer SPA
|
||||
|
||||
`strix view` serves a prebuilt web UI whose source lives in `strix/viewer_src/`
|
||||
(a Vite + React project) and whose built output is committed to
|
||||
`strix/viewer/viewer_dist/` and shipped in the package. End users never run a
|
||||
JS build. If you change anything under `strix/viewer_src/`, rebuild and commit
|
||||
the output:
|
||||
|
||||
```bash
|
||||
make viewer # or: cd strix/viewer_src && npm ci && npm run build
|
||||
```
|
||||
|
||||
Commit both the source change and the regenerated `strix/viewer/viewer_dist/`.
|
||||
|
||||
## 🤝 Community
|
||||
|
||||
- **Discord**: [Join our community](https://discord.gg/strix-ai)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev
|
||||
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev viewer
|
||||
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@@ -15,6 +15,7 @@ help:
|
||||
@echo ""
|
||||
@echo "Development:"
|
||||
@echo " pre-commit - Run pre-commit hooks on all files"
|
||||
@echo " viewer - Rebuild the local-viewer SPA (commit the output)"
|
||||
@echo " clean - Clean up cache files and artifacts"
|
||||
|
||||
install:
|
||||
@@ -66,5 +67,10 @@ clean:
|
||||
find . -name "*.pyc" -delete 2>/dev/null || true
|
||||
@echo "✅ Cleanup complete!"
|
||||
|
||||
viewer:
|
||||
@echo "🖥️ Building the local-viewer SPA..."
|
||||
cd strix/viewer_src && npm ci && npm run build
|
||||
@echo "✅ Viewer built to strix/viewer/viewer_dist/ (commit the changes)."
|
||||
|
||||
dev: format lint type-check
|
||||
@echo "✅ Development cycle complete!"
|
||||
|
||||
@@ -44,6 +44,9 @@ dependencies = [
|
||||
"requests>=2.32.0",
|
||||
"cvss>=3.2",
|
||||
"caido-sdk-client>=0.2.0",
|
||||
"reportlab>=4.0",
|
||||
"pypdf>=5.0",
|
||||
"cryptography>=42",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -74,6 +77,10 @@ build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["strix"]
|
||||
# The prebuilt viewer bundle under strix/viewer/viewer_dist/ ships automatically
|
||||
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
|
||||
# under the package dir too but must never ship in the wheel.
|
||||
exclude = ["strix/viewer_src", "strix/viewer_src/**"]
|
||||
|
||||
# ============================================================================
|
||||
# Type Checking Configuration
|
||||
@@ -111,6 +118,8 @@ module = [
|
||||
"docker.*",
|
||||
"caido_sdk_client.*",
|
||||
"pydantic_settings.*",
|
||||
"reportlab.*",
|
||||
"pypdf.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
disable_error_code = ["import-untyped"]
|
||||
@@ -201,6 +210,15 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Test doubles use fixture tokens/passwords and match a callee signature whose
|
||||
# args they intentionally ignore.
|
||||
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
|
||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||
# circular dependency with strix.telemetry / strix.viewer.report_pdf.
|
||||
"strix/viewer/server.py" = ["N802", "PLC0415"]
|
||||
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
|
||||
"strix/viewer/cli.py" = ["PLC0415"]
|
||||
# Lazy imports inside functions to avoid circular dependency with
|
||||
# strix.telemetry / strix.report.dedupe / cvss.
|
||||
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
|
||||
|
||||
+32
-1
@@ -25,6 +25,13 @@ for tcss_file in strix_root.rglob('*.tcss'):
|
||||
rel_path = tcss_file.relative_to(project_root)
|
||||
datas.append((str(tcss_file), str(rel_path.parent)))
|
||||
|
||||
# Prebuilt local-viewer SPA (served by `strix view`).
|
||||
viewer_dist = strix_root / 'viewer' / 'viewer_dist'
|
||||
for asset in viewer_dist.rglob('*'):
|
||||
if asset.is_file():
|
||||
rel_path = asset.relative_to(project_root)
|
||||
datas.append((str(asset), str(rel_path.parent)))
|
||||
|
||||
datas += collect_data_files('textual')
|
||||
|
||||
datas += collect_data_files('tiktoken')
|
||||
@@ -151,6 +158,21 @@ hiddenimports = [
|
||||
'strix.report.dedupe',
|
||||
'strix.report.state',
|
||||
'strix.report.writer',
|
||||
'strix.viewer',
|
||||
'strix.viewer.auth',
|
||||
'strix.viewer.cli',
|
||||
'strix.viewer.report_pdf',
|
||||
'strix.viewer.server',
|
||||
'strix.viewer.transcript',
|
||||
|
||||
# PDF report generation + encryption
|
||||
'reportlab',
|
||||
'reportlab.pdfgen',
|
||||
'reportlab.pdfbase',
|
||||
'reportlab.lib',
|
||||
'reportlab.platypus',
|
||||
'pypdf',
|
||||
'cryptography',
|
||||
'strix.runtime',
|
||||
'strix.runtime.backends',
|
||||
'strix.runtime.caido_bootstrap',
|
||||
@@ -178,6 +200,16 @@ hiddenimports += collect_submodules('textual')
|
||||
hiddenimports += collect_submodules('rich')
|
||||
hiddenimports += collect_submodules('pydantic')
|
||||
hiddenimports += collect_submodules('pygments')
|
||||
# reportlab loads renderers/fonts dynamically, so pull its whole tree in.
|
||||
hiddenimports += collect_submodules('reportlab')
|
||||
|
||||
# reportlab ships bundled fonts (.pfb/.afm) it needs at runtime.
|
||||
datas += collect_data_files('reportlab')
|
||||
|
||||
# reportlab imports PIL (pillow) lazily for image handling, so it must be
|
||||
# bundled explicitly and kept out of the excludes list below.
|
||||
hiddenimports += collect_submodules('PIL')
|
||||
datas += collect_data_files('PIL')
|
||||
|
||||
excludes = [
|
||||
# Sandbox-only packages
|
||||
@@ -225,7 +257,6 @@ excludes = [
|
||||
'numpy',
|
||||
'pandas',
|
||||
'scipy',
|
||||
'PIL',
|
||||
'cv2',
|
||||
]
|
||||
|
||||
|
||||
@@ -72,6 +72,15 @@ class IntegrationSettings(BaseSettings):
|
||||
perplexity_api_key: str | None = Field(default=None, alias="PERPLEXITY_API_KEY")
|
||||
|
||||
|
||||
class ViewerSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
# Base URL of the Strix relay the local viewer proxies to for email
|
||||
# verification and encrypted report delivery. The browser never talks to
|
||||
# the relay directly; the local server is the only caller.
|
||||
app_url: str = Field(default="https://app.strix.ai", alias="STRIX_APP_URL")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
@@ -79,3 +88,4 @@ class Settings(BaseSettings):
|
||||
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|
||||
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
|
||||
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
|
||||
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
|
||||
|
||||
@@ -21,3 +21,20 @@ def runtime_state_dir(run_dir: Path) -> Path:
|
||||
|
||||
def run_record_path(run_dir: Path) -> Path:
|
||||
return run_dir / RUN_RECORD_FILENAME
|
||||
|
||||
|
||||
def runs_base_dir(*, cwd: Path | None = None) -> Path:
|
||||
base = cwd or Path.cwd()
|
||||
return base / RUNS_DIR_NAME
|
||||
|
||||
|
||||
def latest_run_dir(*, cwd: Path | None = None) -> Path | None:
|
||||
base = runs_base_dir(cwd=cwd)
|
||||
if not base.is_dir():
|
||||
return None
|
||||
candidates = [child for child in base.iterdir() if run_record_path(child).is_file()]
|
||||
if not candidates:
|
||||
return None
|
||||
# run.json is rewritten on status/end changes, so its mtime tracks activity
|
||||
# more reliably than the directory mtime (a live run sorts to the top).
|
||||
return max(candidates, key=lambda child: run_record_path(child).stat().st_mtime)
|
||||
|
||||
@@ -67,6 +67,16 @@ Toast.-information .toast--title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#viewer_cta {
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: round #333333;
|
||||
color: #60a5fa;
|
||||
padding: 0 1;
|
||||
margin-bottom: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#agents_tree {
|
||||
height: 1fr;
|
||||
background: transparent;
|
||||
|
||||
+69
-2
@@ -7,6 +7,7 @@ import argparse
|
||||
import asyncio
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -720,7 +721,9 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
args.scan_mode = persisted_scan_mode
|
||||
|
||||
|
||||
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
|
||||
def display_completion_message(
|
||||
args: argparse.Namespace, results_path: Path, web_url: str | None = None
|
||||
) -> None:
|
||||
console = Console()
|
||||
report_state = get_global_report_state()
|
||||
|
||||
@@ -759,6 +762,29 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
||||
results_text.append(str(results_path), style="#60a5fa")
|
||||
panel_parts.extend(["\n", results_text])
|
||||
|
||||
if web_url:
|
||||
web_text = Text()
|
||||
web_text.append("\n")
|
||||
web_text.append("View in web", style="dim")
|
||||
web_text.append(" ")
|
||||
# OSC-8 hyperlink: clickable in modern terminals, falls back to the URL.
|
||||
web_text.append(web_url, style=f"#60a5fa link {web_url}")
|
||||
panel_parts.extend(["\n", web_text])
|
||||
|
||||
reopen_text = Text()
|
||||
reopen_text.append("\n")
|
||||
reopen_text.append("Reopen", style="dim")
|
||||
reopen_text.append(" ")
|
||||
reopen_text.append(f"strix view {args.run_name}", style="#22c55e")
|
||||
panel_parts.extend(["\n", reopen_text])
|
||||
else:
|
||||
view_text = Text()
|
||||
view_text.append("\n")
|
||||
view_text.append("View", style="dim")
|
||||
view_text.append(" ")
|
||||
view_text.append(f"strix view {args.run_name}", style="#22c55e")
|
||||
panel_parts.extend(["\n", view_text])
|
||||
|
||||
if not scan_completed:
|
||||
resume_text = Text()
|
||||
resume_text.append("\n")
|
||||
@@ -846,6 +872,14 @@ def main() -> None:
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the
|
||||
# scan argument parser (which requires a target) and before any scan setup.
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "view":
|
||||
from strix.viewer.cli import run_view
|
||||
|
||||
run_view(sys.argv[2:])
|
||||
return
|
||||
|
||||
args = parse_arguments()
|
||||
|
||||
if args.config:
|
||||
@@ -940,7 +974,40 @@ def main() -> None:
|
||||
scarf.end(report_state, exit_reason=exit_reason)
|
||||
|
||||
results_path = run_dir_for(args.run_name)
|
||||
display_completion_message(args, results_path)
|
||||
|
||||
# For an interactive run, host the local viewer so the completion panel can
|
||||
# show a clickable "View in web" link. Skipped in non-interactive/CI runs
|
||||
# (no TTY to serve and it would block the process).
|
||||
viewer_httpd = None
|
||||
web_url = None
|
||||
if not args.non_interactive and sys.stdout.isatty():
|
||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
||||
|
||||
if bundle_is_built():
|
||||
try:
|
||||
viewer_httpd, base_url, token = serve(results_path, open_browser=False)
|
||||
# The completion panel's "View in web" link must authorize the
|
||||
# browser, so hand it the tokened URL rather than the bare host.
|
||||
web_url = authorized_url(base_url, token)
|
||||
posthog.viewer_opened(source="post_scan", live=False)
|
||||
except Exception:
|
||||
logger.debug("could not start local viewer", exc_info=True)
|
||||
viewer_httpd, web_url = None, None
|
||||
|
||||
display_completion_message(args, results_path, web_url=web_url)
|
||||
|
||||
if viewer_httpd is not None:
|
||||
console = Console()
|
||||
console.print("[dim]Hosting the local viewer. Press Ctrl-C to stop.[/]")
|
||||
console.print()
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Viewer stopped.[/]")
|
||||
finally:
|
||||
viewer_httpd.shutdown()
|
||||
viewer_httpd.server_close()
|
||||
|
||||
if args.non_interactive:
|
||||
report_state = get_global_report_state()
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version as pkg_version
|
||||
@@ -768,6 +769,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
Binding("ctrl+q", "request_quit", "Quit", priority=True),
|
||||
Binding("ctrl+c", "request_quit", "Quit", priority=True),
|
||||
Binding("escape", "stop_selected_agent", "Stop Agent", priority=True),
|
||||
Binding("ctrl+o", "open_viewer", "Open Viewer", priority=True),
|
||||
]
|
||||
|
||||
def __init__(self, args: argparse.Namespace):
|
||||
@@ -794,6 +796,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self._displayed_events: list[str] = []
|
||||
|
||||
self._scan_thread: threading.Thread | None = None
|
||||
self._viewer_httpd: Any = None
|
||||
self._viewer_url: str | None = None
|
||||
self._scan_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._scan_stop_event = threading.Event()
|
||||
self._scan_completed = threading.Event()
|
||||
@@ -903,7 +907,12 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel")
|
||||
|
||||
sidebar = Vertical(agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar")
|
||||
viewer_cta = Static(self._viewer_cta_markup(), id="viewer_cta")
|
||||
viewer_cta.ALLOW_SELECT = False
|
||||
|
||||
sidebar = Vertical(
|
||||
viewer_cta, agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar"
|
||||
)
|
||||
|
||||
content_container.mount(chat_area_container)
|
||||
content_container.mount(sidebar)
|
||||
@@ -1805,6 +1814,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
async def action_custom_quit(self) -> None:
|
||||
self._fire_sandbox_cleanup()
|
||||
self._shutdown_viewer()
|
||||
|
||||
if self._scan_thread and self._scan_thread.is_alive():
|
||||
self._scan_stop_event.set()
|
||||
@@ -1813,6 +1823,70 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
self.exit()
|
||||
|
||||
def _viewer_cta_markup(self, url: str | None = None) -> str:
|
||||
if url:
|
||||
return f"[@click=app.open_viewer][#22c55e]● Viewer running[/][/]\n[dim]{url}[/]"
|
||||
return "[@click=app.open_viewer]▶ Watch live in browser[/]"
|
||||
|
||||
def _set_viewer_cta(self, markup: str) -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
self.query_one("#viewer_cta", Static).update(markup)
|
||||
|
||||
def action_open_viewer(self) -> None:
|
||||
if self._viewer_url:
|
||||
with contextlib.suppress(Exception):
|
||||
webbrowser.open(self._viewer_url)
|
||||
return
|
||||
try:
|
||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
||||
|
||||
if not bundle_is_built():
|
||||
self._set_viewer_cta("[#eab308]Viewer UI not built[/]")
|
||||
return
|
||||
run_dir = self.report_state.get_run_dir()
|
||||
|
||||
def _viewer_steer(agent_id: str, message: str) -> bool:
|
||||
# Reuse the exact TUI delivery path, but target the agent the
|
||||
# web graph selected (not the TUI's current selection).
|
||||
return send_user_message_to_agent(
|
||||
coordinator=self.coordinator,
|
||||
loop=self._scan_loop,
|
||||
live_view=self.live_view,
|
||||
target_agent_id=agent_id,
|
||||
message=message,
|
||||
)
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=True, steer_handler=_viewer_steer)
|
||||
except Exception:
|
||||
logger.debug("failed to start local viewer", exc_info=True)
|
||||
self._set_viewer_cta("[red]Viewer failed to start[/]")
|
||||
return
|
||||
self._viewer_httpd = httpd
|
||||
# Store the tokened URL so reopening the CTA re-authorizes the browser
|
||||
# (this viewer carries a steer handler, so the session is required).
|
||||
self._viewer_url = authorized_url(url, token)
|
||||
self._set_viewer_cta(self._viewer_cta_markup(self._viewer_url))
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
from strix.telemetry import posthog
|
||||
|
||||
live = self.report_state.run_record.get("status") not in {
|
||||
"completed",
|
||||
"stopped",
|
||||
"failed",
|
||||
"interrupted",
|
||||
}
|
||||
posthog.viewer_opened(source="tui", live=live)
|
||||
|
||||
def _shutdown_viewer(self) -> None:
|
||||
httpd = self._viewer_httpd
|
||||
if httpd is None:
|
||||
return
|
||||
self._viewer_httpd = None
|
||||
with contextlib.suppress(Exception):
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
def _fire_sandbox_cleanup(self) -> None:
|
||||
self.coordinator.mark_shutting_down()
|
||||
loop = self._scan_loop
|
||||
|
||||
@@ -24,14 +24,26 @@ def load_session_history(run_dir: Path, agent_ids: Any) -> list[tuple[str, dict[
|
||||
if not agents_db.exists() or not session_ids:
|
||||
return []
|
||||
session_id_set = set(session_ids)
|
||||
# Open read-only: the scan process may be actively writing this WAL database
|
||||
# from another process (the local viewer tails it live), and a reader must
|
||||
# never lock or mutate it. mode=ro (not immutable=1) still reads the latest
|
||||
# committed WAL state; WAL permits concurrent readers alongside the writer.
|
||||
conn: sqlite3.Connection | None = None
|
||||
try:
|
||||
with sqlite3.connect(agents_db) as conn:
|
||||
rows = conn.execute(
|
||||
"select id, session_id, message_data, created_at from agent_messages order by id"
|
||||
).fetchall()
|
||||
conn = sqlite3.connect(
|
||||
f"file:{agents_db}?mode=ro",
|
||||
uri=True,
|
||||
check_same_thread=False,
|
||||
)
|
||||
rows = conn.execute(
|
||||
"select id, session_id, message_data, created_at from agent_messages order by id"
|
||||
).fetchall()
|
||||
except sqlite3.Error:
|
||||
logger.exception("Failed to hydrate TUI history from %s", agents_db)
|
||||
return []
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
items: list[tuple[str, dict[str, Any], str]] = []
|
||||
for row_id, agent_id, message_data, created_at in rows:
|
||||
|
||||
@@ -142,6 +142,44 @@ def end(report_state: "ReportState", exit_reason: str = "completed") -> None:
|
||||
)
|
||||
|
||||
|
||||
def viewer_opened(source: str, live: bool) -> None:
|
||||
_send(
|
||||
"viewer_opened",
|
||||
{
|
||||
**base_props(),
|
||||
"source": source,
|
||||
"live": live,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def viewer_cta_clicked(cta: str, surface: str | None = None) -> None:
|
||||
props = {
|
||||
**base_props(),
|
||||
"cta": cta[:64],
|
||||
}
|
||||
if surface:
|
||||
props["surface"] = surface[:64]
|
||||
_send("viewer_cta_clicked", props)
|
||||
|
||||
|
||||
_VIEWER_EMAIL_STEPS = frozenset(
|
||||
{"email_submitted", "email_verified", "report_sent", "work_email_required"}
|
||||
)
|
||||
|
||||
|
||||
def viewer_email_event(step: str, purpose: str | None = None) -> None:
|
||||
if step not in _VIEWER_EMAIL_STEPS:
|
||||
return
|
||||
_send(
|
||||
f"viewer_{step}",
|
||||
{
|
||||
**base_props(),
|
||||
**({"purpose": purpose} if purpose else {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def error(error_type: str) -> None:
|
||||
props = {**base_props(), "error_type": error_type}
|
||||
_send("error", props)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Local web viewer for Strix runs.
|
||||
|
||||
Serves a prebuilt single-page app that renders a run (live or finished) read
|
||||
directly from the run's on-disk files. No cloud dependency, no file picker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from strix.viewer.server import serve
|
||||
|
||||
|
||||
__all__ = ["serve"]
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Viewer email verification state and the relay client.
|
||||
|
||||
The local viewer proxies email verification and encrypted-report delivery to
|
||||
the Strix relay (``STRIX_APP_URL``). The browser never talks to the relay
|
||||
directly, and the report password generated locally is never sent to it.
|
||||
|
||||
State lives in ``~/.strix/viewer-auth.json`` (0600). ``is_verified`` is a local
|
||||
flag that unlocks browsing the run history list; the relay still enforces token
|
||||
expiry when a report is actually sent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from strix.config.loader import load_settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUTH_PATH = Path.home() / ".strix" / "viewer-auth.json"
|
||||
|
||||
_OTP_TIMEOUT = 15
|
||||
_SEND_TIMEOUT = 30
|
||||
|
||||
|
||||
class RelayError(Exception):
|
||||
"""A relay call failed. ``code`` is a stable, machine-readable reason."""
|
||||
|
||||
def __init__(self, code: str, message: str | None = None) -> None:
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
# --- local state ------------------------------------------------------------
|
||||
|
||||
|
||||
def read_auth() -> dict[str, Any] | None:
|
||||
"""Return the stored ``{email, token, verified_at}`` record, or None."""
|
||||
try:
|
||||
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
email = data.get("email")
|
||||
token = data.get("token")
|
||||
if not isinstance(email, str) or not email or not isinstance(token, str) or not token:
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _expiry(record: dict[str, Any]) -> datetime | None:
|
||||
"""Parse ``verified_at`` (the relay's ``expires_at``) into an aware UTC datetime.
|
||||
|
||||
Returns None when it is absent or unparseable, in which case expiry cannot be
|
||||
enforced locally (the relay still rejects an expired token on report send).
|
||||
"""
|
||||
raw = record.get("verified_at")
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def is_verified() -> bool:
|
||||
"""True when a usable, unexpired email + token record exists locally.
|
||||
|
||||
The expiry returned by OTP verification is enforced here so history stops
|
||||
unlocking once the token lapses, keeping the local gate in step with the
|
||||
relay (which rejects an expired token on report send).
|
||||
"""
|
||||
record = read_auth()
|
||||
if record is None:
|
||||
return False
|
||||
expiry = _expiry(record)
|
||||
return expiry is None or expiry > datetime.now(UTC)
|
||||
|
||||
|
||||
def write_auth(email: str, token: str, verified_at: str) -> None:
|
||||
"""Atomically persist the auth record with 0600 permissions."""
|
||||
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = json.dumps({"email": email, "token": token, "verified_at": verified_at})
|
||||
tmp = AUTH_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(payload, encoding="utf-8")
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.chmod(0o600)
|
||||
tmp.replace(AUTH_PATH)
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.chmod(0o600)
|
||||
|
||||
|
||||
def forget() -> None:
|
||||
"""Delete the stored auth record. No-op if it is absent."""
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.unlink()
|
||||
|
||||
|
||||
# --- relay client -----------------------------------------------------------
|
||||
|
||||
|
||||
def _app_url() -> str:
|
||||
return load_settings().viewer.app_url.rstrip("/")
|
||||
|
||||
|
||||
def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int, dict[str, Any]]:
|
||||
"""POST JSON to the relay. Returns (status, parsed body).
|
||||
|
||||
Raises RelayError("unavailable") for network/transport failures. HTTP
|
||||
error responses (4xx/5xx) are returned as (status, body) for the caller to
|
||||
map, not raised.
|
||||
"""
|
||||
url = f"{_app_url()}{path}"
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request( # noqa: S310 - fixed https relay URL
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
||||
return response.status, _parse_body(response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, _parse_body(exc.read())
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
logger.warning("relay request to %s failed: %s", path, exc)
|
||||
raise RelayError("unavailable") from exc
|
||||
|
||||
|
||||
def _parse_body(raw: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(raw or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def otp_start(email: str) -> None:
|
||||
"""Ask the relay to email a verification code. Raises RelayError on failure."""
|
||||
status, data = _post_json("/api/oss/otp/start", {"email": email}, timeout=_OTP_TIMEOUT)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 429:
|
||||
raise RelayError("rate_limited")
|
||||
if status == 400:
|
||||
# The relay uses 400 both for a malformed address and, separately, to
|
||||
# reject a free/personal email domain (it wants a work email).
|
||||
if data.get("error") == "work_email_required":
|
||||
raise RelayError("work_email_required")
|
||||
raise RelayError("invalid_email")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def otp_verify(email: str, code: str) -> dict[str, Any]:
|
||||
"""Verify a code. Returns ``{token, email, expires_at}`` or raises RelayError."""
|
||||
status, data = _post_json(
|
||||
"/api/oss/otp/verify",
|
||||
{"email": email, "code": code},
|
||||
timeout=_OTP_TIMEOUT,
|
||||
)
|
||||
if status == 200 and isinstance(data.get("token"), str):
|
||||
return data
|
||||
if status == 403:
|
||||
raise RelayError("invalid_code")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def report_send(
|
||||
token: str,
|
||||
pdf_bytes: bytes,
|
||||
filename: str,
|
||||
run_name: str,
|
||||
target: str,
|
||||
) -> None:
|
||||
"""Forward the encrypted PDF to the relay for delivery.
|
||||
|
||||
The report password is NEVER part of this payload; only the encrypted PDF
|
||||
bytes travel to the relay.
|
||||
"""
|
||||
payload = {
|
||||
"token": token,
|
||||
"pdf_base64": base64.b64encode(pdf_bytes).decode("ascii"),
|
||||
"filename": filename,
|
||||
"run_name": run_name,
|
||||
"target": target,
|
||||
}
|
||||
status, _ = _post_json("/api/oss/report/send", payload, timeout=_SEND_TIMEOUT)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 401:
|
||||
raise RelayError("reverify")
|
||||
if status == 413:
|
||||
raise RelayError("too_large")
|
||||
if status == 403:
|
||||
raise RelayError("forbidden")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AUTH_PATH",
|
||||
"RelayError",
|
||||
"forget",
|
||||
"is_verified",
|
||||
"otp_start",
|
||||
"otp_verify",
|
||||
"read_auth",
|
||||
"report_send",
|
||||
"write_auth",
|
||||
]
|
||||
@@ -0,0 +1,139 @@
|
||||
"""`strix view [<run>]` command: serve a run's viewer UI locally."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from strix.core.paths import (
|
||||
RUNS_DIR_NAME,
|
||||
latest_run_dir,
|
||||
run_dir_for,
|
||||
run_record_path,
|
||||
runs_base_dir,
|
||||
)
|
||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
||||
from strix.viewer.transcript import read_run_summary
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_view(argv: list[str]) -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="strix view",
|
||||
description="Open a local web view of a Strix run (live or finished).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"run",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help=f"Run name under ./{RUNS_DIR_NAME} (defaults to the most recent run).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Port to serve on (default: an available ephemeral port).",
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1", help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--no-open",
|
||||
action="store_true",
|
||||
help="Do not open the browser automatically.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
console = Console()
|
||||
|
||||
if not bundle_is_built():
|
||||
console.print(
|
||||
"[bold red]Viewer UI is not built.[/]\n"
|
||||
"Build it with: [cyan]cd strix/viewer_src && npm ci && npm run build[/]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
run_dir = _resolve_run_dir(args.run, console)
|
||||
|
||||
httpd, url, token = serve(
|
||||
run_dir,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
open_browser=not args.no_open,
|
||||
)
|
||||
# The tokened URL is what authorizes the browser (steering, report sending,
|
||||
# history). Print it rather than the bare URL so the operator -- and only
|
||||
# the operator -- can open or share an authorized link.
|
||||
open_url = authorized_url(url, token)
|
||||
|
||||
run_name = run_dir.name
|
||||
summary = read_run_summary(run_dir)
|
||||
live = not summary.get("finished", False)
|
||||
|
||||
from strix.telemetry import posthog
|
||||
|
||||
posthog.viewer_opened(source="cli", live=live)
|
||||
|
||||
state_label = "[#eab308]live[/]" if live else "[#22c55e]finished[/]"
|
||||
console.print()
|
||||
console.print(f"Serving [bold white]{run_name}[/] ({state_label}) at [#60a5fa]{open_url}[/]")
|
||||
console.print("[dim]This link authorizes the browser; anyone you share it with can steer[/]")
|
||||
console.print("[dim]a live scan and browse history. Press Ctrl-C to stop the viewer.[/]")
|
||||
console.print()
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1.0)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Viewer stopped.[/]")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def _resolve_run_dir(run: str | None, console: Console) -> Path:
|
||||
if run:
|
||||
run_dir = run_dir_for(run)
|
||||
if not run_record_path(run_dir).is_file():
|
||||
_fail_no_run(console, requested=run)
|
||||
return run_dir
|
||||
|
||||
latest = latest_run_dir()
|
||||
if latest is None:
|
||||
_fail_no_run(console, requested=None)
|
||||
return latest
|
||||
|
||||
|
||||
def _fail_no_run(console: Console, *, requested: str | None) -> NoReturn:
|
||||
base = runs_base_dir()
|
||||
available = (
|
||||
sorted(
|
||||
(child.name for child in base.iterdir() if run_record_path(child).is_file()),
|
||||
reverse=True,
|
||||
)
|
||||
if base.is_dir()
|
||||
else []
|
||||
)
|
||||
|
||||
if requested:
|
||||
console.print(f"[bold red]No run named '{requested}' under ./{RUNS_DIR_NAME}.[/]")
|
||||
else:
|
||||
console.print(f"[bold red]No runs found under ./{RUNS_DIR_NAME}.[/]")
|
||||
|
||||
if available:
|
||||
console.print("Available runs:")
|
||||
for name in available[:20]:
|
||||
console.print(f" [cyan]{name}[/]")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
__all__ = ["run_view"]
|
||||
@@ -0,0 +1,618 @@
|
||||
"""Build and encrypt a branded PDF report for a run.
|
||||
|
||||
The layout mirrors the Strix cloud pentest report (cover page, executive
|
||||
severity grid, per-finding detail with colored severity badges) but is rendered
|
||||
entirely locally with reportlab, so it ships without a browser or heavy system
|
||||
deps and keeps the report on the user's machine.
|
||||
|
||||
The PDF carries FULL finding detail, including proof-of-concept scripts, so it
|
||||
is encrypted end to end with AES-256. The password is generated locally with a
|
||||
CSPRNG, shown only to the local browser, and never leaves the machine except in
|
||||
the user's own hands. Strix cannot read the delivered report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfgen import canvas as pdfcanvas
|
||||
from reportlab.platypus import (
|
||||
Flowable,
|
||||
KeepTogether,
|
||||
PageBreak,
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
from strix.viewer.transcript import (
|
||||
primary_target,
|
||||
read_run_summary,
|
||||
read_vulnerabilities,
|
||||
severity_counts,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Palette lifted from the cloud report theme (styles/base.ts, docx/theme.ts).
|
||||
_INK = colors.HexColor("#000000")
|
||||
_TEXT = colors.HexColor("#1a1a1a")
|
||||
_MUTED = colors.HexColor("#666666")
|
||||
_FAINT = colors.HexColor("#999999")
|
||||
_BORDER = colors.HexColor("#e5e5e5")
|
||||
_LIGHT_BG = colors.HexColor("#f7f7f7")
|
||||
|
||||
_SEVERITY_ORDER = ("critical", "high", "medium", "low")
|
||||
_SEVERITY_COLORS = {
|
||||
"critical": colors.HexColor("#dc2626"),
|
||||
"high": colors.HexColor("#ea580c"),
|
||||
"medium": colors.HexColor("#ca8a04"),
|
||||
"low": colors.HexColor("#2563eb"),
|
||||
}
|
||||
|
||||
# Helvetica stands in for Geist: a clean sans with no font file to ship.
|
||||
_SANS = "Helvetica"
|
||||
_SANS_BOLD = "Helvetica-Bold"
|
||||
_MONO = "Courier"
|
||||
|
||||
_PAGE_W, _PAGE_H = A4
|
||||
|
||||
|
||||
def _esc(value: Any) -> str:
|
||||
"""Escape a value for reportlab's Paragraph markup."""
|
||||
return html.escape(str(value)).replace("\n", "<br/>")
|
||||
|
||||
|
||||
class _NumberedCanvas(pdfcanvas.Canvas): # type: ignore[misc] # reportlab base is untyped
|
||||
"""Two-pass canvas that prints 'Page X of Y' on every page after the cover."""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._saved_states: list[dict[str, Any]] = []
|
||||
|
||||
def showPage(self) -> None: # noqa: N802 - reportlab API
|
||||
self._saved_states.append(dict(self.__dict__))
|
||||
self._startPage()
|
||||
|
||||
def save(self) -> None:
|
||||
total = len(self._saved_states)
|
||||
for index, state in enumerate(self._saved_states):
|
||||
self.__dict__.update(state)
|
||||
if index > 0: # skip the cover page
|
||||
self._draw_footer(index + 1, total)
|
||||
super().showPage()
|
||||
super().save()
|
||||
|
||||
def _draw_footer(self, page: int, total: int) -> None:
|
||||
self.setFont(_SANS, 8)
|
||||
self.setFillColor(_FAINT)
|
||||
self.drawCentredString(_PAGE_W / 2, 14 * mm, f"Page {page} of {total}")
|
||||
|
||||
|
||||
class _LogoMark(Flowable): # type: ignore[misc] # reportlab base is untyped
|
||||
"""The rounded-square Strix mark drawn inline (no raster asset to ship)."""
|
||||
|
||||
def __init__(self, size: float = 30) -> None:
|
||||
super().__init__()
|
||||
self.size = size
|
||||
self.width = size
|
||||
self.height = size
|
||||
|
||||
def draw(self) -> None:
|
||||
c = self.canv
|
||||
s = self.size
|
||||
c.setFillColor(_INK)
|
||||
c.roundRect(0, 0, s, s, s * 0.28, fill=1, stroke=0)
|
||||
c.setFillColor(colors.white)
|
||||
c.setFont(_SANS_BOLD, s * 0.56)
|
||||
c.drawCentredString(s / 2, s * 0.27, "S")
|
||||
|
||||
|
||||
def _styles() -> dict[str, ParagraphStyle]:
|
||||
styles: dict[str, ParagraphStyle] = {}
|
||||
styles["wordmark"] = ParagraphStyle(
|
||||
"Wordmark", fontName=_SANS_BOLD, fontSize=17, leading=20, textColor=_INK
|
||||
)
|
||||
styles["badge_label"] = ParagraphStyle(
|
||||
"BadgeLabel", fontName=_SANS_BOLD, fontSize=9, leading=12, textColor=_MUTED
|
||||
)
|
||||
styles["cover_title"] = ParagraphStyle(
|
||||
"CoverTitle", fontName=_SANS_BOLD, fontSize=34, leading=38, textColor=_INK
|
||||
)
|
||||
styles["cover_org"] = ParagraphStyle(
|
||||
"CoverOrg", fontName=_SANS, fontSize=13, leading=18, textColor=_MUTED
|
||||
)
|
||||
styles["meta_label"] = ParagraphStyle(
|
||||
"MetaLabel", fontName=_SANS_BOLD, fontSize=8, leading=12, textColor=_MUTED
|
||||
)
|
||||
styles["meta_value"] = ParagraphStyle(
|
||||
"MetaValue", fontName=_SANS, fontSize=10.5, leading=14, textColor=_TEXT
|
||||
)
|
||||
styles["section"] = ParagraphStyle(
|
||||
"Section", fontName=_SANS_BOLD, fontSize=18, leading=22, textColor=_INK, spaceAfter=6
|
||||
)
|
||||
styles["finding"] = ParagraphStyle(
|
||||
"Finding", fontName=_SANS_BOLD, fontSize=13, leading=17, textColor=_INK, spaceBefore=6
|
||||
)
|
||||
styles["field_label"] = ParagraphStyle(
|
||||
"FieldLabel", fontName=_SANS_BOLD, fontSize=8.5, leading=12, textColor=_MUTED,
|
||||
spaceBefore=10, spaceAfter=2,
|
||||
)
|
||||
styles["body"] = ParagraphStyle(
|
||||
"Body", fontName=_SANS, fontSize=10, leading=15, textColor=_TEXT, spaceAfter=8
|
||||
)
|
||||
styles["md_heading"] = ParagraphStyle(
|
||||
"MdHeading", fontName=_SANS_BOLD, fontSize=11, leading=15, textColor=_INK,
|
||||
spaceBefore=10, spaceAfter=4,
|
||||
)
|
||||
styles["bullet"] = ParagraphStyle(
|
||||
"Bullet", fontName=_SANS, fontSize=10, leading=15, textColor=_TEXT,
|
||||
leftIndent=16, firstLineIndent=-11, spaceAfter=3,
|
||||
)
|
||||
styles["meta_inline"] = ParagraphStyle(
|
||||
"MetaInline", fontName=_SANS, fontSize=9, leading=13, textColor=_MUTED, spaceBefore=4
|
||||
)
|
||||
# spaceBefore/spaceAfter must exceed borderPadding: reportlab does not reserve
|
||||
# a bordered paragraph's top padding, so too small a gap lets the background
|
||||
# box bleed up over the field label above it.
|
||||
styles["code"] = ParagraphStyle(
|
||||
"Code", fontName=_MONO, fontSize=8, leading=11, textColor=_TEXT,
|
||||
backColor=_LIGHT_BG, borderColor=_BORDER, borderWidth=0.5, borderPadding=8,
|
||||
spaceBefore=12, spaceAfter=12,
|
||||
)
|
||||
styles["count"] = ParagraphStyle(
|
||||
"Count", fontName=_SANS_BOLD, fontSize=30, leading=32, alignment=TA_CENTER
|
||||
)
|
||||
styles["count_label"] = ParagraphStyle(
|
||||
"CountLabel", fontName=_SANS_BOLD, fontSize=8, leading=12, textColor=_MUTED,
|
||||
alignment=TA_CENTER, spaceBefore=4,
|
||||
)
|
||||
styles["badge"] = ParagraphStyle(
|
||||
"Badge", fontName=_SANS_BOLD, fontSize=9, leading=11, textColor=colors.white,
|
||||
alignment=TA_CENTER,
|
||||
)
|
||||
styles["confidential"] = ParagraphStyle(
|
||||
"Confidential", fontName=_SANS_BOLD, fontSize=9, leading=12, textColor=colors.white,
|
||||
alignment=TA_CENTER,
|
||||
)
|
||||
return styles
|
||||
|
||||
|
||||
def _parse_time(raw: Any) -> datetime | None:
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
text = raw.strip().replace(" UTC", "Z").replace(" ", "T")
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
return datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _fmt_time(raw: Any) -> str:
|
||||
parsed = _parse_time(raw)
|
||||
return parsed.strftime("%Y-%m-%d %H:%M UTC") if parsed else "n/a"
|
||||
|
||||
|
||||
def _duration(start: Any, end: Any) -> str:
|
||||
start_dt = _parse_time(start)
|
||||
end_dt = _parse_time(end)
|
||||
if not start_dt or not end_dt:
|
||||
return "n/a"
|
||||
seconds = int((end_dt - start_dt).total_seconds())
|
||||
if seconds < 0:
|
||||
return "n/a"
|
||||
hours, remainder = divmod(seconds, 3600)
|
||||
minutes, secs = divmod(remainder, 60)
|
||||
if hours:
|
||||
return f"{hours}h {minutes}m {secs}s"
|
||||
if minutes:
|
||||
return f"{minutes}m {secs}s"
|
||||
return f"{secs}s"
|
||||
|
||||
|
||||
def _severity_badge(styles: dict[str, ParagraphStyle], severity: str) -> Table:
|
||||
"""A colored pill matching .severity-badge in the cloud report."""
|
||||
color = _SEVERITY_COLORS.get(severity, _MUTED)
|
||||
cell = Paragraph(severity.upper(), styles["badge"])
|
||||
table = Table([[cell]], colWidths=[len(severity) * 6.5 + 20])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, -1), color),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
]
|
||||
)
|
||||
)
|
||||
table.hAlign = "LEFT"
|
||||
return table
|
||||
|
||||
|
||||
def _severity_grid(styles: dict[str, ParagraphStyle], counts: dict[str, int]) -> Table:
|
||||
"""The four-card severity grid from the executive summary."""
|
||||
cells: list[list[Flowable]] = []
|
||||
for name in _SEVERITY_ORDER:
|
||||
color = _SEVERITY_COLORS[name]
|
||||
count_style = ParagraphStyle(f"Count{name}", parent=styles["count"], textColor=color)
|
||||
cells.append(
|
||||
[Paragraph(str(counts.get(name, 0)), count_style),
|
||||
Paragraph(name.upper(), styles["count_label"])]
|
||||
)
|
||||
col = (_PAGE_W - 40 * mm) / 4
|
||||
table = Table([cells], colWidths=[col] * 4)
|
||||
style = [
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 16),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 16),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, _BORDER),
|
||||
]
|
||||
for index, name in enumerate(_SEVERITY_ORDER):
|
||||
style.append(("LINEABOVE", (index, 0), (index, 0), 3, _SEVERITY_COLORS[name]))
|
||||
table.setStyle(TableStyle(style))
|
||||
return table
|
||||
|
||||
|
||||
def _section(styles: dict[str, ParagraphStyle], title: str) -> Table:
|
||||
"""Section title with the underline rule from h2.section-title."""
|
||||
table = Table([[Paragraph(_esc(title), styles["section"])]], colWidths=[_PAGE_W - 40 * mm])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("LINEBELOW", (0, 0), (-1, -1), 1, _BORDER),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 0),
|
||||
]
|
||||
)
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def _cover(
|
||||
styles: dict[str, ParagraphStyle], record: dict[str, Any], run_name: str
|
||||
) -> list[Flowable]:
|
||||
header = Table(
|
||||
[[_LogoMark(30), Paragraph("Strix", styles["wordmark"])]],
|
||||
colWidths=[38, _PAGE_W - 40 * mm - 38],
|
||||
)
|
||||
header.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 0),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
target = primary_target(record) or "Target"
|
||||
meta_rows = [
|
||||
("TARGET", primary_target(record) or "unknown target"),
|
||||
("RUN", run_name),
|
||||
("SCAN MODE", str(record.get("scan_mode") or "n/a")),
|
||||
("STATUS", str(record.get("status") or "n/a")),
|
||||
("STARTED", _fmt_time(record.get("start_time"))),
|
||||
("COMPLETED", _fmt_time(record.get("end_time"))),
|
||||
("DURATION", _duration(record.get("start_time"), record.get("end_time"))),
|
||||
]
|
||||
meta_table = Table(
|
||||
[[Paragraph(label, styles["meta_label"]), Paragraph(_esc(value), styles["meta_value"])]
|
||||
for label, value in meta_rows],
|
||||
colWidths=[38 * mm, _PAGE_W - 40 * mm - 38 * mm],
|
||||
)
|
||||
meta_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||
("LINEBELOW", (0, 0), (-1, -2), 0.5, _BORDER),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
confidential = Table([[Paragraph("CONFIDENTIAL", styles["confidential"])]], colWidths=[120])
|
||||
confidential.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, -1), _INK),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 8),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
]
|
||||
)
|
||||
)
|
||||
confidential.hAlign = "CENTER"
|
||||
|
||||
return [
|
||||
header,
|
||||
Spacer(1, 150),
|
||||
Paragraph("PENETRATION TEST REPORT", styles["badge_label"]),
|
||||
Spacer(1, 20),
|
||||
Paragraph("Security Assessment", styles["cover_title"]),
|
||||
Paragraph(_esc(target), styles["cover_org"]),
|
||||
Spacer(1, 28),
|
||||
meta_table,
|
||||
Spacer(1, 90),
|
||||
confidential,
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
|
||||
def _inline_md(text: str) -> str:
|
||||
"""Convert inline markdown (bold, italic, `code`) to reportlab markup.
|
||||
|
||||
Code spans are stashed as placeholders before bold/italic run, so bold that
|
||||
wraps a code span (``**`x`**``) works and code contents are never mangled.
|
||||
"""
|
||||
codes: list[str] = []
|
||||
|
||||
def _stash(match: re.Match[str]) -> str:
|
||||
codes.append(match.group(1))
|
||||
return f"\x00{len(codes) - 1}\x00"
|
||||
|
||||
seg = html.escape(re.sub(r"`([^`]+)`", _stash, text))
|
||||
seg = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", seg)
|
||||
seg = re.sub(r"__(.+?)__", r"<b>\1</b>", seg)
|
||||
seg = re.sub(r"\*(.+?)\*", r"<i>\1</i>", seg)
|
||||
|
||||
def _restore(match: re.Match[str]) -> str:
|
||||
inner = html.escape(codes[int(match.group(1))])
|
||||
return f'<font face="{_MONO}" color="#b31d28">{inner}</font>'
|
||||
|
||||
return re.sub(r"\x00(\d+)\x00", _restore, seg)
|
||||
|
||||
|
||||
def _strip_leading_heading(md: str) -> str:
|
||||
"""Drop a single leading markdown heading (each section adds its own title)."""
|
||||
lines = md.lstrip("\n").split("\n")
|
||||
if lines and re.match(r"^#{1,6}\s+", lines[0].strip()):
|
||||
return "\n".join(lines[1:]).lstrip("\n")
|
||||
return md
|
||||
|
||||
|
||||
def _markdown_flowables( # noqa: PLR0915 - cohesive block parser, splitting hurts clarity
|
||||
md: str, styles: dict[str, ParagraphStyle]
|
||||
) -> list[Flowable]:
|
||||
"""Render a markdown block (headings, lists, fenced code, prose) to flowables."""
|
||||
flow: list[Flowable] = []
|
||||
para: list[str] = []
|
||||
bullets: list[tuple[str, str]] = []
|
||||
|
||||
def flush_para() -> None:
|
||||
if para:
|
||||
flow.append(Paragraph(_inline_md(" ".join(para)), styles["body"]))
|
||||
para.clear()
|
||||
|
||||
def flush_bullets() -> None:
|
||||
for marker, item in bullets:
|
||||
flow.append(Paragraph(f"{marker} {_inline_md(item)}", styles["bullet"]))
|
||||
bullets.clear()
|
||||
|
||||
lines = md.replace("\r\n", "\n").split("\n")
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
stripped = lines[i].strip()
|
||||
if stripped.startswith("```"):
|
||||
flush_para()
|
||||
flush_bullets()
|
||||
i += 1
|
||||
code: list[str] = []
|
||||
while i < len(lines) and not lines[i].strip().startswith("```"):
|
||||
code.append(lines[i])
|
||||
i += 1
|
||||
i += 1 # closing fence
|
||||
flow.append(Paragraph(_esc("\n".join(code)) or " ", styles["code"]))
|
||||
continue
|
||||
if not stripped:
|
||||
flush_para()
|
||||
flush_bullets()
|
||||
i += 1
|
||||
continue
|
||||
heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)
|
||||
if heading:
|
||||
flush_para()
|
||||
flush_bullets()
|
||||
flow.append(Paragraph(_inline_md(heading.group(2)), styles["md_heading"]))
|
||||
i += 1
|
||||
continue
|
||||
ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped)
|
||||
unordered = re.match(r"^[-*+]\s+(.*)$", stripped)
|
||||
if ordered:
|
||||
flush_para()
|
||||
bullets.append((f"{ordered.group(1)}.", ordered.group(2)))
|
||||
i += 1
|
||||
continue
|
||||
if unordered:
|
||||
flush_para()
|
||||
bullets.append(("•", unordered.group(1)))
|
||||
i += 1
|
||||
continue
|
||||
flush_bullets()
|
||||
para.append(stripped)
|
||||
i += 1
|
||||
|
||||
flush_para()
|
||||
flush_bullets()
|
||||
return flow
|
||||
|
||||
|
||||
def _field_block(
|
||||
styles: dict[str, ParagraphStyle], label: str, value: Any, *, code: bool = False
|
||||
) -> list[Flowable]:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return []
|
||||
flow: list[Flowable] = [Paragraph(label.upper(), styles["field_label"])]
|
||||
if code:
|
||||
flow.append(Paragraph(_esc(value), styles["code"]))
|
||||
else:
|
||||
flow.extend(_markdown_flowables(str(value), styles))
|
||||
return flow
|
||||
|
||||
|
||||
def _finding_flowables(
|
||||
styles: dict[str, ParagraphStyle], index: int, vuln: dict[str, Any]
|
||||
) -> list[Flowable]:
|
||||
title = vuln.get("title") or "Untitled finding"
|
||||
severity = str(vuln.get("severity") or "").lower().strip() or "low"
|
||||
|
||||
meta_bits = []
|
||||
if vuln.get("cvss") is not None:
|
||||
meta_bits.append(f"<b>CVSS</b> {_esc(vuln.get('cvss'))}")
|
||||
meta_bits.extend(
|
||||
f"<b>{key.title()}</b> {_esc(vuln.get(key))}"
|
||||
for key in ("target", "endpoint", "method")
|
||||
if vuln.get(key)
|
||||
)
|
||||
|
||||
header: list[Flowable] = [
|
||||
Paragraph(f"{index}. {_esc(title)}", styles["finding"]),
|
||||
Spacer(1, 4),
|
||||
_severity_badge(styles, severity),
|
||||
]
|
||||
if meta_bits:
|
||||
header.append(Paragraph(" ".join(meta_bits), styles["meta_inline"]))
|
||||
|
||||
story: list[Flowable] = [KeepTogether(header)]
|
||||
story.extend(_field_block(styles, "Description", vuln.get("description")))
|
||||
story.extend(_field_block(styles, "Impact", vuln.get("impact")))
|
||||
story.extend(_field_block(styles, "Technical analysis", vuln.get("technical_analysis")))
|
||||
story.extend(_field_block(styles, "Proof of concept", vuln.get("poc_description")))
|
||||
story.extend(_field_block(styles, "PoC script", vuln.get("poc_script_code"), code=True))
|
||||
story.extend(_field_block(styles, "Evidence", vuln.get("evidence"), code=True))
|
||||
|
||||
remediation = vuln.get("remediation_steps")
|
||||
if isinstance(remediation, list):
|
||||
remediation = "\n".join(str(step) for step in remediation)
|
||||
story.extend(_field_block(styles, "Remediation", remediation))
|
||||
|
||||
story.append(Spacer(1, 22))
|
||||
return story
|
||||
|
||||
|
||||
def _overview_flowables(
|
||||
styles: dict[str, ParagraphStyle], record: dict[str, Any], total: int, counts: dict[str, int]
|
||||
) -> list[Flowable]:
|
||||
story: list[Flowable] = [
|
||||
_section(styles, "Executive Summary"),
|
||||
Spacer(1, 16),
|
||||
_severity_grid(styles, counts),
|
||||
Spacer(1, 10),
|
||||
Paragraph(f"<b>{total}</b> total findings across this assessment.", styles["body"]),
|
||||
]
|
||||
scan_results = record.get("scan_results")
|
||||
if not isinstance(scan_results, dict):
|
||||
return story
|
||||
summary = scan_results.get("executive_summary")
|
||||
if isinstance(summary, str) and summary.strip():
|
||||
story.append(Spacer(1, 16))
|
||||
story.extend(_markdown_flowables(_strip_leading_heading(summary), styles))
|
||||
for label, key in (
|
||||
("Methodology", "methodology"),
|
||||
("Technical Analysis", "technical_analysis"),
|
||||
("Recommendations", "recommendations"),
|
||||
):
|
||||
value = scan_results.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
story.append(Spacer(1, 20))
|
||||
story.append(_section(styles, label))
|
||||
story.append(Spacer(1, 12))
|
||||
story.extend(_markdown_flowables(_strip_leading_heading(value), styles))
|
||||
return story
|
||||
|
||||
|
||||
def generate_report_pdf(run_dir: Path) -> bytes:
|
||||
"""Render a branded, full-detail PDF report for the run at ``run_dir``."""
|
||||
record = read_run_summary(run_dir)
|
||||
vulns = [v for v in read_vulnerabilities(run_dir) if isinstance(v, dict)]
|
||||
counts = severity_counts(vulns)
|
||||
run_name = str(record.get("run_name") or run_dir.name)
|
||||
|
||||
styles = _styles()
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=A4,
|
||||
title="Strix Security Report",
|
||||
author="Strix",
|
||||
leftMargin=20 * mm,
|
||||
rightMargin=20 * mm,
|
||||
topMargin=22 * mm,
|
||||
bottomMargin=24 * mm,
|
||||
)
|
||||
|
||||
story: list[Flowable] = []
|
||||
story.extend(_cover(styles, record, run_name))
|
||||
story.extend(_overview_flowables(styles, record, len(vulns), counts))
|
||||
|
||||
story.append(PageBreak())
|
||||
story.append(_section(styles, "Findings"))
|
||||
story.append(Spacer(1, 16))
|
||||
if vulns:
|
||||
for index, vuln in enumerate(vulns, start=1):
|
||||
story.extend(_finding_flowables(styles, index, vuln))
|
||||
else:
|
||||
story.append(Paragraph("No findings were recorded for this run.", styles["body"]))
|
||||
|
||||
doc.build(story, canvasmaker=_NumberedCanvas)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def generate_password() -> str:
|
||||
"""Return a >=20 character URL-safe password from a CSPRNG."""
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
def encrypt_pdf(pdf_bytes: bytes, password: str) -> bytes:
|
||||
"""Encrypt a PDF with AES-256 using ``password`` as the user password."""
|
||||
reader = PdfReader(BytesIO(pdf_bytes))
|
||||
writer = PdfWriter()
|
||||
writer.append(reader)
|
||||
writer.encrypt(user_password=password, algorithm="AES-256")
|
||||
out = BytesIO()
|
||||
writer.write(out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def build_encrypted_report(run_dir: Path) -> tuple[bytes, str, str]:
|
||||
"""Build, encrypt, and name the report. Returns (pdf_bytes, password, filename)."""
|
||||
record = read_run_summary(run_dir)
|
||||
run_name = str(record.get("run_name") or run_dir.name)
|
||||
pdf_bytes = generate_report_pdf(run_dir)
|
||||
password = generate_password()
|
||||
encrypted = encrypt_pdf(pdf_bytes, password)
|
||||
filename = f"strix-report-{run_name}.pdf"
|
||||
return encrypted, password, filename
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_encrypted_report",
|
||||
"encrypt_pdf",
|
||||
"generate_password",
|
||||
"generate_report_pdf",
|
||||
]
|
||||
@@ -0,0 +1,532 @@
|
||||
"""Local HTTP server that serves the viewer SPA and a run's data from disk.
|
||||
|
||||
Design notes:
|
||||
- Uses only the standard library (no new runtime dependency). The workload is
|
||||
serving static files plus a handful of JSON reads off disk, so an async stack
|
||||
buys nothing here.
|
||||
- The browser polls the JSON endpoints (~1s) rather than using SSE: a finished
|
||||
run stops polling, and short-lived polls survive sleep/network blips without
|
||||
server-side connection state, which suits a stdlib ThreadingHTTPServer.
|
||||
- All reads happen per-request straight from disk, so the same server serves a
|
||||
live in-progress run and a finished one identically; the SPA distinguishes
|
||||
them via the ``finished`` flag on /api/run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import secrets
|
||||
import threading
|
||||
import webbrowser
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import parse_qs, unquote, urlencode, urlsplit
|
||||
|
||||
from strix.core.paths import run_record_path
|
||||
from strix.viewer import auth
|
||||
from strix.viewer.transcript import (
|
||||
build_run_state,
|
||||
primary_target,
|
||||
read_report_markdown,
|
||||
read_run_summary,
|
||||
read_vulnerabilities,
|
||||
severity_counts,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def bundle_dir() -> Path:
|
||||
"""Directory holding the committed, prebuilt SPA (index.html + assets)."""
|
||||
return Path(__file__).resolve().parent / "viewer_dist"
|
||||
|
||||
|
||||
def bundle_is_built() -> bool:
|
||||
return (bundle_dir() / "index.html").is_file()
|
||||
|
||||
|
||||
def _iter_run_dirs(base_dir: Path) -> list[Path]:
|
||||
"""Every run directory under ``base_dir``, newest first by record mtime."""
|
||||
if not base_dir.is_dir():
|
||||
return []
|
||||
run_dirs = [child for child in base_dir.iterdir() if run_record_path(child).is_file()]
|
||||
run_dirs.sort(key=lambda child: run_record_path(child).stat().st_mtime, reverse=True)
|
||||
return run_dirs
|
||||
|
||||
|
||||
def run_list_entry(run_dir: Path) -> dict[str, Any]:
|
||||
"""Compact summary of a single run for the history list."""
|
||||
record = read_run_summary(run_dir)
|
||||
return {
|
||||
"name": record.get("run_name") or run_dir.name,
|
||||
"target": primary_target(record),
|
||||
"scan_mode": record.get("scan_mode"),
|
||||
"status": record.get("status"),
|
||||
"start_time": record.get("start_time"),
|
||||
"end_time": record.get("end_time"),
|
||||
"finished": bool(record.get("finished")),
|
||||
"severity_counts": severity_counts(read_vulnerabilities(run_dir)),
|
||||
}
|
||||
|
||||
|
||||
def build_runs_payload(base_dir: Path, *, verified: bool) -> dict[str, Any]:
|
||||
"""The /api/runs payload. Gates the run list behind email verification.
|
||||
|
||||
The count is always advertised so the UI can tease the history, but the
|
||||
entries only appear once the viewer is verified.
|
||||
"""
|
||||
run_dirs = _iter_run_dirs(base_dir)
|
||||
count = len(run_dirs)
|
||||
if not verified:
|
||||
return {"locked": True, "count": count, "runs": []}
|
||||
return {"locked": False, "count": count, "runs": [run_list_entry(d) for d in run_dirs]}
|
||||
|
||||
|
||||
def resolve_run_dir(base_dir: Path, run_param: str | None, default_run_dir: Path) -> Path | None:
|
||||
"""Resolve a ``?run=`` value to a real run directory under ``base_dir``.
|
||||
|
||||
Returns ``default_run_dir`` when no run is requested. Rejects traversal and
|
||||
unknown runs (returns None) so the caller can answer 404.
|
||||
"""
|
||||
if not run_param:
|
||||
return default_run_dir
|
||||
base = base_dir.resolve()
|
||||
candidate = (base / run_param).resolve()
|
||||
# Only direct children of the runs base that actually hold a run record.
|
||||
if candidate.parent != base or not run_record_path(candidate).is_file():
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
# Name of the cookie carrying the per-process session capability.
|
||||
SESSION_COOKIE = "strix_viewer_session"
|
||||
|
||||
|
||||
class _ViewerState:
|
||||
def __init__(
|
||||
self,
|
||||
run_dir: Path,
|
||||
assets_dir: Path,
|
||||
steer_handler: Callable[[str, str], bool] | None = None,
|
||||
) -> None:
|
||||
self.run_dir = run_dir
|
||||
self.assets_dir = assets_dir
|
||||
# The strix_runs directory that holds the launched run; used to
|
||||
# enumerate and resolve other runs for the history list.
|
||||
self.base_dir = run_dir.parent
|
||||
# Set only when the viewer runs inside a live scan process (the TUI
|
||||
# launcher), which can deliver a message to a running agent. Absent for
|
||||
# standalone ``strix view`` / finished runs, so steering is unavailable.
|
||||
self.steer_handler = steer_handler
|
||||
# Unguessable per-process capability. It is minted here, printed/opened
|
||||
# for the operator who started the server (see ``authorized_url``), and
|
||||
# exchanged for a session cookie only when presented on the initial page
|
||||
# load. It is the request-level authorization the review asked for:
|
||||
# reachability of the port (e.g. when bound with ``--host``) is not
|
||||
# enough to steer a live scan, trigger a report, or browse history --
|
||||
# the token is never handed to a caller who merely reaches ``/``.
|
||||
self.session_token = secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
class ViewerHandler(BaseHTTPRequestHandler):
|
||||
server_version = "StrixViewer/1.0"
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
||||
logger.debug("viewer %s - %s", self.address_string(), format % args)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parts = urlsplit(self.path)
|
||||
path = parts.path
|
||||
try:
|
||||
if path.startswith("/api/"):
|
||||
self._handle_api(path, parse_qs(parts.query))
|
||||
else:
|
||||
self._handle_static(path, parse_qs(parts.query))
|
||||
except BrokenPipeError:
|
||||
# The browser closed the connection mid-response (e.g. it
|
||||
# navigated away between polls). Not an error.
|
||||
logger.debug("viewer client disconnected during %s", path)
|
||||
except Exception:
|
||||
# A bad request must never kill the worker thread.
|
||||
logger.exception("viewer request failed: %s", path)
|
||||
self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal error"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlsplit(self.path).path
|
||||
try:
|
||||
if path == "/api/event":
|
||||
self._handle_event()
|
||||
elif path == "/api/auth/otp/start":
|
||||
self._handle_otp_start()
|
||||
elif path == "/api/auth/otp/verify":
|
||||
self._handle_otp_verify()
|
||||
elif path == "/api/auth/forget":
|
||||
self._handle_forget()
|
||||
elif path == "/api/report/send":
|
||||
self._handle_report_send()
|
||||
elif path == "/api/agents/steer":
|
||||
self._handle_steer()
|
||||
else:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown endpoint"})
|
||||
except BrokenPipeError:
|
||||
logger.debug("viewer client disconnected during POST %s", path)
|
||||
except Exception:
|
||||
# A bad request must never kill the worker thread.
|
||||
logger.exception("viewer request failed: POST %s", path)
|
||||
self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal error"})
|
||||
|
||||
def _read_body(self) -> dict[str, Any]:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(length) if length else b""
|
||||
try:
|
||||
body = json.loads(raw or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
# Funnel events the viewer is allowed to forward. This handler is the
|
||||
# trust boundary: only these event names, with only their known props,
|
||||
# ever reach PostHog. Everything else (including any PII) is dropped.
|
||||
_EMAIL_EVENTS = frozenset(
|
||||
{"email_submitted", "email_verified", "report_sent", "work_email_required"}
|
||||
)
|
||||
|
||||
def _handle_event(self) -> None:
|
||||
body = self._read_body()
|
||||
# Forwarded as anonymous PostHog events that respect the global
|
||||
# telemetry opt-out. Never forward the email, code, or report body:
|
||||
# only the whitelisted event names and their known props are passed.
|
||||
event = body.get("event")
|
||||
if event == "cta_clicked":
|
||||
from strix.telemetry import posthog
|
||||
|
||||
cta = str(body.get("cta") or "unknown")
|
||||
surface = body.get("surface")
|
||||
posthog.viewer_cta_clicked(cta, surface=str(surface) if surface else None)
|
||||
elif event in self._EMAIL_EVENTS:
|
||||
from strix.telemetry import posthog
|
||||
|
||||
purpose = body.get("purpose")
|
||||
posthog.viewer_email_event(str(event), purpose=str(purpose) if purpose else None)
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self.end_headers()
|
||||
|
||||
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
||||
# The launched run is always viewable with no verification. Only the
|
||||
# cross-run history list (/api/runs) is gated.
|
||||
if path == "/api/runs":
|
||||
payload = build_runs_payload(state.base_dir, verified=auth.is_verified())
|
||||
self._send_json(HTTPStatus.OK, payload)
|
||||
return
|
||||
if path == "/api/capabilities":
|
||||
# Steering is only possible when the viewer shares a live scan's
|
||||
# coordinator + event loop (the TUI launcher wires a handler).
|
||||
self._send_json(HTTPStatus.OK, {"can_steer": state.steer_handler is not None})
|
||||
return
|
||||
if path == "/api/auth/status":
|
||||
# Report verification through is_verified() so an expired record
|
||||
# is advertised as unverified -- otherwise the SPA would suppress
|
||||
# re-verification while history stays locked, stranding the user.
|
||||
record = auth.read_auth()
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"verified": auth.is_verified(),
|
||||
"email": record.get("email") if record else None,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
run_values = query.get("run")
|
||||
run_param = run_values[0] if run_values else None
|
||||
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
||||
if run_dir is None:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
||||
return
|
||||
|
||||
# The launched run is always viewable. Any *other* run's data is part
|
||||
# of the gated history, so it requires the same email verification as
|
||||
# the /api/runs list — otherwise knowing a run name would leak its
|
||||
# metadata, vulnerabilities, report, and transcript.
|
||||
if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
|
||||
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||
return
|
||||
|
||||
if path == "/api/run":
|
||||
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
|
||||
elif path == "/api/vulnerabilities":
|
||||
self._send_json(HTTPStatus.OK, read_vulnerabilities(run_dir))
|
||||
elif path == "/api/report":
|
||||
self._send_json(HTTPStatus.OK, {"markdown": read_report_markdown(run_dir)})
|
||||
elif path == "/api/transcript":
|
||||
self._send_json(HTTPStatus.OK, build_run_state(run_dir))
|
||||
else:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown endpoint"})
|
||||
|
||||
def _handle_otp_start(self) -> None:
|
||||
email = str(self._read_body().get("email") or "").strip()
|
||||
if not email:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_email"})
|
||||
return
|
||||
try:
|
||||
auth.otp_start(email)
|
||||
except auth.RelayError as exc:
|
||||
self._send_relay_error(exc)
|
||||
return
|
||||
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||
|
||||
def _handle_otp_verify(self) -> None:
|
||||
body = self._read_body()
|
||||
email = str(body.get("email") or "").strip()
|
||||
code = str(body.get("code") or "").strip()
|
||||
if not email or not code:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_code"})
|
||||
return
|
||||
try:
|
||||
result = auth.otp_verify(email, code)
|
||||
except auth.RelayError as exc:
|
||||
self._send_relay_error(exc)
|
||||
return
|
||||
auth.write_auth(
|
||||
email=result.get("email") or email,
|
||||
token=result["token"],
|
||||
verified_at=result.get("expires_at") or "",
|
||||
)
|
||||
verified_email = result.get("email") or email
|
||||
self._send_json(HTTPStatus.OK, {"verified": True, "email": verified_email})
|
||||
|
||||
def _handle_forget(self) -> None:
|
||||
auth.forget()
|
||||
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||
|
||||
def _handle_report_send(self) -> None:
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
record = auth.read_auth()
|
||||
if record is None:
|
||||
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||
return
|
||||
run_param = str(self._read_body().get("run") or "") or None
|
||||
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
||||
if run_dir is None:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
||||
return
|
||||
|
||||
from strix.viewer.report_pdf import build_encrypted_report
|
||||
|
||||
pdf_bytes, password, filename = build_encrypted_report(run_dir)
|
||||
summary = read_run_summary(run_dir)
|
||||
run_name = str(summary.get("run_name") or run_dir.name)
|
||||
target = primary_target(summary) or "unknown target"
|
||||
try:
|
||||
# The password is intentionally NOT passed here; only the
|
||||
# encrypted PDF bytes reach the relay.
|
||||
auth.report_send(record["token"], pdf_bytes, filename, run_name, target)
|
||||
except auth.RelayError as exc:
|
||||
self._send_relay_error(exc)
|
||||
return
|
||||
# The password is returned only to the local (127.0.0.1) browser.
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{"ok": True, "password": password, "filename": filename},
|
||||
)
|
||||
|
||||
# Cap on a steering message so a runaway client cannot flood the agent.
|
||||
_STEER_MESSAGE_MAX = 4000
|
||||
|
||||
def _handle_steer(self) -> None:
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
body = self._read_body()
|
||||
agent_id = body.get("agent_id")
|
||||
message = body.get("message")
|
||||
if not isinstance(agent_id, str) or not agent_id.strip():
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_agent_id"})
|
||||
return
|
||||
if (
|
||||
not isinstance(message, str)
|
||||
or not message.strip()
|
||||
or len(message) > self._STEER_MESSAGE_MAX
|
||||
):
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_message"})
|
||||
return
|
||||
if state.steer_handler is None:
|
||||
# Standalone / finished-run viewing has no live scan to steer.
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "steering_unavailable"})
|
||||
return
|
||||
delivered = state.steer_handler(agent_id, message)
|
||||
if delivered:
|
||||
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||
else:
|
||||
self._send_json(HTTPStatus.OK, {"ok": False, "error": "not_delivered"})
|
||||
|
||||
def _send_relay_error(self, exc: auth.RelayError) -> None:
|
||||
status_by_code = {
|
||||
"rate_limited": HTTPStatus.TOO_MANY_REQUESTS,
|
||||
"invalid_email": HTTPStatus.BAD_REQUEST,
|
||||
"work_email_required": HTTPStatus.BAD_REQUEST,
|
||||
"invalid_code": HTTPStatus.FORBIDDEN,
|
||||
"reverify": HTTPStatus.UNAUTHORIZED,
|
||||
"forbidden": HTTPStatus.FORBIDDEN,
|
||||
"too_large": HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||
"unavailable": HTTPStatus.BAD_GATEWAY,
|
||||
}
|
||||
status = status_by_code.get(exc.code, HTTPStatus.BAD_GATEWAY)
|
||||
self._send_json(status, {"error": exc.code})
|
||||
|
||||
def _cookies(self) -> dict[str, str]:
|
||||
jar: dict[str, str] = {}
|
||||
for chunk in (self.headers.get("Cookie") or "").split(";"):
|
||||
name, sep, value = chunk.strip().partition("=")
|
||||
if sep:
|
||||
jar[name] = value
|
||||
return jar
|
||||
|
||||
def _has_session(self) -> bool:
|
||||
"""True when the request carries this process's session capability.
|
||||
|
||||
The cookie is set only when the SPA is served (index.html), so only
|
||||
the browser this process handed the page to can pass. A direct
|
||||
caller on an exposed port has no cookie and is rejected.
|
||||
"""
|
||||
supplied = self._cookies().get(SESSION_COOKIE, "")
|
||||
return bool(supplied) and secrets.compare_digest(supplied, state.session_token)
|
||||
|
||||
def _token_presented(self, query: dict[str, list[str]]) -> bool:
|
||||
"""True when the request carries the correct bootstrap token.
|
||||
|
||||
The token reaches the operator's browser through the URL printed /
|
||||
opened by the process that started the server, a channel an
|
||||
arbitrary network caller on an exposed port cannot observe.
|
||||
"""
|
||||
supplied = (query.get("token") or [""])[0]
|
||||
return bool(supplied) and secrets.compare_digest(supplied, state.session_token)
|
||||
|
||||
def _handle_static(self, path: str, query: dict[str, list[str]]) -> None:
|
||||
target = self._resolve_asset(path)
|
||||
if target is None:
|
||||
# SPA fallback: unknown non-asset routes render index.html so
|
||||
# client-side deep links work.
|
||||
target = state.assets_dir / "index.html"
|
||||
is_index = target.name == "index.html"
|
||||
if not target.is_file():
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
||||
return
|
||||
content = target.read_bytes()
|
||||
content_type, _ = mimetypes.guess_type(str(target))
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", content_type or "application/octet-stream")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
if is_index and self._token_presented(query):
|
||||
# Exchange the bootstrap token for the per-process session
|
||||
# capability. Issued only when the correct token is presented,
|
||||
# so a caller who merely reaches ``/`` never obtains it.
|
||||
# HttpOnly (JS never needs it; fetch sends it automatically) and
|
||||
# SameSite=Strict (never sent from a cross-site context).
|
||||
self.send_header(
|
||||
"Set-Cookie",
|
||||
f"{SESSION_COOKIE}={state.session_token}; Path=/; HttpOnly; SameSite=Strict",
|
||||
)
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
def _resolve_asset(self, path: str) -> Path | None:
|
||||
rel = unquote(path).lstrip("/")
|
||||
if not rel or rel.endswith("/"):
|
||||
return None
|
||||
root = state.assets_dir.resolve()
|
||||
candidate = (root / rel).resolve()
|
||||
# Path-traversal guard: never serve outside the bundle root.
|
||||
if root != candidate and root not in candidate.parents:
|
||||
logger.warning("viewer rejected traversal attempt: %s", path)
|
||||
return None
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
def _send_json(self, status: HTTPStatus, payload: Any) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
return ViewerHandler
|
||||
|
||||
|
||||
def authorized_url(base_url: str, token: str) -> str:
|
||||
"""URL that bootstraps the viewer session for the operator.
|
||||
|
||||
Presenting ``token`` on the initial page load is what mints the session
|
||||
cookie, so this URL is printed / opened only for the operator who started
|
||||
the server. Sharing it (rather than the bare ``base_url``) is what lets a
|
||||
trusted remote user authorize when the viewer is exposed with ``--host``.
|
||||
"""
|
||||
return f"{base_url}/?{urlencode({'token': token})}"
|
||||
|
||||
|
||||
def serve(
|
||||
run_dir: Path,
|
||||
*,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 0,
|
||||
open_browser: bool = True,
|
||||
steer_handler: Callable[[str, str], bool] | None = None,
|
||||
) -> tuple[ThreadingHTTPServer, str, str]:
|
||||
"""Start the viewer server on a background thread; return (server, url, token).
|
||||
|
||||
``url`` is the bare base; pass it through ``authorized_url(url, token)`` to
|
||||
build the operator link that authorizes the browser.
|
||||
|
||||
Binds an ephemeral port by default. If a fixed ``port`` is requested but in
|
||||
use, falls back to an ephemeral port. Reused by both the ``strix view``
|
||||
command and the in-TUI launcher; callers own the server's lifetime.
|
||||
|
||||
``steer_handler`` is supplied only by the in-TUI launcher, which runs inside
|
||||
the live scan process and can forward a message to a running agent. Left
|
||||
``None`` (standalone ``strix view``), steering is reported unavailable.
|
||||
"""
|
||||
assets_dir = bundle_dir()
|
||||
state = _ViewerState(run_dir=run_dir, assets_dir=assets_dir, steer_handler=steer_handler)
|
||||
handler = _make_handler(state)
|
||||
|
||||
try:
|
||||
httpd = ThreadingHTTPServer((host, port), handler)
|
||||
except OSError:
|
||||
if port == 0:
|
||||
raise
|
||||
logger.info("viewer port %s unavailable, falling back to an ephemeral port", port)
|
||||
httpd = ThreadingHTTPServer((host, 0), handler)
|
||||
|
||||
httpd.daemon_threads = True
|
||||
bound_port = int(httpd.server_address[1])
|
||||
url = f"http://{host}:{bound_port}"
|
||||
|
||||
thread = threading.Thread(target=httpd.serve_forever, name="strix-viewer", daemon=True)
|
||||
thread.start()
|
||||
|
||||
if open_browser:
|
||||
_open_browser(authorized_url(url, state.session_token))
|
||||
|
||||
return httpd, url, state.session_token
|
||||
|
||||
|
||||
def _open_browser(url: str) -> None:
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception: # noqa: BLE001 - launching the browser is best-effort
|
||||
logger.debug("could not open browser for %s", url, exc_info=True)
|
||||
|
||||
|
||||
__all__ = ["authorized_url", "bundle_dir", "bundle_is_built", "serve"]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Build the JSON payloads the viewer SPA consumes from a run directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from strix.core.paths import run_record_path
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TERMINAL_STATUSES = {"completed", "stopped", "failed", "interrupted"}
|
||||
|
||||
_KNOWN_SEVERITIES = ("critical", "high", "medium", "low")
|
||||
|
||||
|
||||
def severity_counts(vulns: list[Any]) -> dict[str, int]:
|
||||
"""Bucket vulnerabilities into critical/high/medium/low counts.
|
||||
|
||||
Mirrors the SPA's ``severityCounts``: severities are lowercased and
|
||||
trimmed, and anything outside the four known buckets (``info``,
|
||||
``informational``, ``unknown``, missing, ...) folds into ``low`` so the
|
||||
shared UI renders cleanly.
|
||||
"""
|
||||
counts = dict.fromkeys(_KNOWN_SEVERITIES, 0)
|
||||
for vuln in vulns:
|
||||
raw = vuln.get("severity") if isinstance(vuln, dict) else None
|
||||
severity = str(raw or "").lower().strip()
|
||||
if severity not in counts:
|
||||
severity = "low"
|
||||
counts[severity] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def build_run_state(run_dir: Path) -> dict[str, Any]:
|
||||
"""Agent graph + full per-agent event/message stream.
|
||||
|
||||
Reuses the Textual-free ``TuiLiveView`` projection so the viewer and the TUI
|
||||
share one parser for ``agents.json`` + ``agents.db`` and never drift.
|
||||
"""
|
||||
# Imported lazily so importing strix.viewer does not eagerly pull the TUI.
|
||||
from strix.interface.tui.live_view import TuiLiveView # noqa: PLC0415
|
||||
|
||||
view = TuiLiveView()
|
||||
view.hydrate_from_run_dir(run_dir)
|
||||
return {"agents": list(view.agents.values()), "events": view.events}
|
||||
|
||||
|
||||
def read_run_summary(run_dir: Path) -> dict[str, Any]:
|
||||
"""The ``run.json`` record plus a computed ``finished`` flag."""
|
||||
record = _load_json(run_record_path(run_dir), default={})
|
||||
if not isinstance(record, dict):
|
||||
record = {}
|
||||
status = record.get("status")
|
||||
finished = status in _TERMINAL_STATUSES and bool(record.get("end_time"))
|
||||
return {**record, "finished": finished}
|
||||
|
||||
|
||||
def primary_target(record: dict[str, Any]) -> str | None:
|
||||
"""The first target's original string from a run record, or None."""
|
||||
targets = record.get("targets_info")
|
||||
if isinstance(targets, list):
|
||||
for entry in targets:
|
||||
if isinstance(entry, dict):
|
||||
original = entry.get("original")
|
||||
if isinstance(original, str) and original:
|
||||
return original
|
||||
return None
|
||||
|
||||
|
||||
def read_vulnerabilities(run_dir: Path) -> list[Any]:
|
||||
"""The ``vulnerabilities.json`` list (empty until a scan writes it)."""
|
||||
data = _load_json(run_dir / "vulnerabilities.json", default=[])
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def read_report_markdown(run_dir: Path) -> str:
|
||||
"""The executive report markdown (empty until a scan writes it)."""
|
||||
report_path = run_dir / "penetration_test_report.md"
|
||||
try:
|
||||
return report_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _load_json(path: Path, *, default: Any) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return default
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_run_state",
|
||||
"primary_target",
|
||||
"read_report_markdown",
|
||||
"read_run_summary",
|
||||
"read_vulnerabilities",
|
||||
"severity_counts",
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="./logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
<script type="module" crossorigin src="./assets/index-Cmmg8DTB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B-nLXAmX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="./logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4272
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "strix-viewer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"clsx": "^2.1.1",
|
||||
"diff": "^8.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,806 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ShieldCheck,
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Waypoints,
|
||||
Mail,
|
||||
ChevronDown,
|
||||
Wrench,
|
||||
FileCheck2,
|
||||
CalendarClock,
|
||||
Radar,
|
||||
GitPullRequest,
|
||||
Rocket,
|
||||
ArrowUpRight,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
import type { Vulnerability, VulnerabilitySeverity } from "@/types/issues";
|
||||
import { SEVERITY_COLORS } from "@/types/issues";
|
||||
import { getSeverityDot } from "@/lib/vulnerability-utils";
|
||||
import VulnerabilityDetail from "@/components/vulnerability/VulnerabilityDetail";
|
||||
import { ContentSection } from "@/components/vulnerability/ContentSection";
|
||||
import { IssueSeveritySummary } from "@/components/IssueSeveritySummary";
|
||||
import AgentGraph from "@/components/live/AgentGraph";
|
||||
import { buildGraphAgents } from "@/components/live/AgentTranscript";
|
||||
import AgentDetailModal from "@/components/live/AgentDetailModal";
|
||||
import { ScanPromptComposer } from "@/components/live/ScanPromptComposer";
|
||||
import { severityCounts, type ParsedRunSummary } from "@/lib/local-run-parser";
|
||||
import {
|
||||
fetchAll,
|
||||
fetchAuthStatus,
|
||||
fetchCapabilities,
|
||||
fetchRunSummary,
|
||||
fetchRuns,
|
||||
fetchTranscript,
|
||||
fetchVulnerabilities,
|
||||
forgetAuth,
|
||||
type AuthStatus,
|
||||
type LoadedRun,
|
||||
type RunsPayload,
|
||||
} from "@/data/serverSource";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { runTitle } from "@/lib/target-utils";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import PastRunsView from "@/components/PastRunsView";
|
||||
import EmailReportView from "@/components/EmailReportView";
|
||||
import FeatureDetail from "@/components/FeatureDetail";
|
||||
import { ProTile, ProInlineCta, type ProItem } from "@/components/ProCta";
|
||||
import { FEATURES } from "@/lib/pro-features";
|
||||
|
||||
export type View = "overview" | "issues" | "agents" | "history" | "feature" | "email";
|
||||
|
||||
const TRUST_BANNER =
|
||||
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix. Emailing a report is an explicit opt-in that sends an encrypted copy only you can open.";
|
||||
|
||||
const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
|
||||
const POLL_MS = 500;
|
||||
|
||||
// Curated inline CTAs. Continuous-coverage row on Overview (the restyled upsell
|
||||
// tiles), plus the recommendations pairing.
|
||||
const RECOMMENDATION_CTAS: ProItem[] = [
|
||||
{ title: "One-click autofix + open a fix PR", desc: "Fix it for you and open a PR, retested.", slug: "autofix", icon: Wrench },
|
||||
{ title: "Export SOC 2 / ISO 27001 report", desc: "Share an auditor-ready report with your team.", slug: "compliance", icon: FileCheck2 },
|
||||
];
|
||||
const COVERAGE_CTAS: ProItem[] = [
|
||||
{ title: "Scheduled pentesting", desc: "Continuous coverage for your whole org.", slug: "scheduled", icon: CalendarClock },
|
||||
{ title: "Attack surface monitoring", desc: "Continuous coverage for your whole org.", slug: "asm", icon: Radar },
|
||||
{ title: "PR reviews", desc: "Pentest every pull request your team opens.", slug: "pr_reviews", icon: GitPullRequest },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const [activeRun, setActiveRun] = useState<string | null>(null);
|
||||
const [run, setRun] = useState<LoadedRun | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [view, setView] = useState<View>("overview");
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
const [auth, setAuth] = useState<AuthStatus | null>(null);
|
||||
const [runs, setRuns] = useState<RunsPayload | null>(null);
|
||||
const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report");
|
||||
const [emailSkipDisclosure, setEmailSkipDisclosure] = useState(false);
|
||||
// Whether this viewer can steer a live scan (true only inside the in-TUI
|
||||
// launcher that shares the running scan's coordinator + event loop).
|
||||
const [canSteer, setCanSteer] = useState(false);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
try {
|
||||
setAuth(await fetchAuthStatus());
|
||||
} catch {
|
||||
/* auth status is best-effort; the launched run stays viewable */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshRuns = useCallback(async () => {
|
||||
try {
|
||||
setRuns(await fetchRuns());
|
||||
} catch {
|
||||
/* history list is best-effort */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAuth();
|
||||
void refreshRuns();
|
||||
// Capabilities never change over a session, so fetch once on mount.
|
||||
fetchCapabilities()
|
||||
.then((caps) => setCanSteer(caps.can_steer))
|
||||
.catch(() => {
|
||||
/* absence of steering is the safe default */
|
||||
});
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
// Live polling, scoped to the active run. Re-runs when the active run changes
|
||||
// so switching to a past run (?run=<name>) reloads its data; a finished run
|
||||
// does a single full fetch and stops.
|
||||
const finishedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
finishedRef.current = false;
|
||||
|
||||
const schedule = () => {
|
||||
timer = setTimeout(tick, POLL_MS);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const { summary, raw, finished } = await fetchRunSummary(activeRun);
|
||||
if (cancelled) return;
|
||||
if (finished && !finishedRef.current) {
|
||||
finishedRef.current = true;
|
||||
const full = await fetchAll(activeRun);
|
||||
if (!cancelled) setRun(full);
|
||||
return; // stop polling
|
||||
}
|
||||
const [transcript, vulnerabilities] = await Promise.all([
|
||||
fetchTranscript(activeRun).catch(() => ({ agents: [], events: [] })),
|
||||
fetchVulnerabilities(summary.runId, activeRun).catch(() => [] as Vulnerability[]),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setRun((prev) => ({
|
||||
summary,
|
||||
raw,
|
||||
finished,
|
||||
transcript,
|
||||
vulnerabilities,
|
||||
reportMarkdown: prev?.reportMarkdown ?? null,
|
||||
}));
|
||||
schedule();
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : "Could not load run data.");
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const full = await fetchAll(activeRun);
|
||||
if (cancelled) return;
|
||||
setRun(full);
|
||||
if (full.finished) {
|
||||
finishedRef.current = true;
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : "Could not load run data.");
|
||||
schedule();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [activeRun]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => (run ? severityCounts(run.vulnerabilities) : null),
|
||||
[run]
|
||||
);
|
||||
const selected = run?.vulnerabilities.find((v) => v.id === selectedId) ?? null;
|
||||
const agentCount = run?.transcript.agents.length ?? 0;
|
||||
const verified = auth?.verified === true;
|
||||
|
||||
// Per-run guard for the default view: land on Agents while a scan is live,
|
||||
// Overview once it finishes. Applied at most once per run and never once the
|
||||
// user has navigated manually (userSetView flips the guard).
|
||||
const initialViewAppliedRef = useRef(false);
|
||||
|
||||
// Reset the guard whenever the active run changes so the newly selected run
|
||||
// gets its own default.
|
||||
useEffect(() => {
|
||||
initialViewAppliedRef.current = false;
|
||||
}, [activeRun]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialViewAppliedRef.current || !run) return;
|
||||
if (run.finished) {
|
||||
initialViewAppliedRef.current = true;
|
||||
setView("overview");
|
||||
} else if (agentCount > 0) {
|
||||
// Live and agents have appeared: default to the agent graph. If it is
|
||||
// live but no agents exist yet, wait (do not apply, do not set the flag).
|
||||
initialViewAppliedRef.current = true;
|
||||
setView("agents");
|
||||
}
|
||||
}, [run, agentCount]);
|
||||
|
||||
// User-initiated navigation: mark the default guard applied so the per-run
|
||||
// default effect never yanks the user off the view they chose.
|
||||
const userSetView = useCallback((v: View) => {
|
||||
initialViewAppliedRef.current = true;
|
||||
setView(v);
|
||||
}, []);
|
||||
|
||||
const selectRun = useCallback((name: string) => {
|
||||
setActiveRun(name);
|
||||
setSelectedId(null);
|
||||
setRun(null);
|
||||
setError(null);
|
||||
// Reset the guard so the per-run default applies to the newly selected run.
|
||||
initialViewAppliedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const goEmail = useCallback((skipDisclosure: boolean, surface: string) => {
|
||||
trackCta("email_report", surface);
|
||||
setEmailPurpose("report");
|
||||
setEmailSkipDisclosure(skipDisclosure);
|
||||
userSetView("email");
|
||||
}, [userSetView]);
|
||||
|
||||
// Sidebar entry keeps the disclosure (first place those users see it);
|
||||
const openEmail = useCallback(() => goEmail(false, "sidebar"), [goEmail]);
|
||||
// the Overview CTA already states the tradeoff, so it starts the flow directly.
|
||||
const openEmailFromOverview = useCallback(() => goEmail(true, "overview"), [goEmail]);
|
||||
|
||||
const openHistory = useCallback(() => {
|
||||
void refreshRuns();
|
||||
userSetView("history");
|
||||
}, [refreshRuns, userSetView]);
|
||||
|
||||
const onPastRunsVerified = useCallback(async () => {
|
||||
await refreshAuth();
|
||||
await refreshRuns();
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
const selectFeature = useCallback((slug: string) => {
|
||||
trackCta(slug, "sidebar_nav");
|
||||
setActiveFeature(slug);
|
||||
userSetView("feature");
|
||||
}, [userSetView]);
|
||||
|
||||
const onForget = useCallback(async () => {
|
||||
await forgetAuth();
|
||||
await refreshAuth();
|
||||
await refreshRuns();
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex">
|
||||
<Sidebar
|
||||
view={view}
|
||||
onSelectView={(v) => {
|
||||
if (v === "history") openHistory();
|
||||
else userSetView(v);
|
||||
}}
|
||||
activeFeature={activeFeature}
|
||||
onSelectFeature={selectFeature}
|
||||
issuesCount={run?.vulnerabilities.length ?? 0}
|
||||
agentCount={agentCount}
|
||||
runCount={runs?.count ?? 0}
|
||||
verified={verified}
|
||||
email={auth?.email ?? null}
|
||||
onOpenEmail={openEmail}
|
||||
onOpenHistory={openHistory}
|
||||
onForget={() => void onForget()}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Top bar */}
|
||||
<div className="border-b border-[#222]">
|
||||
<div className="max-w-[72rem] mx-auto px-6 py-4 flex items-center gap-1.5">
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "topbar")}
|
||||
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100 lg:hidden"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<img src="./logo.png" alt="Strix" className="w-10 h-8 object-cover" />
|
||||
<div className="text-base text-white font-medium tracking-tight">Strix</div>
|
||||
</a>
|
||||
<span className="text-xs text-[#666]">Local results</span>
|
||||
{run && <LiveIndicator finished={run.finished} />}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{verified && runs && !runs.locked && runs.runs.length > 0 && (
|
||||
<RunSwitcher
|
||||
runs={runs}
|
||||
activeRun={activeRun}
|
||||
launchedName={runTitle(run?.summary.targets[0] ?? null, run?.summary.runName ?? run?.summary.runId ?? "Current run")}
|
||||
onSelect={selectRun}
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "run_in_cloud")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("run_in_cloud", "topbar")}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Run in the cloud
|
||||
<ArrowUpRight className="w-3 h-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[72rem] mx-auto px-6 py-8 space-y-6">
|
||||
{/* Trust banner (not on the Pro feature or email pages) */}
|
||||
{view !== "feature" && view !== "email" && (
|
||||
<div className="rounded-lg px-4 py-3 flex gap-3 items-start" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<ShieldCheck className="w-5 h-5 flex-shrink-0 mt-0.5 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-sm text-[#aaa] leading-relaxed">{TRUST_BANNER}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !run && view !== "history" && view !== "email" && view !== "feature" && (
|
||||
<div className="rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5 text-red-400" aria-hidden="true" />
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "email" ? (
|
||||
<EmailReportView
|
||||
activeRun={activeRun}
|
||||
auth={auth}
|
||||
purpose={emailPurpose}
|
||||
skipDisclosure={emailSkipDisclosure}
|
||||
onAuthChanged={() => {
|
||||
void refreshAuth();
|
||||
void refreshRuns();
|
||||
}}
|
||||
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
|
||||
/>
|
||||
) : view === "feature" && activeFeature && FEATURES[activeFeature] ? (
|
||||
<FeatureDetail feature={FEATURES[activeFeature]} />
|
||||
) : view === "history" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-5 h-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">Past runs</h1>
|
||||
</div>
|
||||
<PastRunsView
|
||||
runs={runs}
|
||||
activeRun={activeRun}
|
||||
onSelectRun={selectRun}
|
||||
onVerified={() => void onPastRunsVerified()}
|
||||
/>
|
||||
</div>
|
||||
) : !run && !error ? (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center">
|
||||
<div className="w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin" />
|
||||
<p className="text-sm text-[#888]">Loading run data…</p>
|
||||
</div>
|
||||
) : run && counts ? (
|
||||
<>
|
||||
<SummaryHeader summary={run.summary} />
|
||||
|
||||
{/* Tab strip: shown on small screens where the sidebar is hidden. */}
|
||||
<div className="flex gap-5 border-b border-[#2a2a2a] lg:hidden">
|
||||
<TabButton active={view === "overview"} onClick={() => userSetView("overview")}>
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
|
||||
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
|
||||
</TabButton>
|
||||
{agentCount > 0 && (
|
||||
<TabButton active={view === "agents"} onClick={() => userSetView("agents")}>
|
||||
Agents ({agentCount})
|
||||
</TabButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === "overview" ? (
|
||||
<OverviewTab
|
||||
summary={run.summary}
|
||||
counts={counts}
|
||||
total={run.vulnerabilities.length}
|
||||
reportMarkdown={run.reportMarkdown}
|
||||
onOpenEmail={openEmailFromOverview}
|
||||
/>
|
||||
) : view === "agents" && agentCount > 0 ? (
|
||||
<AgentsTab run={run} canSteer={canSteer} />
|
||||
) : selected ? (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setSelectedId(null)}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to all findings
|
||||
</button>
|
||||
<VulnerabilityDetail vulnerability={selected} />
|
||||
</div>
|
||||
) : (
|
||||
<FindingsList
|
||||
vulnerabilities={run.vulnerabilities}
|
||||
finished={run.finished}
|
||||
onSelect={(id) => setSelectedId(id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunSwitcher({
|
||||
runs,
|
||||
activeRun,
|
||||
launchedName,
|
||||
onSelect,
|
||||
}: {
|
||||
runs: RunsPayload;
|
||||
activeRun: string | null;
|
||||
launchedName: string;
|
||||
onSelect: (name: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeEntry = runs.runs.find((r) => r.name === activeRun);
|
||||
const current = activeEntry ? runTitle(activeEntry.target, activeEntry.name) : launchedName;
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[#aaa] transition-colors hover:text-white"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<History className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<span className="max-w-[160px] truncate">{current}</span>
|
||||
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 z-50 mt-1.5 max-h-80 w-64 overflow-y-auto rounded-lg py-1 shadow-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
|
||||
>
|
||||
{runs.runs.map((r) => {
|
||||
const active = r.name === activeRun;
|
||||
return (
|
||||
<button
|
||||
key={r.name}
|
||||
onMouseDown={() => onSelect(r.name)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
|
||||
active ? "text-white" : "text-[#aaa]"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{runTitle(r.target, r.name)}</span>
|
||||
{r.target && <span className="block truncate font-mono text-[#666]">{r.target}</span>}
|
||||
</span>
|
||||
{active && <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveIndicator({ finished }: { finished: boolean }) {
|
||||
if (finished) {
|
||||
return (
|
||||
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-[#888]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#555]" />
|
||||
Complete
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
Live
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string | null {
|
||||
if (seconds == null) return null;
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
function SummaryHeader({ summary }: { summary: ParsedRunSummary }) {
|
||||
const duration = formatDuration(summary.durationSeconds);
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Scan results")}
|
||||
</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]">
|
||||
{summary.targets.length > 0 && (
|
||||
<span className="font-mono text-[#aaa]">{summary.targets.join(", ")}</span>
|
||||
)}
|
||||
{summary.scanMode && <Meta label={summary.scanMode} />}
|
||||
{duration && <Meta label={duration} />}
|
||||
{summary.status && <Meta label={summary.status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Meta({ label }: { label: string }) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-[#333]">·</span>
|
||||
<span className="capitalize">{label}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingsList({
|
||||
vulnerabilities,
|
||||
finished,
|
||||
onSelect,
|
||||
}: {
|
||||
vulnerabilities: Vulnerability[];
|
||||
finished: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const sorted = [...vulnerabilities].sort(
|
||||
(a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
|
||||
);
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
|
||||
{finished ? "No findings in this run." : "No findings yet. The scan is still running…"}
|
||||
</div>
|
||||
{finished && (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-medium text-white">Stay ahead of new exposures</p>
|
||||
<p className="mt-0.5 mb-3 text-xs text-[#666]">
|
||||
Attack surface monitoring catches new exposures for your org over time.
|
||||
</p>
|
||||
<ProInlineCta
|
||||
label="Attack surface monitoring"
|
||||
desc="Continuous coverage for your whole org."
|
||||
slug="asm"
|
||||
surface="empty_state"
|
||||
icon={Radar}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sorted.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => onSelect(v.id)}
|
||||
className="cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${getSeverityDot(v.severity)}`} aria-hidden="true" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium text-white truncate">{v.title}</span>
|
||||
{v.target && (
|
||||
<span className="block text-xs text-[#666] font-mono truncate">{v.target}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full border capitalize ${SEVERITY_COLORS[v.severity]}`}
|
||||
>
|
||||
{v.severity}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Strip a single leading markdown heading (report sections embed their own). */
|
||||
function stripLeadingHeading(md: string): string {
|
||||
return md.replace(/^\s*#{1,6}[ \t]+.*(?:\r?\n)+/, "").trimStart();
|
||||
}
|
||||
|
||||
function dedupeHeadings(md: string): string {
|
||||
const out: string[] = [];
|
||||
let lastHeading: string | null = null;
|
||||
for (const line of md.split("\n")) {
|
||||
const m = line.match(/^#{1,6}\s+(.*)$/);
|
||||
if (m) {
|
||||
const norm = m[1].trim().toLowerCase();
|
||||
if (norm === lastHeading) continue;
|
||||
lastHeading = norm;
|
||||
} else if (line.trim() !== "") {
|
||||
lastHeading = null;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** Primary local CTA: email an encrypted PDF. Verify-email affordance, no lock. */
|
||||
function EmailReportCta({ onOpenEmail }: { onOpenEmail: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onOpenEmail}
|
||||
className="group w-full cursor-pointer rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-4 text-left transition-colors hover:border-emerald-500/40"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ border: "1px solid rgba(16,185,129,0.3)", background: "rgba(16,185,129,0.08)" }}
|
||||
>
|
||||
<Mail className="h-4 w-4 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white">Email an encrypted PDF report of this run</p>
|
||||
<p className="mt-0.5 text-xs text-[#888]">
|
||||
Encrypted with a key only you can see, email verified with a one-time code before sending.
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90">
|
||||
Email report
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
summary,
|
||||
counts,
|
||||
total,
|
||||
reportMarkdown,
|
||||
onOpenEmail,
|
||||
}: {
|
||||
summary: ParsedRunSummary;
|
||||
counts: Record<VulnerabilitySeverity, number>;
|
||||
total: number;
|
||||
reportMarkdown: string | null;
|
||||
onOpenEmail: () => void;
|
||||
}) {
|
||||
const sections = (
|
||||
[
|
||||
["Executive Summary", summary.executiveSummary],
|
||||
["Technical Analysis", summary.technicalAnalysis],
|
||||
["Methodology", summary.methodology],
|
||||
["Recommendations", summary.recommendations],
|
||||
] as const
|
||||
)
|
||||
.filter(([, content]) => !!content)
|
||||
.map(([title, content]) => ({ title, content: stripLeadingHeading(content as string) }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{total > 0 && (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<IssueSeveritySummary findings={{ total, ...counts }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary CTA: the one primary on Overview. */}
|
||||
<EmailReportCta onOpenEmail={onOpenEmail} />
|
||||
|
||||
{sections.length > 0 ? (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
|
||||
{sections.map((s) => (
|
||||
<ContentSection key={s.title} title={s.title} content={s.content} />
|
||||
))}
|
||||
</div>
|
||||
) : reportMarkdown ? (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<ContentSection content={dedupeHeadings(reportMarkdown)} />
|
||||
</div>
|
||||
) : (
|
||||
total === 0 && (
|
||||
<p className="text-sm text-[#888]">No summary available for this run yet.</p>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Near Recommendations: act on the fixes. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{RECOMMENDATION_CTAS.map((item) => (
|
||||
<ProTile key={item.slug} item={item} surface="overview" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Continuous coverage for your org (restyled upsell tiles). */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-white">Continuous coverage for your org</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{COVERAGE_CTAS.map((item) => (
|
||||
<ProTile key={item.slug} item={item} surface="overview" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`cursor-pointer relative pb-2.5 text-sm font-semibold transition-colors ${
|
||||
active ? "text-white" : "text-[#666] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
{active && <span className="absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
const { agents, events } = run.transcript;
|
||||
const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]);
|
||||
// Clicking a graph node opens the agent's transcript in a modal (matching the
|
||||
// cloud app); no node selected means no modal.
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
|
||||
|
||||
// Live steering is only possible in-process (canSteer) while the scan runs.
|
||||
const steerable = canSteer && !run.finished;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Waypoints className="w-4 h-4 text-[#888]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-white">Agent graph</h2>
|
||||
<span className="text-xs text-[#666]">
|
||||
{agents.length} agent{agents.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 mb-4 text-xs text-[#666]">
|
||||
Click an agent to open its full transcript.
|
||||
</p>
|
||||
<div className="h-[480px] rounded-lg border border-[#1a1a1a] overflow-hidden">
|
||||
<AgentGraph
|
||||
agents={graphAgents}
|
||||
selectedAgentId={selectedId}
|
||||
onSelectAgent={(id) => setSelectedId(id)}
|
||||
eventsLoaded
|
||||
eventsEmpty={graphAgents.size === 0}
|
||||
scanCompleted={run.finished}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live steering: only in-process while the scan runs. Otherwise omitted. */}
|
||||
{steerable && <ScanPromptComposer agents={agents} />}
|
||||
|
||||
{/* Re-run always routes to Strix Cloud. */}
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-semibold text-white">Run this scan with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this scan on managed infra in the cloud.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Re-run in Strix Cloud with more depth"
|
||||
desc="Run this scan on managed infra with more depth."
|
||||
slug="live_scan"
|
||||
surface="agents"
|
||||
icon={Rocket}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAgent && (
|
||||
<AgentDetailModal
|
||||
agent={selectedAgent}
|
||||
events={events}
|
||||
steerable={steerable}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Extracted ProviderIcon from strix-app's AddRepositoryDialog. The dialog itself
|
||||
// (and its next/link dependency) is dropped; the IssueSidebar only needs this SVG
|
||||
// switch to badge a finding's source-control provider. Web-app targets resolve to
|
||||
// provider === null and never reach here (they render a globe icon instead).
|
||||
import { Github, Gitlab } from "lucide-react";
|
||||
|
||||
function BitbucketIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
||||
<path d="M2.65 3a.72.72 0 0 0-.72.83l2.86 17.39a.98.98 0 0 0 .96.82h13.72a.72.72 0 0 0 .72-.6l2.86-17.4A.72.72 0 0 0 22.3 3H2.65Zm12.1 12.53H9.3L8.06 8.9h7.8l-1.11 6.63Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderIcon({ provider, className }: { provider: string; className?: string }) {
|
||||
const cls = className ?? "w-4 h-4";
|
||||
if (provider === "gitlab") return <Gitlab className={`${cls} text-orange-400`} />;
|
||||
if (provider === "bitbucket") return <BitbucketIcon className={`${cls} text-blue-400`} />;
|
||||
return <Github className={`${cls} text-white`} />;
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Mail, ShieldCheck, Lock, Copy, Check, Loader2, AlertCircle, ArrowLeft } from "lucide-react";
|
||||
import {
|
||||
otpStart,
|
||||
otpVerify,
|
||||
sendReport,
|
||||
type AuthStatus,
|
||||
} from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* The email-report / email-verification flow rendered as its own page (not a
|
||||
* modal, so it never floats over another surface). Report mode ends in the
|
||||
* one-time password panel; verify mode just confirms the email and returns to
|
||||
* the caller. The page unmounts when you navigate away, so state resets each
|
||||
* time it is opened.
|
||||
*/
|
||||
|
||||
type Step = "disclosure" | "email" | "code" | "sending" | "password";
|
||||
|
||||
interface EmailReportViewProps {
|
||||
activeRun: string | null;
|
||||
auth: AuthStatus | null;
|
||||
purpose: "report" | "verify";
|
||||
/**
|
||||
* Skip the report disclosure and start the flow directly (used by the
|
||||
* Overview CTA, which already states the tradeoff). Unverified users land on
|
||||
* the email step; already-verified users send immediately.
|
||||
*/
|
||||
skipDisclosure?: boolean;
|
||||
/** Refresh auth + runs after a successful verify (lifts state to App). */
|
||||
onAuthChanged: () => void;
|
||||
/** Leave this page (report "Done" -> overview; verify success -> history). */
|
||||
onExit: (dest: "overview" | "history") => void;
|
||||
}
|
||||
|
||||
const OTP_START_ERRORS: Record<string, string> = {
|
||||
work_email_required: "Please use your work email, not a personal one.",
|
||||
rate_limited: "Too many requests. Wait a minute and try again.",
|
||||
invalid_email: "That email does not look right. Check it and try again.",
|
||||
unavailable: "The email service is unavailable right now. Try again shortly.",
|
||||
};
|
||||
|
||||
const SEND_ERRORS: Record<string, string> = {
|
||||
forbidden: "This email was unsubscribed from Strix, so we cannot send to it.",
|
||||
too_large: "This report is too large to email. Try a smaller run.",
|
||||
unavailable: "The email service is unavailable right now. Try again shortly.",
|
||||
};
|
||||
|
||||
// A small set of common personal providers for instant client-side feedback.
|
||||
// The relay is authoritative (it checks the full free-email-domains list).
|
||||
const COMMON_FREE_DOMAINS = new Set([
|
||||
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "outlook.com",
|
||||
"hotmail.com", "live.com", "icloud.com", "me.com", "aol.com", "proton.me",
|
||||
"protonmail.com", "gmx.com", "mail.com",
|
||||
]);
|
||||
|
||||
export default function EmailReportView({
|
||||
activeRun,
|
||||
auth,
|
||||
purpose,
|
||||
skipDisclosure = false,
|
||||
onAuthChanged,
|
||||
onExit,
|
||||
}: EmailReportViewProps) {
|
||||
const verified = auth?.verified === true;
|
||||
const verifyOnly = purpose === "verify";
|
||||
// Verify mode (and the Overview CTA, which skips the disclosure) start on the
|
||||
// email step; a verified user who skips the disclosure sends immediately.
|
||||
const [step, setStep] = useState<Step>(() => {
|
||||
if (verifyOnly) return "email";
|
||||
if (skipDisclosure) return verified ? "sending" : "email";
|
||||
return "disclosure";
|
||||
});
|
||||
const [email, setEmail] = useState(auth?.email ?? "");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [filename, setFilename] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [sentTo, setSentTo] = useState("");
|
||||
const autoSentRef = useRef(false);
|
||||
|
||||
const doSend = async () => {
|
||||
setStep("sending");
|
||||
setError(null);
|
||||
const result = await sendReport(activeRun);
|
||||
if (result.ok) {
|
||||
track("report_sent");
|
||||
setPassword(result.password);
|
||||
setFilename(result.filename);
|
||||
setStep("password");
|
||||
return;
|
||||
}
|
||||
if (result.error === "reverify" || result.error === "unverified") {
|
||||
setNotice("Your verification expired. Enter your email to verify again.");
|
||||
setStep("email");
|
||||
return;
|
||||
}
|
||||
setError(SEND_ERRORS[result.error] ?? "Could not send the report. Try again.");
|
||||
setStep("disclosure");
|
||||
};
|
||||
|
||||
const startFlow = () => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
if (verified) void doSend();
|
||||
else setStep("email");
|
||||
};
|
||||
|
||||
// A verified user who skipped the disclosure (Overview CTA) sends on arrival.
|
||||
useEffect(() => {
|
||||
if (!verifyOnly && skipDisclosure && verified && !autoSentRef.current) {
|
||||
autoSentRef.current = true;
|
||||
void doSend();
|
||||
}
|
||||
// Run once on mount; the page remounts fresh each time it is opened.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const submitEmail = async () => {
|
||||
const value = email.trim();
|
||||
if (!value) {
|
||||
setError("Enter your email to continue.");
|
||||
return;
|
||||
}
|
||||
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
|
||||
if (COMMON_FREE_DOMAINS.has(domain)) {
|
||||
track("work_email_required");
|
||||
setError(OTP_START_ERRORS.work_email_required);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpStart(value);
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
track("email_submitted", { purpose });
|
||||
setNotice(`We sent a 6-digit code to ${value}.`);
|
||||
setStep("code");
|
||||
} else {
|
||||
if (result.error === "work_email_required") track("work_email_required");
|
||||
setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const submitCode = async () => {
|
||||
const value = code.trim();
|
||||
if (value.length < 4) {
|
||||
setError("Enter the 6-digit code from your email.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpVerify(email.trim(), value);
|
||||
setBusy(false);
|
||||
if (!result.verified) {
|
||||
setError("That code did not match. Check it and try again.");
|
||||
return;
|
||||
}
|
||||
track("email_verified", { purpose });
|
||||
setSentTo(result.email);
|
||||
onAuthChanged();
|
||||
if (verifyOnly) onExit("history");
|
||||
else void doSend();
|
||||
};
|
||||
|
||||
const copyPassword = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(password);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard may be unavailable; the password is visible to copy manually */
|
||||
}
|
||||
};
|
||||
|
||||
const confirmationEmail = sentTo || auth?.email || email.trim();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md space-y-4">
|
||||
<button
|
||||
onClick={() => onExit(verifyOnly ? "history" : "overview")}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{verifyOnly ? "Back to past runs" : "Back to results"}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{verifyOnly ? "Verify your email" : "Email report"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<p className="mb-4 text-xs text-[#666]">
|
||||
{verifyOnly
|
||||
? "We send a one-time code to confirm it is you."
|
||||
: "Verified by a one-time code sent to your email"}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && !error && step !== "password" && (
|
||||
<p className="mb-4 text-xs text-[#888]">{notice}</p>
|
||||
)}
|
||||
|
||||
{step === "disclosure" && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="space-y-2.5 rounded-lg p-3.5"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">
|
||||
Viewing stays local and nothing is uploaded. Emailing is an explicit
|
||||
opt-in: we send an <span className="text-white">encrypted PDF</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Lock className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">
|
||||
The report is encrypted with a password that only you hold. Strix
|
||||
cannot read it and never stores it. We collect only your email so we
|
||||
can send it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={startFlow}
|
||||
className="w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
{verified ? "Email me the encrypted PDF" : "Continue with your email"}
|
||||
</button>
|
||||
{verified && auth?.email && (
|
||||
<p className="text-center text-xs text-[#666]">Sending to {auth.email}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "email" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitEmail();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
<span className="mt-1.5 block text-[11px] text-[#666]">Use your work email.</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Send me a code
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === "code" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitCode();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">6-digit code</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{verifyOnly ? "Verify" : "Verify and send"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("email");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}}
|
||||
className="w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]"
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === "sending" && (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" aria-hidden="true" />
|
||||
<p className="text-sm text-[#aaa]">Generating and encrypting locally...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "password" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">
|
||||
<Check className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-xs text-emerald-200">
|
||||
Sent to {confirmationEmail}. Open the attached PDF with this password.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your one-time password</span>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg bg-black p-3"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<code className="flex-1 break-all font-mono text-base text-white">{password}</code>
|
||||
<button
|
||||
onClick={copyPassword}
|
||||
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs text-[#aaa] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#666]">
|
||||
Save this now. Strix never stores it, so we cannot show it again. File:{" "}
|
||||
<span className="font-mono text-[#888]">{filename}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onExit("overview")}
|
||||
className="w-full cursor-pointer rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from "react";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { otpStart, otpVerify } from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Compact inline email -> 6-digit-code verify flow. Unlike EmailReportView this
|
||||
* has no page chrome, no report send, and no password panel: it just confirms
|
||||
* the email so the past-runs list can unlock in place. On success it calls
|
||||
* `onVerified` (the parent refreshes auth + runs).
|
||||
*/
|
||||
|
||||
const OTP_START_ERRORS: Record<string, string> = {
|
||||
work_email_required: "Please use your work email, not a personal one.",
|
||||
rate_limited: "Too many requests. Wait a minute and try again.",
|
||||
invalid_email: "That email does not look right. Check it and try again.",
|
||||
unavailable: "The email service is unavailable right now. Try again shortly.",
|
||||
};
|
||||
|
||||
// A small set of common personal providers for instant client-side feedback.
|
||||
// The relay is authoritative (it checks the full free-email-domains list).
|
||||
const COMMON_FREE_DOMAINS = new Set([
|
||||
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "outlook.com",
|
||||
"hotmail.com", "live.com", "icloud.com", "me.com", "aol.com", "proton.me",
|
||||
"protonmail.com", "gmx.com", "mail.com",
|
||||
]);
|
||||
|
||||
export default function EmailVerifyInline({ onVerified }: { onVerified: () => void }) {
|
||||
const [step, setStep] = useState<"email" | "code">("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const submitEmail = async () => {
|
||||
const value = email.trim();
|
||||
if (!value) {
|
||||
setError("Enter your email to continue.");
|
||||
return;
|
||||
}
|
||||
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
|
||||
if (COMMON_FREE_DOMAINS.has(domain)) {
|
||||
track("work_email_required");
|
||||
setError(OTP_START_ERRORS.work_email_required);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpStart(value);
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
track("email_submitted", { purpose: "verify" });
|
||||
setNotice(`We sent a 6-digit code to ${value}.`);
|
||||
setStep("code");
|
||||
} else {
|
||||
if (result.error === "work_email_required") track("work_email_required");
|
||||
setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const submitCode = async () => {
|
||||
const value = code.trim();
|
||||
if (value.length < 4) {
|
||||
setError("Enter the 6-digit code from your email.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpVerify(email.trim(), value);
|
||||
setBusy(false);
|
||||
if (!result.verified) {
|
||||
setError("That code did not match. Check it and try again.");
|
||||
return;
|
||||
}
|
||||
track("email_verified", { purpose: "verify" });
|
||||
onVerified();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-5 max-w-sm text-left">
|
||||
{error && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && !error && <p className="mb-3 text-xs text-[#888]">{notice}</p>}
|
||||
|
||||
{step === "email" ? (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitEmail();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
<span className="mt-1.5 block text-[11px] text-[#666]">Use your work email.</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Send me a code
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitCode();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">6-digit code</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Verify
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("email");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}}
|
||||
className="w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]"
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
WandSparkles,
|
||||
Puzzle,
|
||||
Users,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import type { ProFeature } from "@/lib/pro-features";
|
||||
import { ProTag } from "@/components/ProCta";
|
||||
|
||||
/**
|
||||
* In-app upsell page for a single platform feature. Modeled on the cloud app's
|
||||
* Networks upsell: a centered bordered card with an icon medallion, tier pill,
|
||||
* headline, one-line description, a shared "Included in Strix Pro" bullet list,
|
||||
* then a primary sign-up CTA and a secondary link to all plans.
|
||||
*/
|
||||
|
||||
const INCLUDED = [
|
||||
{
|
||||
icon: CalendarClock,
|
||||
text: "Continuous coverage: scheduled pentests and attack surface monitoring",
|
||||
},
|
||||
{ icon: WandSparkles, text: "One-click autofix that opens a retested pull request" },
|
||||
{ icon: Puzzle, text: "Two-way sync to Jira, Linear, and Slack" },
|
||||
{ icon: Users, text: "Your whole team, with roles and shared history" },
|
||||
];
|
||||
|
||||
export default function FeatureDetail({ feature }: { feature: ProFeature }) {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<div className="rounded-2xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
|
||||
<div
|
||||
className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<ProTag label={feature.tier} />
|
||||
</div>
|
||||
|
||||
<h2 className="mt-3 text-2xl font-semibold text-white">{feature.headline}</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[#888]">{feature.description}</p>
|
||||
|
||||
<div
|
||||
className="mt-6 rounded-xl p-4 text-left"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#666]">
|
||||
Included in Strix Pro
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{INCLUDED.map((item) => {
|
||||
const BulletIcon = item.icon;
|
||||
return (
|
||||
<li key={item.text} className="flex items-start gap-2.5">
|
||||
<BulletIcon
|
||||
className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm text-[#aaa]">{item.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col items-center gap-3">
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, feature.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(feature.slug, "feature_page")}
|
||||
className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Start free
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl(PRICING_URL, feature.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(feature.slug, "feature_page_plans")}
|
||||
className="inline-flex items-center gap-1 text-xs text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
View all plans
|
||||
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface IssueSeveritySummaryFindings {
|
||||
total: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
interface IssueSeveritySummaryProps {
|
||||
findings: IssueSeveritySummaryFindings;
|
||||
className?: string;
|
||||
/** Noun for the total count (e.g. "issues", "CVEs"). Defaults to "issues". */
|
||||
unit?: string;
|
||||
/** Optional content rendered at the end of the count row (e.g. a KEV badge). */
|
||||
trailing?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SEVERITIES = [
|
||||
{ key: "critical", label: "critical", dotClass: "bg-red-500", textClass: "text-red-500" },
|
||||
{ key: "high", label: "high", dotClass: "bg-orange-500", textClass: "text-orange-500" },
|
||||
{ key: "medium", label: "medium", dotClass: "bg-yellow-500", textClass: "text-yellow-500" },
|
||||
{ key: "low", label: "low", dotClass: "bg-blue-500", textClass: "text-blue-500" },
|
||||
] as const;
|
||||
|
||||
export function IssueSeveritySummary({
|
||||
findings,
|
||||
className,
|
||||
unit = "issues",
|
||||
trailing,
|
||||
}: IssueSeveritySummaryProps) {
|
||||
if (findings.total <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-semibold text-white tabular-nums">{findings.total}</span>
|
||||
<span className="text-sm text-[#666]">{unit}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
{SEVERITIES.map(({ key, label, dotClass, textClass }) => {
|
||||
const count = findings[key];
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<div className={cn("w-2 h-2 rounded-full", dotClass)} aria-hidden="true" />
|
||||
<span className={cn("text-sm tabular-nums", textClass)}>{count}</span>
|
||||
<span className="text-xs text-[#555]">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{trailing ? <div className="flex items-center gap-2">{trailing}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 rounded-full bg-[#222] overflow-hidden flex">
|
||||
{SEVERITIES.map(({ key, dotClass }) => {
|
||||
const count = findings[key];
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn("h-full", dotClass)}
|
||||
style={{ width: `${(count / findings.total) * 100}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState } from "react";
|
||||
import { History, ChevronRight, Terminal } from "lucide-react";
|
||||
import type { RunListEntry, RunsPayload, RunSeverityCounts } from "@/data/serverSource";
|
||||
import { runTitle } from "@/lib/target-utils";
|
||||
import { trackCta } from "@/lib/cta";
|
||||
import EmailVerifyInline from "@/components/EmailVerifyInline";
|
||||
|
||||
/**
|
||||
* "Past runs" panel. Unverified users see a tease with the run count and a
|
||||
* verify affordance (the launched run stays fully visible; the CLI
|
||||
* `strix view <name>` still works). Verified users get the full history and can
|
||||
* switch the active run, which threads ?run=<name> through the data fetches.
|
||||
*/
|
||||
|
||||
const SEV = [
|
||||
{ key: "critical", dot: "bg-red-500", text: "text-red-500" },
|
||||
{ key: "high", dot: "bg-orange-500", text: "text-orange-500" },
|
||||
{ key: "medium", dot: "bg-yellow-500", text: "text-yellow-500" },
|
||||
{ key: "low", dot: "bg-blue-500", text: "text-blue-500" },
|
||||
] as const;
|
||||
|
||||
function SeverityChips({ counts }: { counts: RunSeverityCounts }) {
|
||||
const shown = SEV.filter((s) => counts[s.key] > 0);
|
||||
if (shown.length === 0) {
|
||||
return <span className="text-xs text-[#555]">No findings</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{shown.map((s) => (
|
||||
<div key={s.key} className="flex items-center gap-1.5">
|
||||
<span className={`h-2 w-2 rounded-full ${s.dot}`} aria-hidden="true" />
|
||||
<span className={`text-xs tabular-nums ${s.text}`}>{counts[s.key]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T");
|
||||
const d = new Date(normalized);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative time ("just now" / "5m ago" / "3h ago" / "2d ago"), falling back to
|
||||
* the absolute date for anything older than a week (mirrors the pro app).
|
||||
*/
|
||||
function formatTimeAgo(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T");
|
||||
const d = new Date(normalized);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return formatDate(iso);
|
||||
}
|
||||
|
||||
interface PastRunsViewProps {
|
||||
runs: RunsPayload | null;
|
||||
activeRun: string | null;
|
||||
onSelectRun: (name: string) => void;
|
||||
onVerified: () => void;
|
||||
}
|
||||
|
||||
export default function PastRunsView({
|
||||
runs,
|
||||
activeRun,
|
||||
onSelectRun,
|
||||
onVerified,
|
||||
}: PastRunsViewProps) {
|
||||
const count = runs?.count ?? 0;
|
||||
const [showVerify, setShowVerify] = useState(false);
|
||||
|
||||
if (!runs || runs.locked) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
|
||||
<div
|
||||
className="mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
<History className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-white">Browse every run on this machine</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-md text-sm text-[#888]">
|
||||
You have {count} past {count === 1 ? "run" : "runs"} on this machine.
|
||||
</p>
|
||||
{showVerify ? (
|
||||
<>
|
||||
<p className="mx-auto mt-3 max-w-sm text-xs text-[#666]">
|
||||
Verify your email with a one-time code to unlock the full history.
|
||||
</p>
|
||||
<EmailVerifyInline onVerified={onVerified} />
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
trackCta("history_unlock", "past_runs");
|
||||
setShowVerify(true);
|
||||
}}
|
||||
className="mt-4 cursor-pointer rounded-lg bg-white px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
View runs
|
||||
</button>
|
||||
)}
|
||||
<p className="mt-4 flex items-center justify-center gap-1.5 text-xs text-[#555]">
|
||||
<Terminal className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Or open one from the CLI with{" "}
|
||||
<code className="font-mono text-[#888]">strix view <name></code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runs.runs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
|
||||
No past runs found on this machine yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{runs.runs.map((run: RunListEntry) => {
|
||||
const active = run.name === activeRun;
|
||||
const date = formatTimeAgo(run.start_time) ?? formatTimeAgo(run.end_time);
|
||||
const title = runTitle(run.target, run.name);
|
||||
return (
|
||||
<button
|
||||
key={run.name}
|
||||
onClick={() => onSelectRun(run.name)}
|
||||
className={`group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
|
||||
active
|
||||
? "border-[#444] bg-[rgba(255,255,255,0.04)]"
|
||||
: "border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-white">{title}</span>
|
||||
{active && (
|
||||
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400" style={{ border: "1px solid rgba(16,185,129,0.3)" }}>
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[#666]">
|
||||
{run.scan_mode && <span className="capitalize">{run.scan_mode}</span>}
|
||||
{run.scan_mode && (date || run.status) && <span className="text-[#333]">·</span>}
|
||||
{date && <span>{date}</span>}
|
||||
{date && run.status && <span className="text-[#333]">·</span>}
|
||||
{run.status && <span className="capitalize">{run.status}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<SeverityChips counts={run.severity_counts} />
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useState } from "react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import type { ProFeature } from "@/lib/pro-features";
|
||||
|
||||
/**
|
||||
* Shared Pro CTA primitives. Every Pro item is a direct link-out to the cloud
|
||||
* sign-up in a new tab with a hover tooltip one-liner (no modal, no lock icon).
|
||||
* Built once here and reused by the sidebar Platform section, the top upsell
|
||||
* row, and the inline CTAs in the tabs.
|
||||
*/
|
||||
|
||||
/** Small tier pill ("Pro" or "Enterprise"). Deliberately not a padlock. */
|
||||
export function ProTag({ label = "Pro", className = "" }: { label?: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[#aaa] ${className}`}
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight hover tooltip. Wraps a trigger and reveals `text` above it on
|
||||
* hover/focus. Plain Tailwind + local state (no radix vendored).
|
||||
*/
|
||||
export function Tooltip({
|
||||
text,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
text: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<span
|
||||
className={`relative inline-flex ${className}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
{children}
|
||||
{open && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-[240px] -translate-x-1/2 rounded-md px-2.5 py-1.5 text-xs text-[#ddd] shadow-lg"
|
||||
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProItem {
|
||||
title: string;
|
||||
desc: string;
|
||||
slug: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card-style Pro feature tile: icon + name + one-liner + Pro tag + arrow.
|
||||
* Used in the top upsell row and inline CTA grids.
|
||||
*/
|
||||
export function ProTile({ item, surface }: { item: ProItem; surface?: string }) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, item.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(item.slug, surface)}
|
||||
title={item.desc}
|
||||
className="group block cursor-pointer rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-4 text-left transition-colors hover:border-[#444]"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ProTag />
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-white">{item.title}</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">{item.desc}</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar-row Pro item: a two-line row (icon + label + short one-liner
|
||||
* underneath) with a small right-aligned tier tag. Opens the in-app
|
||||
* FeatureDetail view via onClick (no link-out) so it sits uniformly beside the
|
||||
* run/local rows in the themed nav list.
|
||||
*/
|
||||
export function ProNavItem({
|
||||
feature,
|
||||
active,
|
||||
onClick,
|
||||
collapsed = false,
|
||||
}: {
|
||||
feature: ProFeature;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
const Icon = feature.icon;
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={`${feature.title} (${feature.tier})`}
|
||||
className={`group flex w-full cursor-pointer items-center justify-center rounded-md px-2.5 py-2 transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`group flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2.5 py-1.5 text-left transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex-1 truncate text-sm">{feature.title}</span>
|
||||
<ProTag label={feature.tier} />
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{feature.navDesc}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Pro CTA button (compact). Used in the finding detail and per-surface
|
||||
* rows where a full card is too heavy.
|
||||
*/
|
||||
export function ProInlineCta({
|
||||
label,
|
||||
desc,
|
||||
slug,
|
||||
icon: Icon,
|
||||
surface,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
slug: string;
|
||||
icon: React.ElementType;
|
||||
surface?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip text={desc}>
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(slug, surface)}
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-[#222] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-sm text-[#aaa] transition-colors hover:border-[#444] hover:text-white"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
<ProTag className="ml-0.5" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
FileText,
|
||||
Bug,
|
||||
Waypoints,
|
||||
History,
|
||||
Mail,
|
||||
ArrowUpRight,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { ProNavItem } from "@/components/ProCta";
|
||||
import { FEATURES, PLATFORM_ORDER } from "@/lib/pro-features";
|
||||
import type { View } from "@/App";
|
||||
|
||||
/**
|
||||
* Persistent left rail. A single, ungrouped, ordered list of uniform two-line
|
||||
* rows (icon + label + short one-liner): the current run's views, the local
|
||||
* run-history + email-report actions, then the platform features. No section
|
||||
* headers. Tier is shown only by the inline Pro/Enterprise tag on platform
|
||||
* rows. Matches App.tsx's dark palette.
|
||||
*
|
||||
* Can collapse to a narrow icon-only rail; the collapsed state persists in
|
||||
* localStorage and each icon row keeps a `title` tooltip so the labels stay
|
||||
* discoverable.
|
||||
*/
|
||||
|
||||
const COLLAPSE_KEY = "strix_viewer_sidebar_collapsed";
|
||||
|
||||
interface SidebarProps {
|
||||
view: View;
|
||||
onSelectView: (view: View) => void;
|
||||
activeFeature: string | null;
|
||||
onSelectFeature: (slug: string) => void;
|
||||
issuesCount: number;
|
||||
agentCount: number;
|
||||
runCount: number;
|
||||
verified: boolean;
|
||||
email: string | null;
|
||||
onOpenEmail: () => void;
|
||||
onOpenHistory: () => void;
|
||||
onForget: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
view,
|
||||
onSelectView,
|
||||
activeFeature,
|
||||
onSelectFeature,
|
||||
issuesCount,
|
||||
agentCount,
|
||||
runCount,
|
||||
verified,
|
||||
email,
|
||||
onOpenEmail,
|
||||
onOpenHistory,
|
||||
onForget,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setCollapsed(localStorage.getItem(COLLAPSE_KEY) === "1");
|
||||
} catch {
|
||||
/* localStorage may be unavailable; default to expanded */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`hidden flex-shrink-0 border-r border-[#222] lg:block ${collapsed ? "w-14" : "w-72"}`}
|
||||
>
|
||||
<div className="sticky top-0 flex h-screen flex-col overflow-y-auto px-3 py-4">
|
||||
{/* Header: wordmark + Explore full platform + signed-in chip */}
|
||||
<div className="px-1.5">
|
||||
<div className={`flex items-center ${collapsed ? "flex-col gap-2" : "justify-between"}`}>
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<img src="./logo.png" alt="Strix" className="h-8 w-10 object-cover" />
|
||||
{!collapsed && (
|
||||
<span className="text-base font-medium tracking-tight text-white">Strix</span>
|
||||
)}
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleCollapsed}
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
className="flex-shrink-0 cursor-pointer rounded-md p-1.5 text-[#666] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeft className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "sidebar_start_free")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("sidebar_start_free", "sidebar")}
|
||||
title="Explore full platform"
|
||||
className={`mt-3 flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-white font-semibold text-black transition-opacity hover:opacity-90 ${
|
||||
collapsed ? "px-0 py-2" : "px-3 py-2 text-sm"
|
||||
}`}
|
||||
>
|
||||
{!collapsed && "Explore full platform"}
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
{verified && email && (
|
||||
collapsed ? (
|
||||
<div
|
||||
className="mt-2.5 flex items-center justify-center rounded-lg py-2"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
title={`Linked email: ${email}`}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="mt-2.5 flex items-center gap-2 rounded-lg px-2.5 py-2"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[11px] text-[#666]">Linked email</p>
|
||||
<p className="truncate text-xs text-[#aaa]" title={email}>{email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onForget}
|
||||
title="Forget this email on this machine"
|
||||
className="flex-shrink-0 cursor-pointer text-[#666] transition-colors hover:text-white"
|
||||
aria-label="Forget"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* One single ordered list, no section headers. */}
|
||||
<div className="mt-6 space-y-0.5">
|
||||
<NavItem
|
||||
icon={FileText}
|
||||
label="Overview"
|
||||
desc="This run's executive report"
|
||||
active={view === "overview"}
|
||||
onClick={() => onSelectView("overview")}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<NavItem
|
||||
icon={Bug}
|
||||
label="Issues"
|
||||
desc="Findings from this run"
|
||||
count={issuesCount > 0 ? issuesCount : undefined}
|
||||
active={view === "issues"}
|
||||
onClick={() => onSelectView("issues")}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
{agentCount > 0 && (
|
||||
<NavItem
|
||||
icon={Waypoints}
|
||||
label="Agents"
|
||||
desc="What each agent did"
|
||||
count={agentCount}
|
||||
active={view === "agents"}
|
||||
onClick={() => onSelectView("agents")}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={History}
|
||||
label="Past runs"
|
||||
desc="Every run on this machine"
|
||||
count={runCount > 0 ? runCount : undefined}
|
||||
active={view === "history"}
|
||||
onClick={onOpenHistory}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<NavItem
|
||||
icon={Mail}
|
||||
label="Email report"
|
||||
desc="Get an encrypted PDF by email"
|
||||
active={view === "email"}
|
||||
onClick={onOpenEmail}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
|
||||
{PLATFORM_ORDER.map((slug) => {
|
||||
const feature = FEATURES[slug];
|
||||
if (!feature) return null;
|
||||
return (
|
||||
<ProNavItem
|
||||
key={slug}
|
||||
feature={feature}
|
||||
active={view === "feature" && activeFeature === slug}
|
||||
onClick={() => onSelectFeature(slug)}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
desc,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
collapsed = false,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
desc: string;
|
||||
count?: number;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={count != null ? `${label} (${count})` : label}
|
||||
className={`flex w-full cursor-pointer items-center justify-center rounded-md px-2.5 py-2 transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2.5 py-1.5 text-left transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex-1 truncate text-sm">{label}</span>
|
||||
{count != null && <span className="text-xs text-[#666] tabular-nums">{count}</span>}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{desc}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { AgentTranscript } from "./AgentTranscript";
|
||||
import { ScanPromptComposer } from "./ScanPromptComposer";
|
||||
import type { TranscriptAgent, TranscriptEvent } from "@/data/serverSource";
|
||||
|
||||
/** Status -> the small leading dot color, matching the graph node styling. */
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
completed: "bg-emerald-400",
|
||||
running: "bg-blue-400",
|
||||
waiting: "bg-yellow-400",
|
||||
stopped: "bg-[#888]",
|
||||
crashed: "bg-red-400",
|
||||
failed: "bg-red-400",
|
||||
};
|
||||
|
||||
/** Consider the user "at the bottom" within this many px. */
|
||||
const NEAR_BOTTOM_PX = 80;
|
||||
|
||||
/**
|
||||
* Overlay modal showing a single agent's full transcript. Matches the cloud
|
||||
* app: a fixed-size panel with a pinned header (status dot + agent name), the
|
||||
* transcript scrolling beneath it, and a footer. Auto-scrolls to follow new
|
||||
* activity while the user is near the bottom (so a live run trails). Closes on
|
||||
* backdrop click, the X button, or Escape.
|
||||
*/
|
||||
export function AgentDetailModal({
|
||||
agent,
|
||||
events,
|
||||
steerable,
|
||||
onClose,
|
||||
}: {
|
||||
agent: TranscriptAgent;
|
||||
events: TranscriptEvent[];
|
||||
steerable: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const nearBottom = useRef(false);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
nearBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
|
||||
}, []);
|
||||
|
||||
// Follow new activity when the user is near the bottom (live trailing).
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !nearBottom.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 sm:p-8"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Agent ${agent.name}`}
|
||||
>
|
||||
<div
|
||||
className="relative flex h-[80vh] w-full max-w-5xl flex-col rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#222] px-5 py-3.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 flex-shrink-0 rounded-full ${STATUS_DOT[agent.status] ?? "bg-[#888]"}`}
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold text-white">{agent.name}</span>
|
||||
<span className="flex-shrink-0 font-mono text-xs text-[#555]">{agent.id}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex-shrink-0 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-5">
|
||||
<AgentTranscript agent={agent} events={events} showHeader={false} />
|
||||
</div>
|
||||
|
||||
{steerable && (
|
||||
<div className="border-t border-[#222] px-5 py-3">
|
||||
<ScanPromptComposer
|
||||
agents={[agent]}
|
||||
fixedAgentId={agent.id}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentDetailModal;
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from "@xyflow/react";
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import AgentNodeComponent from "./AgentNode";
|
||||
import GraphSkeleton from "./GraphSkeleton";
|
||||
import type { AgentNode } from "@/types/events";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
const NODE_WIDTH = 260;
|
||||
const NODE_HEIGHT = 80;
|
||||
|
||||
const nodeTypes = { agentNode: AgentNodeComponent };
|
||||
|
||||
function getLayoutedElements(
|
||||
agents: Map<string, AgentNode>,
|
||||
selectedAgentId: string | null
|
||||
) {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
for (const [id, agent] of agents) {
|
||||
g.setNode(id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
nodes.push({
|
||||
id,
|
||||
type: "agentNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...agent, isSelected: id === selectedAgentId },
|
||||
});
|
||||
|
||||
if (agent.parentId && agents.has(agent.parentId)) {
|
||||
const edgeId = `${agent.parentId}->${id}`;
|
||||
g.setEdge(agent.parentId, id);
|
||||
edges.push({
|
||||
id: edgeId,
|
||||
source: agent.parentId,
|
||||
target: id,
|
||||
style: { stroke: "#2a2a2a", strokeWidth: 1.5 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
for (const node of nodes) {
|
||||
const pos = g.node(node.id);
|
||||
if (pos) {
|
||||
node.position = {
|
||||
x: pos.x - NODE_WIDTH / 2,
|
||||
y: pos.y - NODE_HEIGHT / 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
const ZOOM_DURATION = 300;
|
||||
|
||||
|
||||
/** Centers viewport on the root node (no parentId) at a fixed zoom — only once on first load */
|
||||
function CenterOnRoot({ nodes }: { nodes: Node[] }) {
|
||||
const { setCenter } = useReactFlow();
|
||||
const hasCentered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (nodes.length > 0 && !hasCentered.current) {
|
||||
const root = nodes.find((n) => !(n.data as Record<string, unknown>).parentId);
|
||||
const target = root ?? nodes[0];
|
||||
hasCentered.current = true;
|
||||
const cx = target.position.x + NODE_WIDTH / 2;
|
||||
const cy = target.position.y + NODE_HEIGHT / 2;
|
||||
setTimeout(() => setCenter(cx, cy, { zoom: 0.85, duration: 400 }), 60);
|
||||
}
|
||||
}, [nodes, setCenter]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function SmoothControls() {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
return (
|
||||
<Controls
|
||||
position="bottom-right"
|
||||
showZoom={false}
|
||||
showFitView={false}
|
||||
showInteractive={false}
|
||||
className="!bg-transparent !border-none !shadow-none"
|
||||
>
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border border-[#222]">
|
||||
<button onClick={() => zoomIn({ duration: ZOOM_DURATION })} className="flex items-center justify-center w-7 h-7 bg-[#111] text-white hover:bg-[#2a2a2a] transition-colors" title="Zoom in">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-3.5 h-3.5"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</button>
|
||||
<button onClick={() => zoomOut({ duration: ZOOM_DURATION })} className="flex items-center justify-center w-7 h-7 bg-[#111] text-white hover:bg-[#2a2a2a] border-y border-[#222] transition-colors" title="Zoom out">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-3.5 h-3.5"><path d="M5 12h14" /></svg>
|
||||
</button>
|
||||
<button onClick={() => fitView({ padding: 0.3, duration: ZOOM_DURATION })} className="flex items-center justify-center w-7 h-7 bg-[#111] text-white hover:bg-[#2a2a2a] transition-colors" title="Fit view">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-3.5 h-3.5"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</Controls>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentGraphProps {
|
||||
agents: Map<string, AgentNode>;
|
||||
selectedAgentId: string | null;
|
||||
onSelectAgent: (id: string | null) => void;
|
||||
eventsLoaded?: boolean;
|
||||
eventsEmpty?: boolean;
|
||||
scanCompleted?: boolean;
|
||||
}
|
||||
|
||||
export default function AgentGraph({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
eventsLoaded,
|
||||
eventsEmpty,
|
||||
scanCompleted,
|
||||
}: AgentGraphProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.size === 0) return;
|
||||
const { nodes: ln, edges: le } = getLayoutedElements(agents, selectedAgentId);
|
||||
setNodes(ln);
|
||||
setEdges(le);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agents.size, setNodes, setEdges]);
|
||||
|
||||
// Sync agent data (status, name, etc.) into existing nodes without re-layout
|
||||
useEffect(() => {
|
||||
if (agents.size === 0) return;
|
||||
setNodes((nds) =>
|
||||
nds.map((n) => {
|
||||
const agent = agents.get(n.id);
|
||||
if (!agent) return n;
|
||||
return { ...n, data: { ...agent, isSelected: n.id === selectedAgentId } };
|
||||
})
|
||||
);
|
||||
}, [agents, selectedAgentId, setNodes]);
|
||||
|
||||
const nodeClickedRef = useRef(false);
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(_: React.MouseEvent, node: Node) => {
|
||||
nodeClickedRef.current = true;
|
||||
onSelectAgent(node.id);
|
||||
},
|
||||
[onSelectAgent]
|
||||
);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
if (nodeClickedRef.current) {
|
||||
nodeClickedRef.current = false;
|
||||
return;
|
||||
}
|
||||
onSelectAgent(null);
|
||||
}, [onSelectAgent]);
|
||||
|
||||
// Convex responded, zero events — show empty state (not skeleton)
|
||||
if (agents.size === 0 && eventsLoaded && eventsEmpty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-4">
|
||||
<div className="w-10 h-10 mb-3 rounded-full bg-[#111] flex items-center justify-center">
|
||||
{scanCompleted ? (
|
||||
<svg className="w-5 h-5 text-[#444]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[#555]">
|
||||
{scanCompleted
|
||||
? "Agent trace data is not available for this pentest"
|
||||
: "Waiting for agent data\u2026"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showGraph = agents.size > 0;
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
{/* Skeleton overlay — fades out when graph is ready */}
|
||||
<div
|
||||
className={`absolute inset-0 z-10 transition-opacity duration-500 ${
|
||||
showGraph ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<GraphSkeleton />
|
||||
</div>
|
||||
|
||||
{/* Graph — fades in */}
|
||||
<div
|
||||
className={`h-full transition-opacity duration-500 ${
|
||||
showGraph ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={nodeTypes}
|
||||
nodesConnectable={false}
|
||||
edgesFocusable={false}
|
||||
edgesReconnectable={false}
|
||||
minZoom={0.15}
|
||||
maxZoom={1.5}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="bg-black"
|
||||
>
|
||||
<Background color="#111" gap={20} />
|
||||
<CenterOnRoot nodes={nodes} />
|
||||
<SmoothControls />
|
||||
<MiniMap
|
||||
position="bottom-left"
|
||||
nodeColor={(n) => {
|
||||
const status = (n.data as Record<string, unknown>)?.status as string;
|
||||
if (status === "running") return "#3b82f6";
|
||||
if (status === "completed") return "#10b981";
|
||||
if (status === "failed" || status === "error") return "#ef4444";
|
||||
return "#555";
|
||||
}}
|
||||
maskColor="rgba(0,0,0,0.8)"
|
||||
style={{ width: 80, height: 50 }}
|
||||
className="!bg-[#0a0a0a] !border-[#222]"
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import type { AgentNode as AgentNodeData } from "@/types/events";
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
running: "bg-blue-500",
|
||||
completed: "bg-emerald-500",
|
||||
failed: "bg-red-500",
|
||||
error: "bg-red-500",
|
||||
};
|
||||
|
||||
function AgentNodeComponent({ data, selected }: NodeProps) {
|
||||
const agent = data as unknown as AgentNodeData & { isSelected: boolean };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-[260px] rounded-lg border px-4 py-3 transition-colors ${
|
||||
agent.isSelected || selected
|
||||
? "border-white/30 bg-[#0a0a0a]"
|
||||
: "border-[#222] bg-black hover:border-[#333]"
|
||||
}`}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className={`!w-1.5 !h-1.5 !border-0 ${agent.parentId ? "!bg-[#444]" : "!bg-transparent"}`} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2 shrink-0">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full rounded-full opacity-75 ${STATUS_STYLES[agent.status] ?? "bg-gray-500"} ${
|
||||
agent.status === "running" ? "animate-ping" : ""
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative inline-flex h-2 w-2 rounded-full ${STATUS_STYLES[agent.status] ?? "bg-gray-500"}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white leading-snug line-clamp-3">
|
||||
{agent.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} className={`!w-1.5 !h-1.5 !border-0 ${agent.children && agent.children.length > 0 ? "!bg-[#444]" : "!bg-transparent"}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(AgentNodeComponent);
|
||||
@@ -0,0 +1,302 @@
|
||||
import { Component, useMemo, type ReactNode } from "react";
|
||||
import { Brain, Bot } from "lucide-react";
|
||||
import { getToolRenderer, getToolIcon } from "./tool-renderers";
|
||||
import ChatBubble from "./tool-renderers/ChatBubble";
|
||||
import type { ToolRendererProps, AgentNode as GraphAgentNode } from "@/types/events";
|
||||
import type { TranscriptAgent, TranscriptEvent } from "@/data/serverSource";
|
||||
|
||||
/* ---------- Error boundary so one bad event never blanks the transcript ---------- */
|
||||
class RendererErrorBoundary extends Component<
|
||||
{ toolName: string; children: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { toolName: string; children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<span className="text-[#555] font-semibold text-sm">
|
||||
{this.props.toolName.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function SafeToolRenderer(props: ToolRendererProps) {
|
||||
const Renderer = getToolRenderer(props.toolName);
|
||||
return (
|
||||
<RendererErrorBoundary toolName={props.toolName}>
|
||||
<Renderer {...props} />
|
||||
</RendererErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Value coercion ----------
|
||||
* args/result arrive as either a JSON object or a Python-repr string
|
||||
* ("{'thought': '...'}"). Try JSON, then a naive python->json pass, then wrap
|
||||
* the raw string so the fallback renderer can display it. Never throws. */
|
||||
function coerce(value: unknown): unknown {
|
||||
if (value == null || typeof value !== "string") return value;
|
||||
const t = value.trim();
|
||||
if (!t) return value;
|
||||
try {
|
||||
return JSON.parse(t);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
try {
|
||||
const jsonish = t
|
||||
.replace(/\bNone\b/g, "null")
|
||||
.replace(/\bTrue\b/g, "true")
|
||||
.replace(/\bFalse\b/g, "false")
|
||||
.replace(/'/g, '"');
|
||||
return JSON.parse(jsonish);
|
||||
} catch {
|
||||
return { __raw: value };
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
const c = coerce(value);
|
||||
if (c && typeof c === "object" && !Array.isArray(c)) return c as Record<string, unknown>;
|
||||
if (c == null) return {};
|
||||
return { __raw: typeof c === "string" ? c : JSON.stringify(c) };
|
||||
}
|
||||
|
||||
/** Numeric suffix of an event id ("tool_37" -> 37) for stable ordering. */
|
||||
function eventSeq(id: string): number {
|
||||
const m = /(\d+)$/.exec(id);
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
}
|
||||
|
||||
/** A chat event whose author is the human/user (vs an assistant "thinking"). */
|
||||
function isUserChat(event: TranscriptEvent): boolean {
|
||||
const role = event.data?.role;
|
||||
return event.type === "chat" && (role === "user" || role === "human");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inter-agent message deliveries land in the recipient's session as user-role
|
||||
* items prefixed with a header (see the engine's message formatter). They are
|
||||
* already represented via the sending agent's tool renderer, so we never render
|
||||
* them as chat bubbles here.
|
||||
*/
|
||||
function isInterAgentDelivery(event: TranscriptEvent): boolean {
|
||||
return isUserChat(event) && String(event.data?.content ?? "").startsWith("[Message from ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The reconstructed SDK session records every incoming user-role item for an
|
||||
* agent: its initial input (the root's assembled brief, or a subagent's spawn /
|
||||
* inherited-context prompt), inter-agent deliveries, AND genuine human steering
|
||||
* messages sent live from the viewer or TUI. We only want the last group. Given
|
||||
* an agent's events in order, hide the first user message (its initial input)
|
||||
* and every inter-agent delivery; keep the rest, which are the human's live
|
||||
* instructions, rendered as "User" bubbles.
|
||||
*/
|
||||
function hiddenUserEventIds(agentEventsInOrder: TranscriptEvent[]): Set<string> {
|
||||
const hidden = new Set<string>();
|
||||
let sawInitialInput = false;
|
||||
for (const e of agentEventsInOrder) {
|
||||
if (!isUserChat(e)) continue;
|
||||
if (isInterAgentDelivery(e)) {
|
||||
hidden.add(e.id);
|
||||
continue;
|
||||
}
|
||||
if (!sawInitialInput) {
|
||||
sawInitialInput = true;
|
||||
hidden.add(e.id);
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
completed: "text-emerald-400 border-emerald-500/30 bg-emerald-500/10",
|
||||
running: "text-blue-400 border-blue-500/30 bg-blue-500/10",
|
||||
waiting: "text-yellow-400 border-yellow-500/30 bg-yellow-500/10",
|
||||
stopped: "text-[#aaa] border-[#333] bg-[#1a1a1a]",
|
||||
crashed: "text-red-400 border-red-500/30 bg-red-500/10",
|
||||
failed: "text-red-400 border-red-500/30 bg-red-500/10",
|
||||
};
|
||||
|
||||
/** Map our engine agent statuses onto the graph node's status union. */
|
||||
function graphStatus(status: string): GraphAgentNode["status"] {
|
||||
if (status === "completed") return "completed";
|
||||
if (status === "running") return "running";
|
||||
if (status === "failed" || status === "crashed") return "failed";
|
||||
// waiting / stopped / unknown → keep the raw string; AgentNode/MiniMap fall
|
||||
// back to a neutral gray for anything they don't explicitly style.
|
||||
return status as GraphAgentNode["status"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt transcript agents + events into the Map<id, AgentNode> that the live
|
||||
* AgentGraph renders: children from parent_id, tool/message counts by scanning
|
||||
* events, and a task pulled from the spawning create_agent call where present.
|
||||
*/
|
||||
export function buildGraphAgents(
|
||||
agents: TranscriptAgent[],
|
||||
events: TranscriptEvent[]
|
||||
): Map<string, GraphAgentNode> {
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
for (const a of agents) {
|
||||
if (a.parent_id) {
|
||||
const arr = childrenOf.get(a.parent_id) ?? [];
|
||||
arr.push(a.id);
|
||||
childrenOf.set(a.parent_id, arr);
|
||||
}
|
||||
}
|
||||
|
||||
const toolCount = new Map<string, number>();
|
||||
const messageCount = new Map<string, number>();
|
||||
// A create_agent call names the child but not its id, so map spawned tasks by
|
||||
// agent NAME (best-effort — used only for the graph node subtitle).
|
||||
const taskByName = new Map<string, string>();
|
||||
for (const e of events) {
|
||||
if (e.type === "tool") {
|
||||
toolCount.set(e.agent_id, (toolCount.get(e.agent_id) ?? 0) + 1);
|
||||
if (e.data?.tool_name === "create_agent") {
|
||||
const args = asRecord(e.data.args);
|
||||
const name = (args.name as string) ?? (args.agent_name as string) ?? "";
|
||||
const task = (args.task as string) ?? "";
|
||||
if (name && task) taskByName.set(name, task);
|
||||
}
|
||||
} else if (!isUserChat(e)) {
|
||||
// Count only assistant messages for the graph node subtitle.
|
||||
messageCount.set(e.agent_id, (messageCount.get(e.agent_id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const map = new Map<string, GraphAgentNode>();
|
||||
for (const a of agents) {
|
||||
map.set(a.id, {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
task: taskByName.get(a.name) ?? "",
|
||||
status: graphStatus(a.status),
|
||||
parentId: a.parent_id,
|
||||
children: childrenOf.get(a.id) ?? [],
|
||||
createdAt: a.created_at,
|
||||
toolCount: toolCount.get(a.id) ?? 0,
|
||||
messageCount: messageCount.get(a.id) ?? 0,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/* ---------- Per-agent transcript ---------- */
|
||||
export function AgentTranscript({
|
||||
agent,
|
||||
events,
|
||||
showHeader = true,
|
||||
}: {
|
||||
agent: TranscriptAgent;
|
||||
events: TranscriptEvent[];
|
||||
showHeader?: boolean;
|
||||
}) {
|
||||
const mine = useMemo(() => {
|
||||
const ordered = events
|
||||
.filter((e) => e.agent_id === agent.id)
|
||||
.sort((a, b) => eventSeq(a.id) - eventSeq(b.id));
|
||||
const hidden = hiddenUserEventIds(ordered);
|
||||
return ordered.filter((e) => !hidden.has(e.id));
|
||||
}, [events, agent.id]);
|
||||
|
||||
const toolCount = mine.filter((e) => e.type === "tool").length;
|
||||
const msgCount = mine.length - toolCount;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showHeader && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="text-base font-semibold text-white truncate">{agent.name}</span>
|
||||
<span
|
||||
className={`flex-shrink-0 text-xs font-medium capitalize px-2 py-0.5 rounded-full border ${
|
||||
STATUS_STYLE[agent.status] ?? "text-[#aaa] border-[#333] bg-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
{agent.status}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[#555]">{agent.id}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[#666] mb-4">
|
||||
{msgCount} message{msgCount === 1 ? "" : "s"} · {toolCount} tool call
|
||||
{toolCount === 1 ? "" : "s"}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mine.length === 0 ? (
|
||||
<p className="text-sm text-[#666]">No recorded activity for this agent.</p>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{mine.map((event, i) => {
|
||||
const isLast = i === mine.length - 1;
|
||||
const isTool = event.type === "tool";
|
||||
const toolName = isTool ? String(event.data?.tool_name ?? "tool") : "";
|
||||
const role = !isTool ? String(event.data?.role ?? "assistant") : "";
|
||||
|
||||
let Icon;
|
||||
let iconColor: string;
|
||||
if (isTool) {
|
||||
const meta = getToolIcon(toolName);
|
||||
Icon = meta.icon;
|
||||
iconColor = meta.color;
|
||||
} else {
|
||||
const isUser = role === "user" || role === "human";
|
||||
Icon = isUser ? Bot : Brain;
|
||||
iconColor = isUser ? "text-blue-400" : "text-purple-400";
|
||||
}
|
||||
|
||||
const status = isTool ? String(event.data?.status ?? "completed") : "completed";
|
||||
|
||||
return (
|
||||
<div key={event.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<div
|
||||
className={`w-[30px] h-[30px] rounded-full bg-black border flex items-center justify-center shrink-0 ${
|
||||
isTool && status === "running"
|
||||
? "border-blue-500/40 animate-pulse"
|
||||
: isTool && status === "failed"
|
||||
? "border-red-500/30"
|
||||
: "border-[#222]"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-3.5 h-3.5 ${iconColor}`} />
|
||||
</div>
|
||||
{!isLast && <div className="w-px flex-1 bg-[#1a1a1a] mt-1" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-[5px] pb-6">
|
||||
{isTool ? (
|
||||
<SafeToolRenderer
|
||||
toolName={toolName}
|
||||
args={asRecord(event.data?.args)}
|
||||
result={coerce(event.data?.result) ?? null}
|
||||
status={
|
||||
status as ToolRendererProps["status"]
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ChatBubble
|
||||
role={role}
|
||||
content={String(event.data?.content ?? "")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
function SkeletonNode({ w = 24 }: { w?: number }) {
|
||||
return (
|
||||
<div className="w-[180px] h-[72px] rounded-lg border border-[#222] bg-[#0a0a0a] px-3 py-2 shrink-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-[#2a2a2a]" />
|
||||
<div className="h-3 rounded bg-[#252525]" style={{ width: `${w * 4}px` }} />
|
||||
</div>
|
||||
<div className="h-2 w-28 rounded bg-[#1e1e1e] mb-1.5" />
|
||||
<div className="flex gap-3">
|
||||
<div className="h-2 w-8 rounded bg-[#1e1e1e]" />
|
||||
<div className="h-2 w-8 rounded bg-[#1e1e1e]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VLine() {
|
||||
return <div className="w-px h-6 bg-[#2a2a2a]" />;
|
||||
}
|
||||
|
||||
function HBranch({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="relative flex justify-center">
|
||||
<div className="absolute top-0 h-px bg-[#2a2a2a]" style={{ width: `${(count - 1) * 220}px` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GraphSkeleton() {
|
||||
return (
|
||||
<div className="h-full bg-black overflow-hidden">
|
||||
<div className="flex flex-col items-center pt-10 animate-pulse">
|
||||
<SkeletonNode w={20} />
|
||||
<VLine />
|
||||
<HBranch count={3} />
|
||||
<div className="flex gap-10">
|
||||
{[18, 22, 16].map((w, i) => (
|
||||
<div key={i} className="flex flex-col items-center">
|
||||
<VLine />
|
||||
<SkeletonNode w={w} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-10 w-full justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<VLine />
|
||||
<HBranch count={2} />
|
||||
<div className="flex gap-10">
|
||||
{[14, 20].map((w, i) => (
|
||||
<div key={i} className="flex flex-col items-center">
|
||||
<VLine />
|
||||
<SkeletonNode w={w} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<VLine />
|
||||
<SkeletonNode w={18} />
|
||||
<VLine />
|
||||
<SkeletonNode w={12} />
|
||||
</div>
|
||||
<div className="w-[180px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowUp, ChevronDown, ChevronUp, Loader2, Sparkles } from "lucide-react";
|
||||
import { steerAgent, type TranscriptAgent } from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ROOT_TARGET_VALUE = "__root__";
|
||||
|
||||
interface ScanPromptComposerProps {
|
||||
/** All agents in the run; used to resolve the root and running children. */
|
||||
agents: TranscriptAgent[];
|
||||
/**
|
||||
* Single-agent (modal) mode: pins the composer to one agent and shows a
|
||||
* static "Target: <name>" pill instead of the dropdown. Omit for the
|
||||
* multi-agent graph variant.
|
||||
*/
|
||||
fixedAgentId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Faithful port of the pro app's ScanPromptComposer for the local viewer.
|
||||
* Collapsed by default into a "Guide the agent" pill; expands into a card with
|
||||
* an auto-resizing textarea and a target control. The viewer's steering is
|
||||
* immediate (no Enterprise lock, no bridge-connecting state), so this is only
|
||||
* rendered by callers when steering is available. Sends via steerAgent, which
|
||||
* requires a concrete agent id, so "Root agent" resolves to the root agent's id.
|
||||
*/
|
||||
export function ScanPromptComposer({
|
||||
agents,
|
||||
fixedAgentId,
|
||||
className,
|
||||
}: ScanPromptComposerProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
const isModal = fixedAgentId != null;
|
||||
|
||||
// Root = the agent with no parent; fall back to the first agent.
|
||||
const rootAgent = useMemo(
|
||||
() => agents.find((a) => !a.parent_id) ?? agents[0] ?? null,
|
||||
[agents]
|
||||
);
|
||||
|
||||
// Multi-agent dropdown options: running child agents plus Root (added in JSX).
|
||||
const targetOptions = useMemo(
|
||||
() => agents.filter((a) => a.parent_id && a.status === "running"),
|
||||
[agents]
|
||||
);
|
||||
|
||||
// Selected target for the multi-agent variant. ROOT sentinel by default.
|
||||
const [selectedTarget, setSelectedTarget] = useState<string>(ROOT_TARGET_VALUE);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// If the selected child target disappears (finished), fall back to Root.
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedTarget !== ROOT_TARGET_VALUE &&
|
||||
!targetOptions.some((a) => a.id === selectedTarget)
|
||||
) {
|
||||
setSelectedTarget(ROOT_TARGET_VALUE);
|
||||
}
|
||||
}, [selectedTarget, targetOptions]);
|
||||
|
||||
// Resolve the concrete agent id + display name for the current target.
|
||||
const { targetId, targetName } = useMemo(() => {
|
||||
if (isModal) {
|
||||
const agent = agents.find((a) => a.id === fixedAgentId) ?? null;
|
||||
return {
|
||||
targetId: fixedAgentId ?? null,
|
||||
targetName: agent?.name ?? "this agent",
|
||||
};
|
||||
}
|
||||
if (selectedTarget === ROOT_TARGET_VALUE) {
|
||||
return {
|
||||
targetId: rootAgent?.id ?? null,
|
||||
targetName: "Root agent",
|
||||
};
|
||||
}
|
||||
const agent = agents.find((a) => a.id === selectedTarget) ?? null;
|
||||
return {
|
||||
targetId: agent?.id ?? rootAgent?.id ?? null,
|
||||
targetName: agent?.name ?? "Root agent",
|
||||
};
|
||||
}, [agents, fixedAgentId, isModal, rootAgent, selectedTarget]);
|
||||
|
||||
const empty = value.trim().length === 0;
|
||||
|
||||
// Grow the textarea with its content, capped by max-h via CSS.
|
||||
useLayoutEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [value]);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
setExpanded(true);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
setExpanded(false);
|
||||
setFocused(false);
|
||||
setMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (sending) return;
|
||||
const message = value.trim();
|
||||
if (!message || !targetId) return;
|
||||
|
||||
setSending(true);
|
||||
setFeedback(null);
|
||||
const name = targetName;
|
||||
const res = await steerAgent(targetId, message);
|
||||
setSending(false);
|
||||
if (res.ok) {
|
||||
setValue("");
|
||||
setFeedback(`Sent to ${name}`);
|
||||
track("agent_steered");
|
||||
} else if (res.error === "not_delivered") {
|
||||
setFeedback("Could not reach that agent (it may have finished).");
|
||||
} else {
|
||||
setFeedback("Could not send that message. Try again.");
|
||||
}
|
||||
}, [sending, value, targetId, targetName]);
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-3 rounded-2xl border border-white/[0.08] bg-[#050505] px-5 py-3 text-left transition-colors duration-300 hover:border-white/[0.12] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20",
|
||||
className
|
||||
)}
|
||||
aria-expanded={false}
|
||||
aria-label="Expand live prompt composer"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-[#666]" />
|
||||
<span className="truncate text-sm font-medium text-white">Guide the agent</span>
|
||||
</div>
|
||||
<ChevronUp className="h-4 w-4 shrink-0 text-[#777]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 rounded-2xl border border-white/[0.08] bg-[#050505] overflow-hidden transition-colors duration-300",
|
||||
focused ? "border-white/[0.18]" : "hover:border-white/[0.12]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/[0.06] px-5 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-[#666]" />
|
||||
<p className="text-sm font-medium text-white">Live prompt</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-[#777]">Connected</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isModal ? (
|
||||
<div className="rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-xs text-[#aaa]">
|
||||
Target: <span className="text-white">{targetName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-[#aaa]">Target:</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
onBlur={() => requestAnimationFrame(() => setMenuOpen(false))}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 text-xs text-white transition-colors hover:border-white/[0.16] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<span className="max-w-[140px] truncate">{targetName}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-[#999]" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="absolute right-0 z-10 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-[#333] bg-[#0a0a0a] py-1 shadow-xl"
|
||||
role="listbox"
|
||||
>
|
||||
<TargetMenuItem
|
||||
label="Root agent"
|
||||
active={selectedTarget === ROOT_TARGET_VALUE}
|
||||
onSelect={() => {
|
||||
setSelectedTarget(ROOT_TARGET_VALUE);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
{targetOptions.map((option) => (
|
||||
<TargetMenuItem
|
||||
key={option.id}
|
||||
label={option.name}
|
||||
active={selectedTarget === option.id}
|
||||
onSelect={() => {
|
||||
setSelectedTarget(option.id);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCollapse}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[#777] transition-colors hover:bg-white/[0.06] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
|
||||
aria-label="Collapse live prompt composer"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pt-4 pb-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Send a live prompt to the running scan…"
|
||||
maxLength={4000}
|
||||
disabled={sending}
|
||||
className="block w-full resize-none border-0 bg-transparent p-0 text-[15px] leading-6 text-white placeholder:text-[#444] focus:outline-none disabled:opacity-60 max-h-[160px] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-4 pb-4">
|
||||
<div className="text-xs text-[#666]">{feedback ?? "Press Enter to send."}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleSend();
|
||||
}}
|
||||
disabled={sending || empty}
|
||||
className={cn(
|
||||
"inline-flex h-10 min-w-[112px] items-center justify-center gap-2 rounded-full px-4 text-sm font-medium transition-colors",
|
||||
sending || empty
|
||||
? "bg-white/[0.08] text-[#666]"
|
||||
: "bg-white text-black hover:bg-neutral-200"
|
||||
)}
|
||||
>
|
||||
{sending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" strokeWidth={2.5} />
|
||||
)}
|
||||
<span>Send prompt</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetMenuItem({
|
||||
label,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
// onMouseDown so the click lands before the trigger's onBlur closes the menu.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}}
|
||||
className={cn(
|
||||
"block w-full truncate px-3 py-1.5 text-left text-xs transition-colors hover:bg-white/[0.06]",
|
||||
active ? "text-white" : "text-[#aaa]"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScanPromptComposer;
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps) {
|
||||
if (toolName === "create_agent") {
|
||||
const name = (args.name as string) ?? (args.agent_name as string) ?? "";
|
||||
const task = (args.task as string) ?? "";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">spawning</span>
|
||||
{name && <span className="text-cyan-400 font-semibold text-sm">{name}</span>}
|
||||
</div>
|
||||
{task && <div className="mt-1.5"><TruncatedText text={task} maxLines={15} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "agent_finish") {
|
||||
const summary = (args.result_summary as string) ?? "";
|
||||
const success = args.success as boolean | undefined;
|
||||
const rawFindings = args.findings;
|
||||
const findings = Array.isArray(rawFindings) ? rawFindings as string[] : undefined;
|
||||
return (
|
||||
<div>
|
||||
<span className={`font-semibold text-sm ${success === false ? "text-red-400/80" : "text-emerald-400/80"}`}>
|
||||
{success === false ? "Agent failed" : "Agent completed"}
|
||||
</span>
|
||||
{summary && <div className="mt-1.5"><TruncatedText text={summary} maxLines={20} /></div>}
|
||||
{findings && findings.length > 0 && (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{findings.map((f, i) => (
|
||||
<div key={i} className="text-[13px] text-[#888]"><span className="text-red-400/50 mr-1">•</span>{typeof f === "string" ? f : JSON.stringify(f)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "send_message_to_agent") {
|
||||
const message = (args.message as string) ?? "";
|
||||
const agentId = (args.target_agent_id as string) ?? (args.agent_id as string) ?? "";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">message</span>
|
||||
{agentId && <span className="text-[#888] text-[13px]">to {agentId.slice(0, 16)}</span>}
|
||||
</div>
|
||||
{message && <div className="mt-1.5"><TruncatedText text={message} maxLines={20} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "wait_for_message") {
|
||||
const reason = (args.reason as string) ?? "";
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">waiting</span>
|
||||
{reason && <span className="text-[#888] text-[13px] truncate">{reason}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "stop_agent") {
|
||||
const targetAgentId = (args.target_agent_id as string) ?? "";
|
||||
const cascade = args.cascade !== false;
|
||||
const reason = (args.reason as string) ?? "";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-red-400/80 font-semibold text-sm">stopping</span>
|
||||
{targetAgentId && <span className="text-[#888] text-[13px]">{targetAgentId.slice(0, 16)}</span>}
|
||||
{cascade && <span className="text-[#555] text-[13px] italic">+ descendants</span>}
|
||||
</div>
|
||||
{reason && <div className="mt-1.5 text-[#888] text-[13px]">{reason}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "view_agent_graph") {
|
||||
return (
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">viewing agents graph</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">{toolName.replace(/_/g, " ")}</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { shortPath } from "./utils";
|
||||
|
||||
const DIFF_PREVIEW_LINES = 30;
|
||||
|
||||
const BEGIN_PATCH = "*** Begin Patch";
|
||||
const END_PATCH = "*** End Patch";
|
||||
const ADD_FILE = "*** Add File: ";
|
||||
const UPDATE_FILE = "*** Update File: ";
|
||||
const DELETE_FILE = "*** Delete File: ";
|
||||
|
||||
const OP_LABEL: Record<string, string> = { add: "create", update: "edit", delete: "delete" };
|
||||
|
||||
interface PatchOp {
|
||||
kind: "add" | "update" | "delete";
|
||||
path: string;
|
||||
oldLines: string[];
|
||||
newLines: string[];
|
||||
}
|
||||
|
||||
/** apply_patch args arrive as {patch: text} (chat-completions FunctionTool) or
|
||||
* {input: text} (CustomTool). Mirrors the OSS `_extract_patch_text`. */
|
||||
function extractPatchText(args: Record<string, unknown>): string {
|
||||
const raw = args.patch;
|
||||
if (typeof raw === "string") return raw;
|
||||
if (raw && typeof raw === "object" && typeof (raw as Record<string, unknown>).patch === "string") {
|
||||
return (raw as Record<string, string>).patch;
|
||||
}
|
||||
return typeof args.input === "string" ? args.input : "";
|
||||
}
|
||||
|
||||
/** Parse V4A patch text into per-file operations (mirrors `_parse_patch_operations`). */
|
||||
function parsePatchOperations(patchText: string): PatchOp[] {
|
||||
const ops: PatchOp[] = [];
|
||||
let current: PatchOp | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (current) ops.push(current);
|
||||
current = null;
|
||||
};
|
||||
|
||||
for (const line of patchText.split("\n")) {
|
||||
if (line === BEGIN_PATCH || line === END_PATCH) continue;
|
||||
if (line.startsWith(ADD_FILE)) {
|
||||
flush();
|
||||
current = { kind: "add", path: line.slice(ADD_FILE.length).trim(), oldLines: [], newLines: [] };
|
||||
} else if (line.startsWith(UPDATE_FILE)) {
|
||||
flush();
|
||||
current = { kind: "update", path: line.slice(UPDATE_FILE.length).trim(), oldLines: [], newLines: [] };
|
||||
} else if (line.startsWith(DELETE_FILE)) {
|
||||
flush();
|
||||
current = { kind: "delete", path: line.slice(DELETE_FILE.length).trim(), oldLines: [], newLines: [] };
|
||||
} else if (current?.kind === "update") {
|
||||
if (line.startsWith("@@")) continue;
|
||||
if (line.startsWith("-") && !line.startsWith("---")) current.oldLines.push(line.slice(1));
|
||||
else if (line.startsWith("+") && !line.startsWith("+++")) current.newLines.push(line.slice(1));
|
||||
} else if (current?.kind === "add") {
|
||||
if (line.startsWith("+")) current.newLines.push(line.slice(1));
|
||||
else if (line.trim()) current.newLines.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return ops;
|
||||
}
|
||||
|
||||
function Operation({ op }: { op: PatchOp }) {
|
||||
const label = OP_LABEL[op.kind] ?? "file";
|
||||
const total = op.oldLines.length + op.newLines.length;
|
||||
const truncated = total > DIFF_PREVIEW_LINES;
|
||||
const oldBudget = truncated && total > 0 ? Math.round(DIFF_PREVIEW_LINES * (op.oldLines.length / total)) : op.oldLines.length;
|
||||
const newBudget = truncated ? DIFF_PREVIEW_LINES - oldBudget : op.newLines.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sky-400/80 font-semibold text-sm shrink-0">{label}</span>
|
||||
{op.path && <span className="text-[#888] font-mono text-[13px] break-all">{shortPath(op.path)}</span>}
|
||||
</div>
|
||||
{(op.oldLines.length > 0 || op.newLines.length > 0) && (
|
||||
<div className="font-mono text-[13px] leading-relaxed mt-1.5">
|
||||
{op.oldLines.slice(0, oldBudget).map((line, i) => (
|
||||
<div key={`o${i}`} className="text-red-400/60">
|
||||
<span className="select-none text-red-400/30 mr-1">-</span>{line}
|
||||
</div>
|
||||
))}
|
||||
{op.newLines.slice(0, newBudget).map((line, i) => (
|
||||
<div key={`n${i}`} className="text-emerald-400/60">
|
||||
<span className="select-none text-emerald-400/30 mr-1">+</span>{line}
|
||||
</div>
|
||||
))}
|
||||
{truncated && <div className="text-[#444] mt-0.5">... {total - DIFF_PREVIEW_LINES} more lines</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApplyPatchRenderer({ args, result, status }: ToolRendererProps) {
|
||||
const ops = parsePatchOperations(extractPatchText(args));
|
||||
|
||||
if (ops.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-sky-400/80 font-semibold text-sm">patch</span>
|
||||
{status === "failed" && typeof result === "string" && result.trim() && (
|
||||
<div className="text-red-400/70 text-[13px] mt-1">{result.trim()}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{ops.map((op, i) => (
|
||||
<Operation key={i} op={op} />
|
||||
))}
|
||||
{status === "failed" && typeof result === "string" && result.trim() && (
|
||||
<div className="text-red-400/70 text-[13px]">{result.trim()}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { SyntaxBlock } from "./ToolCard";
|
||||
|
||||
const SIMPLE_ACTIONS: Record<string, string> = {
|
||||
back: "going back in browser history",
|
||||
forward: "going forward in browser history",
|
||||
scroll_down: "scrolling down",
|
||||
scroll_up: "scrolling up",
|
||||
refresh: "refreshing",
|
||||
close_tab: "closing tab",
|
||||
switch_tab: "switching tab",
|
||||
list_tabs: "listing tabs",
|
||||
view_source: "viewing page source",
|
||||
get_console_logs: "getting console logs",
|
||||
screenshot: "taking screenshot",
|
||||
wait: "waiting...",
|
||||
close: "closing",
|
||||
};
|
||||
|
||||
const CLICK_ACTIONS: Record<string, string> = {
|
||||
click: "clicking",
|
||||
double_click: "double clicking",
|
||||
hover: "hovering",
|
||||
};
|
||||
|
||||
function UrlLabel({ prefix, url, suffix }: { prefix: string; url?: string; suffix?: string }) {
|
||||
return (
|
||||
<span className="text-[#888] text-[13px]">
|
||||
{prefix}
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyan-400/80 hover:underline"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
)}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function describeAction(args: Record<string, unknown>): React.ReactNode {
|
||||
const action = (args.action as string) ?? "";
|
||||
const url = (args.url as string) ?? undefined;
|
||||
|
||||
// Simple actions (no extra args)
|
||||
if (action in SIMPLE_ACTIONS) return SIMPLE_ACTIONS[action];
|
||||
|
||||
// URL actions: launch, goto, new_tab
|
||||
if (action === "launch") {
|
||||
if (!url) return "launching";
|
||||
return <UrlLabel prefix="launching " url={url} />;
|
||||
}
|
||||
if (action === "goto" || action === "navigate") {
|
||||
return <UrlLabel prefix="navigating to " url={url} />;
|
||||
}
|
||||
if (action === "new_tab") {
|
||||
return <UrlLabel prefix="opening tab " url={url} />;
|
||||
}
|
||||
|
||||
// Click actions
|
||||
if (action in CLICK_ACTIONS) return CLICK_ACTIONS[action];
|
||||
|
||||
// Type
|
||||
if (action === "type") {
|
||||
const text = ((args.text as string) ?? "").slice(0, 40);
|
||||
return `typing "${text}"`;
|
||||
}
|
||||
|
||||
// Key press
|
||||
if (action === "press_key" || action === "key_press") {
|
||||
return `pressing key ${(args.key as string) ?? ""}`;
|
||||
}
|
||||
|
||||
// Save PDF
|
||||
if (action === "save_pdf" || action === "save_as_pdf") {
|
||||
const path = (args.file_path as string) ?? "";
|
||||
return `saving PDF${path ? ` to ${path}` : ""}`;
|
||||
}
|
||||
|
||||
// Execute JS — description only, code shown separately
|
||||
if (action === "execute_js") return "executing javascript";
|
||||
|
||||
return action || "browser action";
|
||||
}
|
||||
|
||||
export default function BrowserRenderer({ args }: ToolRendererProps) {
|
||||
const action = (args.action as string) ?? "";
|
||||
const jsCode = action === "execute_js"
|
||||
? ((args.js_code as string) ?? (args.code as string) ?? "")
|
||||
: "";
|
||||
const description = describeAction(args);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-blue-400/80 font-semibold text-sm shrink-0">Browser</span>
|
||||
<span className="min-w-0 truncate text-[#888] text-[13px]">
|
||||
{typeof description === "string" ? description : description}
|
||||
</span>
|
||||
</div>
|
||||
{jsCode && <SyntaxBlock code={jsCode} language="javascript" collapsible />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
interface ChatBubbleProps {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const MAX_LINES = 30;
|
||||
|
||||
export default function ChatBubble({ role, content }: ChatBubbleProps) {
|
||||
const isUser = role === "user" || role === "human";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className={`font-semibold text-sm ${isUser ? "text-blue-400/80" : "text-purple-400/80"}`}>
|
||||
{isUser ? "User" : "Thinking"}
|
||||
</span>
|
||||
<div className="mt-1.5 italic text-[#888]">
|
||||
<TruncatedText text={content} maxLines={MAX_LINES} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { CodeBlock } from "./ToolCard";
|
||||
|
||||
/**
|
||||
* Generic renderer for tool names without a dedicated family renderer. Shows the
|
||||
* humanized tool name plus a pretty-printed dump of args/result. Tolerates the
|
||||
* server sending args/result as either a parsed object or an unparseable
|
||||
* Python-repr string (which arrives here wrapped as { __raw }); never crashes.
|
||||
*/
|
||||
function pretty(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "string") return value.trim() ? value : null;
|
||||
if (typeof value === "object") {
|
||||
const rec = value as Record<string, unknown>;
|
||||
if (typeof rec.__raw === "string") return rec.__raw;
|
||||
if (Object.keys(rec).length === 0) return null;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default function FallbackRenderer({ toolName, args, result }: ToolRendererProps) {
|
||||
const argsText = pretty(args);
|
||||
const resultText = pretty(result);
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[#888] font-semibold text-sm">{toolName.replace(/_/g, " ")}</span>
|
||||
{argsText && <CodeBlock className="text-[#777]">{argsText}</CodeBlock>}
|
||||
{resultText && <CodeBlock className="text-[#666]">{resultText}</CodeBlock>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { shortPath } from "./utils";
|
||||
|
||||
const DIFF_PREVIEW_LINES = 30;
|
||||
|
||||
export default function FileEditRenderer({ toolName, args }: ToolRendererProps) {
|
||||
const filePath = (args.path as string) ?? (args.file_path as string) ?? "";
|
||||
const command = (args.command as string) ?? "";
|
||||
const oldStr = (args.old_str as string) ?? "";
|
||||
const newStr = (args.new_str as string) ?? "";
|
||||
const regex = (args.regex as string) ?? "";
|
||||
|
||||
let label: string;
|
||||
if (toolName === "list_files") label = "list";
|
||||
else if (toolName === "search_files") label = "search";
|
||||
else if (command === "view") label = "view";
|
||||
else if (command === "create") label = "create";
|
||||
else if (command === "str_replace") label = "edit";
|
||||
else if (command === "undo_edit") label = "undo";
|
||||
else if (command === "insert") label = "insert";
|
||||
else label = "file";
|
||||
|
||||
const pathDisplay = filePath ? shortPath(filePath) : "";
|
||||
const regexDisplay = regex ? ` /${regex}/` : "";
|
||||
|
||||
const oldLines = oldStr ? oldStr.split("\n") : [];
|
||||
const newLines = newStr ? newStr.split("\n") : [];
|
||||
const totalLines = oldLines.length + newLines.length;
|
||||
const truncated = totalLines > DIFF_PREVIEW_LINES;
|
||||
|
||||
// If truncated, split the budget proportionally
|
||||
const oldBudget = truncated ? Math.round(DIFF_PREVIEW_LINES * (oldLines.length / totalLines)) : oldLines.length;
|
||||
const newBudget = truncated ? DIFF_PREVIEW_LINES - oldBudget : newLines.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sky-400/80 font-semibold text-sm shrink-0">{label}</span>
|
||||
{pathDisplay && <span className="text-[#888] font-mono text-[13px] break-all">{pathDisplay}</span>}
|
||||
</div>
|
||||
{regexDisplay && (
|
||||
<div className="text-purple-400/60 font-mono text-[13px] break-all mt-0.5">{regexDisplay}</div>
|
||||
)}
|
||||
{(oldStr || newStr) && (
|
||||
<div className="font-mono text-[13px] leading-relaxed mt-1.5">
|
||||
{oldLines.slice(0, oldBudget).map((line, i) => (
|
||||
<div key={`o${i}`} className="text-red-400/60">
|
||||
<span className="select-none text-red-400/30 mr-1">-</span>{line}
|
||||
</div>
|
||||
))}
|
||||
{newLines.slice(0, newBudget).map((line, i) => (
|
||||
<div key={`n${i}`} className="text-emerald-400/60">
|
||||
<span className="select-none text-emerald-400/30 mr-1">+</span>{line}
|
||||
</div>
|
||||
))}
|
||||
{truncated && (
|
||||
<div className="text-[#444] mt-0.5">... {totalLines - DIFF_PREVIEW_LINES} more lines</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
export default function FinishRenderer({ args }: ToolRendererProps) {
|
||||
const executiveSummary = (args.executive_summary as string) ?? "";
|
||||
const methodology = (args.methodology as string) ?? "";
|
||||
const technicalAnalysis = (args.technical_analysis as string) ?? "";
|
||||
const recommendations = (args.recommendations as string) ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<span className="text-emerald-400/80 font-semibold text-sm">Penetration test completed</span>
|
||||
{executiveSummary && (
|
||||
<div><span className="text-emerald-400/60 text-sm font-semibold">Executive Summary</span><div className="mt-1"><TruncatedText text={executiveSummary} maxLines={25} /></div></div>
|
||||
)}
|
||||
{methodology && (
|
||||
<div><span className="text-emerald-400/60 text-sm font-semibold">Methodology</span><div className="mt-1"><TruncatedText text={methodology} maxLines={25} /></div></div>
|
||||
)}
|
||||
{technicalAnalysis && (
|
||||
<div><span className="text-emerald-400/60 text-sm font-semibold">Technical Analysis</span><div className="mt-1"><TruncatedText text={technicalAnalysis} maxLines={25} /></div></div>
|
||||
)}
|
||||
{recommendations && (
|
||||
<div><span className="text-emerald-400/60 text-sm font-semibold">Recommendations</span><div className="mt-1"><TruncatedText text={recommendations} maxLines={25} /></div></div>
|
||||
)}
|
||||
{!executiveSummary && !methodology && !technicalAnalysis && !recommendations && (
|
||||
<div className="text-[#555] text-xs">Generating final report...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
|
||||
export default function LoadSkillRenderer({ args }: ToolRendererProps) {
|
||||
const requestedRaw = (args.skills as string) ?? "";
|
||||
const requestedSkills = requestedRaw
|
||||
.split(",")
|
||||
.map((skill) => skill.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-emerald-400/80 font-semibold text-sm">Loading skill</span>
|
||||
{requestedSkills.length > 0 && (
|
||||
<span className="text-[#888] text-[13px]">{requestedSkills.join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { rehypeCodeMeta, mdComponents } from "@/components/vulnerability/MdCodeBlock";
|
||||
|
||||
interface MarkdownProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Markdown({ text, className = "" }: MarkdownProps) {
|
||||
return (
|
||||
<div className={`prose-markdown ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeCodeMeta]}
|
||||
components={mdComponents}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
export default function NotesRenderer({ toolName, args, result }: ToolRendererProps) {
|
||||
if (toolName === "create_note") {
|
||||
const title = (args.title as string) ?? "";
|
||||
const content = (args.content as string) ?? "";
|
||||
const category = (args.category as string) ?? "general";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-400/80 font-semibold text-sm">note</span>
|
||||
<span className="text-[#555] text-[13px]">({category})</span>
|
||||
</div>
|
||||
{title && <div className="mt-1.5 text-[#999] text-[13px]">{title}</div>}
|
||||
{content && <div className="mt-1"><Markdown text={content} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "delete_note") {
|
||||
return <span className="text-amber-400/80 font-semibold text-sm">note removed</span>;
|
||||
}
|
||||
|
||||
if (toolName === "update_note") {
|
||||
const title = (args.title as string) ?? "";
|
||||
const content = (args.content as string) ?? "";
|
||||
return (
|
||||
<div>
|
||||
<span className="text-amber-400/80 font-semibold text-sm">note updated</span>
|
||||
{title && <div className="mt-1.5 text-[#999] text-[13px]">{title}</div>}
|
||||
{content && <div className="mt-1"><Markdown text={content} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "get_note") {
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const note = res && typeof res === "object" && res.success
|
||||
? (res.note as Record<string, string> | undefined)
|
||||
: undefined;
|
||||
return (
|
||||
<div>
|
||||
<span className="text-amber-400/80 font-semibold text-sm">note read</span>
|
||||
{note && (
|
||||
<>
|
||||
<div className="mt-1.5 text-[#999] text-[13px]">
|
||||
{note.title ?? "(untitled)"}
|
||||
<span className="text-[#555] ml-1">({note.category ?? "general"})</span>
|
||||
</div>
|
||||
{note.content && <div className="mt-1"><Markdown text={note.content} /></div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "list_notes") {
|
||||
const res = result as Record<string, unknown> | null;
|
||||
let notes: Array<Record<string, string>> = [];
|
||||
if (res && typeof res === "object" && res.success) {
|
||||
const rawNotes = res.notes;
|
||||
notes = Array.isArray(rawNotes) ? rawNotes as Array<Record<string, string>> : [];
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="text-amber-400/80 font-semibold text-sm">notes</span>
|
||||
{notes.length > 0 ? (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{notes.map((n, i) => (
|
||||
<div key={i} className="text-[13px]">
|
||||
<span className="text-[#555] mr-1">-</span>
|
||||
<span className="text-[#999]">{n.title ?? "(untitled)"}</span>
|
||||
<span className="text-[#555] ml-1">({n.category ?? "general"})</span>
|
||||
{n.content && <div className="ml-3"><Markdown text={n.content} /></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="mt-1 text-[#555] text-xs">No notes</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-amber-400/80 font-semibold text-sm">note</span>;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { CodeBlock } from "./ToolCard";
|
||||
|
||||
const MAX_LINE_LENGTH = 200;
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "text-emerald-400/80", POST: "text-blue-400/80", PUT: "text-yellow-400/80",
|
||||
PATCH: "text-orange-400/80", DELETE: "text-red-400/80",
|
||||
};
|
||||
|
||||
function statusColor(code: number): string {
|
||||
if (code < 300) return "text-emerald-400/80";
|
||||
if (code < 400) return "text-yellow-400/80";
|
||||
if (code < 500) return "text-orange-400/80";
|
||||
return "text-red-400/80";
|
||||
}
|
||||
|
||||
/** Hard truncate with trailing "..." */
|
||||
function trunc(text: string, maxLen = 80): string {
|
||||
return text.length > maxLen ? text.slice(0, maxLen - 3) + "..." : text;
|
||||
}
|
||||
|
||||
/** Replace newlines/tabs, then truncate */
|
||||
function sanitize(text: string, maxLen = 150): string {
|
||||
return trunc(text.replace(/\n/g, " ").replace(/\r/g, "").replace(/\t/g, " "), maxLen);
|
||||
}
|
||||
|
||||
/** Limit body to maxLines, each truncated to MAX_LINE_LENGTH-5; returns display string */
|
||||
function limitBody(body: string, maxLines: number): string {
|
||||
const lines = body.split("\n");
|
||||
const display = lines.slice(0, maxLines).map(l => trunc(l, MAX_LINE_LENGTH - 5)).join("\n");
|
||||
return lines.length > maxLines ? display + "\n..." : display;
|
||||
}
|
||||
|
||||
function ListRequests({ args, result }: ToolRendererProps) {
|
||||
const filter = (args.httpql_filter as string) ?? "";
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const rawReqs = res ? res.requests : null;
|
||||
const requests = Array.isArray(rawReqs) ? rawReqs as Array<Record<string, unknown>> : [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">listing requests</span>
|
||||
{filter && <span className="text-[#888] text-[13px]">{trunc(filter, 150)}</span>}
|
||||
</div>
|
||||
{requests.length > 0 && (
|
||||
<div className="mt-1.5 font-mono text-[13px] space-y-0.5">
|
||||
{requests.slice(0, 20).map((r, i) => {
|
||||
const m = ((r.method as string) ?? "GET").toUpperCase();
|
||||
const host = (r.host as string) ?? "";
|
||||
const path = (r.path as string) ?? "";
|
||||
const resp = r.response as Record<string, unknown> | undefined;
|
||||
const sc = (resp?.statusCode as number) ?? null;
|
||||
return (
|
||||
<div key={i} className="flex gap-2">
|
||||
<span className={`w-10 shrink-0 font-bold ${METHOD_COLORS[m] ?? "text-[#888]"}`}>{m}</span>
|
||||
<span className="text-[#777] truncate">{trunc(host + path, 180)}</span>
|
||||
{sc != null && <span className={`ml-auto shrink-0 ${statusColor(sc)}`}>{sc}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{requests.length > 20 && <div className="text-[#555]">... +{requests.length - 20} more</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewRequest({ args, result }: ToolRendererProps) {
|
||||
const requestId = args.request_id as number | undefined;
|
||||
const part = (args.part as string) ?? "request";
|
||||
const searchPattern = (args.search_pattern as string) ?? "";
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const rawMatches = res ? res.matches : null;
|
||||
const matches = Array.isArray(rawMatches) ? rawMatches as Array<Record<string, string>> : [];
|
||||
const content = res ? (res.content as string) ?? null : null;
|
||||
const hasMore = res ? !!(res.has_more) : false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{searchPattern ? "searching" : "viewing"} {part}</span>
|
||||
{requestId != null && <span className="text-[#888] text-[13px]">#{requestId}</span>}
|
||||
{searchPattern && <span className="text-[#666] font-mono text-[13px]">/{trunc(searchPattern, 100)}/</span>}
|
||||
</div>
|
||||
{matches.length > 0 && (
|
||||
<div className="mt-1.5 font-mono text-[13px] space-y-1">
|
||||
{matches.slice(0, 5).map((m, i) => {
|
||||
// Sanitize context: replace newlines with space, trim to 100 chars
|
||||
const before = ((m.before ?? "").replace(/\n/g, " ").replace(/\r/g, "")).slice(-100);
|
||||
const after = ((m.after ?? "").replace(/\n/g, " ").replace(/\r/g, "")).slice(0, 100);
|
||||
return (
|
||||
<div key={i}>
|
||||
{before && <span className="text-[#555]">...{before}</span>}
|
||||
<span className="text-amber-400/80 font-bold">{m.match}</span>
|
||||
{after && <span className="text-[#555]">{after}...</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{matches.length > 5 && <div className="text-[#555]">... +{matches.length - 5} more matches</div>}
|
||||
</div>
|
||||
)}
|
||||
{content && !matches.length && (() => {
|
||||
const lines = content.split("\n");
|
||||
const display = lines.slice(0, 15).map(l => trunc(l, MAX_LINE_LENGTH)).join("\n");
|
||||
const showMore = hasMore || lines.length > 15;
|
||||
return (
|
||||
<CodeBlock className="text-[#666]">
|
||||
{display + (showMore ? "\n... more content available" : "")}
|
||||
</CodeBlock>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SendRequest({ args, result }: ToolRendererProps) {
|
||||
const method = ((args.method as string) ?? "GET").toUpperCase();
|
||||
const url = (args.url as string) ?? "";
|
||||
const headers = args.headers as Record<string, string> | undefined;
|
||||
const rawBody = args.body;
|
||||
const reqBody = typeof rawBody === "string" ? rawBody : "";
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const error = res ? (res.error as string) ?? null : null;
|
||||
const statusCode = res ? (res.status_code as number) ?? null : null;
|
||||
const responseTime = res ? (res.response_time_ms as number) ?? null : null;
|
||||
const rawResBody = res ? res.body : null;
|
||||
const resBody = typeof rawResBody === "string" ? rawResBody : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-purple-400/80 font-semibold text-sm">request</span>
|
||||
<div className="mt-1.5 font-mono text-[13px] space-y-0.5">
|
||||
<div>
|
||||
<span className="text-[#555] select-none mr-1">>></span>
|
||||
<span className={`font-bold ${METHOD_COLORS[method] ?? "text-[#888]"}`}>{method}</span>
|
||||
<span className="text-[#888] ml-1 break-all">{trunc(url, 180)}</span>
|
||||
</div>
|
||||
{headers && typeof headers === "object" && Object.entries(headers).slice(0, 5).map(([k, v]) => (
|
||||
<div key={k} className="text-[#555] pl-5">{k}: {sanitize(String(v), 150)}</div>
|
||||
))}
|
||||
</div>
|
||||
{reqBody && (
|
||||
<CodeBlock className="text-[#888]">{limitBody(reqBody, 4)}</CodeBlock>
|
||||
)}
|
||||
{error && <div className="text-red-400/70 text-[13px] mt-1.5">{sanitize(error, 150)}</div>}
|
||||
{statusCode != null && (
|
||||
<div className="font-mono text-[13px] mt-1.5">
|
||||
<span className="text-[#555] select-none mr-1"><<</span>
|
||||
<span className={`font-bold ${statusColor(statusCode)}`}>{statusCode}</span>
|
||||
{responseTime != null && <span className="text-[#555] ml-2">{responseTime}ms</span>}
|
||||
</div>
|
||||
)}
|
||||
{resBody && (
|
||||
<CodeBlock className="text-[#666]">{limitBody(resBody, 6)}</CodeBlock>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RepeatRequest({ args, result }: ToolRendererProps) {
|
||||
const requestId = args.request_id as number | undefined;
|
||||
const modifications = args.modifications as Record<string, unknown> | undefined;
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const statusCode = res ? (res.status_code as number) ?? null : null;
|
||||
const responseTime = res ? (res.response_time_ms as number) ?? null : null;
|
||||
const rawRepBody = res ? res.body : null;
|
||||
const resBody = typeof rawRepBody === "string" ? rawRepBody : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">repeating request</span>
|
||||
{requestId != null && <span className="text-[#888] text-[13px]">#{requestId}</span>}
|
||||
</div>
|
||||
{modifications && typeof modifications === "object" && Object.keys(modifications).length > 0 && (
|
||||
<div className="mt-1.5 font-mono text-[13px] space-y-0.5">
|
||||
{Object.entries(modifications).slice(0, 5).map(([k, v]) => (
|
||||
<div key={k}><span className="text-orange-400/60">{k}:</span> <span className="text-[#777]">{sanitize(typeof v === "string" ? v : JSON.stringify(v), 150)}</span></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{statusCode != null && (
|
||||
<div className="font-mono text-[13px] mt-1.5">
|
||||
<span className="text-[#555] select-none mr-1"><<</span>
|
||||
<span className={`font-bold ${statusColor(statusCode)}`}>{statusCode}</span>
|
||||
{responseTime != null && <span className="text-[#555] ml-2">{responseTime}ms</span>}
|
||||
</div>
|
||||
)}
|
||||
{resBody && (
|
||||
<CodeBlock className="text-[#666]">{limitBody(resBody, 5)}</CodeBlock>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SCOPE_ACTION: Record<string, string> = {
|
||||
get: "getting", list: "listing", create: "creating", update: "updating", delete: "deleting",
|
||||
};
|
||||
|
||||
function ScopeRules({ args }: ToolRendererProps) {
|
||||
const action = (args.action as string) ?? "";
|
||||
const scopeName = (args.scope_name as string) ?? "";
|
||||
const label = SCOPE_ACTION[action] ?? (action ? action : "managing");
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{label} proxy scope</span>
|
||||
{scopeName && <span className="text-[#888] text-[13px]">{trunc(scopeName, 50)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListSitemap({ args }: ToolRendererProps) {
|
||||
const parentId = args.parent_id as string | undefined;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">listing sitemap</span>
|
||||
{parentId && <span className="text-[#888] text-[13px]">under #{trunc(String(parentId), 20)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewSitemapEntry({ args }: ToolRendererProps) {
|
||||
const entryId = args.entry_id as string | undefined;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">viewing sitemap entry</span>
|
||||
{entryId && <span className="text-[#888] text-[13px]">#{trunc(String(entryId), 20)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProxyRenderer(props: ToolRendererProps) {
|
||||
switch (props.toolName) {
|
||||
case "list_requests": return <ListRequests {...props} />;
|
||||
case "view_request": return <ViewRequest {...props} />;
|
||||
case "send_request": return <SendRequest {...props} />;
|
||||
case "repeat_request": return <RepeatRequest {...props} />;
|
||||
case "scope_rules": return <ScopeRules {...props} />;
|
||||
case "list_sitemap": return <ListSitemap {...props} />;
|
||||
case "view_sitemap_entry": return <ViewSitemapEntry {...props} />;
|
||||
default:
|
||||
return (
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{props.toolName.replace(/_/g, " ")}</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { CodeBlock, SyntaxBlock } from "./ToolCard";
|
||||
|
||||
const MAX_OUTPUT_LINES = 50;
|
||||
const MAX_LINE_LENGTH = 200;
|
||||
const HEAD = 25;
|
||||
const TAIL = 24;
|
||||
|
||||
// Full ANSI escape sequence pattern (matches Python's ANSI_PATTERN)
|
||||
const ANSI_PATTERN = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g;
|
||||
|
||||
// Strips truncation notices added by Python executor
|
||||
const STRIP_PATTERN = /\.\.\. \[(stdout|stderr|result|output|error) truncated at \d+k? chars\]/g;
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(ANSI_PATTERN, "");
|
||||
}
|
||||
|
||||
function truncateLine(line: string): string {
|
||||
const clean = stripAnsi(line);
|
||||
if (clean.length > MAX_LINE_LENGTH) return clean.slice(0, MAX_LINE_LENGTH - 3) + "...";
|
||||
return clean;
|
||||
}
|
||||
|
||||
function cleanOutput(output: string): string {
|
||||
return output.replace(STRIP_PATTERN, "").trim();
|
||||
}
|
||||
|
||||
function formatOutput(output: string): string {
|
||||
const lines = output.split("\n");
|
||||
if (lines.length <= MAX_OUTPUT_LINES) return lines.map(truncateLine).join("\n");
|
||||
const hiddenCount = lines.length - HEAD - TAIL;
|
||||
return [
|
||||
...lines.slice(0, HEAD).map(truncateLine),
|
||||
`... ${hiddenCount} lines truncated ...`,
|
||||
...lines.slice(-TAIL).map(truncateLine),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export default function PythonRenderer({ args, result }: ToolRendererProps) {
|
||||
const action = (args.action as string) ?? "";
|
||||
const code = (args.code as string) ?? (args.script as string) ?? "";
|
||||
|
||||
const res = result as Record<string, unknown> | string | null;
|
||||
let stdout: string | null = null;
|
||||
if (res && typeof res === "object") stdout = typeof res.stdout === "string" ? res.stdout : null;
|
||||
else if (typeof res === "string") stdout = res;
|
||||
|
||||
const subtitle =
|
||||
action === "new_session" ? "new session" :
|
||||
action === "close" ? "close session" :
|
||||
action === "list_sessions" ? "list sessions" : null;
|
||||
|
||||
const output = stdout ? formatOutput(cleanOutput(stdout)) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-yellow-400/80 font-semibold text-sm">Python</span>
|
||||
{subtitle && <span className="text-[#888] text-[13px]">{subtitle}</span>}
|
||||
</div>
|
||||
{code && <SyntaxBlock code={code} language="python" collapsible />}
|
||||
{output && <CodeBlock className="text-[#666]">{output}</CodeBlock>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
function ScanStartInfo({ args }: ToolRendererProps) {
|
||||
const rawTargets = args.targets;
|
||||
const targets = Array.isArray(rawTargets) ? rawTargets : [];
|
||||
const targetNames = targets.map((t) => (typeof t === "object" && t ? (t.original as string) ?? null : null)).filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-emerald-400/80 font-semibold text-sm">Starting penetration test</span>
|
||||
{targetNames.length === 1 && <span className="text-[#888] text-[13px]">on {targetNames[0]}</span>}
|
||||
</div>
|
||||
{targetNames.length > 1 && (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{targetNames.map((t, i) => (
|
||||
<div key={i} className="text-[13px] text-[#888]"><span className="text-[#555] mr-1">•</span>{t}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentStartInfo({ args }: ToolRendererProps) {
|
||||
const name = (args.name as string) ?? "Unknown Agent";
|
||||
const task = (args.task as string) ?? "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[#888] text-[13px]">subagent</span>
|
||||
<span className="text-purple-400 font-semibold text-sm">{name}</span>
|
||||
</div>
|
||||
{task && <div className="mt-1.5"><TruncatedText text={task} maxLines={15} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScanInfoRenderer(props: ToolRendererProps) {
|
||||
if (props.toolName === "subagent_start_info") return <SubagentStartInfo {...props} />;
|
||||
return <ScanStartInfo {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { CodeBlock, SyntaxBlock } from "./ToolCard";
|
||||
|
||||
const MAX_OUTPUT_LINES = 50;
|
||||
const MAX_LINE_LENGTH = 200;
|
||||
const HEAD = 25;
|
||||
const TAIL = 24;
|
||||
|
||||
const STRIP_PATTERNS: RegExp[] = [
|
||||
/\n?\[Command still running after [\d.]+s - showing output so far\.?\s*(?:Use C-c to interrupt if needed\.)?\]/g,
|
||||
/^\[Below is the output of the previous command\.\]\n?/gm,
|
||||
/^No command is currently running\. Cannot send input\.$/gm,
|
||||
/^A command is already running\. Use is_input=true to send input to it, or interrupt it first \(e\.g\., with C-c\)\.$/gm,
|
||||
];
|
||||
|
||||
// Terminal-tool chunk metadata (the OSS engine's shell tool prepends these; the
|
||||
// TUI strips them in strix/interface/tui/renderers/shell_renderer.py). Only a
|
||||
// contiguous block anchored on a "Chunk ID:" line is stripped, so identical
|
||||
// text inside real command output is left untouched.
|
||||
const CHUNK_PREAMBLE_START = /^Chunk ID: [0-9a-f]+\s*$/;
|
||||
const CHUNK_PREAMBLE_METADATA: RegExp[] = [
|
||||
/^Wall time: [\d.]+ seconds\s*$/,
|
||||
/^Process exited with code -?\d+\s*$/,
|
||||
/^Process running with session ID \d+\s*$/,
|
||||
/^Original token count: \d+\s*$/,
|
||||
];
|
||||
|
||||
function stripChunkPreambles(lines: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (CHUNK_PREAMBLE_START.test(lines[i])) {
|
||||
let j = i + 1;
|
||||
while (j < lines.length && CHUNK_PREAMBLE_METADATA.some((p) => p.test(lines[j]))) j++;
|
||||
if (j < lines.length && lines[j].trim() === "Output:") j++;
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
out.push(lines[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function truncateLine(line: string): string {
|
||||
if (line.length > MAX_LINE_LENGTH) return line.slice(0, MAX_LINE_LENGTH - 3) + "...";
|
||||
return line;
|
||||
}
|
||||
|
||||
function cleanOutput(raw: string, command: string = ""): string {
|
||||
// Strip ANSI escape sequences and carriage returns
|
||||
let cleaned = raw.replace(/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g, "").replace(/\r/g, "");
|
||||
|
||||
for (const pattern of STRIP_PATTERNS) {
|
||||
cleaned = cleaned.replace(pattern, "");
|
||||
}
|
||||
|
||||
if (cleaned.trim()) {
|
||||
const lines = stripChunkPreambles(cleaned.split("\n"));
|
||||
const filtered: string[] = [];
|
||||
for (const line of lines) {
|
||||
// Skip leading blank lines
|
||||
if (filtered.length === 0 && !line.trim()) continue;
|
||||
// Skip [STRIX_N]$ prompt lines
|
||||
if (/^\[STRIX_\d+\]\$\s*/.test(line)) continue;
|
||||
// Skip echoed command (plain)
|
||||
if (command && line.trim() === command.trim()) continue;
|
||||
// Skip echoed command with $/#/> prefix
|
||||
if (command && new RegExp(`^[\\$#>]\\s*${escapeRegex(command.trim())}\\s*$`).test(line)) continue;
|
||||
filtered.push(line);
|
||||
}
|
||||
// Strip trailing [STRIX_N]$ lines
|
||||
while (filtered.length > 0 && /^\[STRIX_\d+\]\$\s*/.test(filtered[filtered.length - 1])) {
|
||||
filtered.pop();
|
||||
}
|
||||
cleaned = filtered.join("\n");
|
||||
}
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
function formatOutput(output: string): string {
|
||||
const lines = output.split("\n");
|
||||
if (lines.length <= MAX_OUTPUT_LINES) return lines.map(truncateLine).join("\n");
|
||||
const hiddenCount = lines.length - HEAD - TAIL;
|
||||
return [
|
||||
...lines.slice(0, HEAD).map(truncateLine),
|
||||
`... ${hiddenCount} lines truncated ...`,
|
||||
...lines.slice(-TAIL).map(truncateLine),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export default function TerminalRenderer({ toolName, args, result }: ToolRendererProps) {
|
||||
const isStdin = toolName === "write_stdin";
|
||||
const command = isStdin
|
||||
? ((args.chars as string) ?? (args.input as string) ?? "")
|
||||
: ((args.command as string) ?? (args.cmd as string) ?? "");
|
||||
|
||||
const res = result as Record<string, unknown> | string | null;
|
||||
let content: string | null = null;
|
||||
let error: string | null = null;
|
||||
let exitCode: number | null = null;
|
||||
|
||||
if (res && typeof res === "object") {
|
||||
content = typeof res.content === "string" ? res.content : null;
|
||||
error = typeof res.error === "string" ? res.error : null;
|
||||
exitCode = typeof res.exit_code === "number" ? res.exit_code : null;
|
||||
const s = typeof res.status === "string" ? res.status : "";
|
||||
if (s === "running" || s === "command still running") content = null;
|
||||
} else if (typeof res === "string") {
|
||||
content = res;
|
||||
}
|
||||
|
||||
const output = content ? formatOutput(cleanOutput(content, command)) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-emerald-400/80 font-semibold text-sm">{isStdin ? "Terminal input" : "Terminal"}</span>
|
||||
{command && <SyntaxBlock code={command} language="bash" collapsible />}
|
||||
{error && <CodeBlock className="text-red-400/70">{error}</CodeBlock>}
|
||||
{output && <CodeBlock className="text-[#666]">{output}</CodeBlock>}
|
||||
{exitCode != null && exitCode !== 0 && (
|
||||
<div className="font-mono text-[13px] text-red-400/70 mt-0.5">exit code {exitCode}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
export default function ThinkRenderer({ args }: ToolRendererProps) {
|
||||
const thought = (args.thought as string) ?? (args.content as string) ?? "";
|
||||
if (!thought) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-purple-400/80 font-semibold text-sm">Agent is thinking</span>
|
||||
<div className="mt-1.5 italic text-[#888]">
|
||||
<TruncatedText text={thought} maxLines={20} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { Circle, CircleDot, CircleCheckBig, Trash2, Plus, RefreshCw, CheckCheck, RotateCcw, Pencil } from "lucide-react";
|
||||
|
||||
interface TodoItem {
|
||||
id?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, { label: string; Icon: typeof Circle }> = {
|
||||
create_todo: { label: "Task added", Icon: Plus },
|
||||
list_todos: { label: "Plan", Icon: CheckCheck },
|
||||
update_todo: { label: "Task updated", Icon: Pencil },
|
||||
mark_todo_done: { label: "Task completed", Icon: CircleCheckBig },
|
||||
mark_todo_pending: { label: "Task reopened", Icon: RotateCcw },
|
||||
delete_todo: { label: "Task removed", Icon: Trash2 },
|
||||
};
|
||||
|
||||
function StatusIcon({ status }: { status: string }) {
|
||||
if (status === "done") return <CircleCheckBig className="w-3.5 h-3.5 text-emerald-400/70 shrink-0" />;
|
||||
if (status === "in_progress") return <CircleDot className="w-3.5 h-3.5 text-purple-400/70 shrink-0 animate-pulse" />;
|
||||
return <Circle className="w-3.5 h-3.5 text-[#444] shrink-0" />;
|
||||
}
|
||||
|
||||
function TodoList({ todos, highlightId }: { todos: TodoItem[]; highlightId?: string }) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{todos.map((todo, i) => {
|
||||
const s = todo.status ?? "pending";
|
||||
const isHighlighted = highlightId && todo.id === highlightId;
|
||||
return (
|
||||
<div
|
||||
key={todo.id ?? i}
|
||||
className={`flex items-start gap-2.5 py-1.5 px-2 -mx-2 rounded-md transition-colors ${
|
||||
isHighlighted ? "bg-purple-500/[0.08]" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="mt-[1px]">
|
||||
<StatusIcon status={s} />
|
||||
</div>
|
||||
<span
|
||||
className={`text-[13px] leading-snug ${
|
||||
s === "done"
|
||||
? "text-[#555] line-through"
|
||||
: s === "in_progress"
|
||||
? "text-[#bbb]"
|
||||
: "text-[#999]"
|
||||
}`}
|
||||
>
|
||||
{todo.title ?? "(untitled)"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TodoRenderer({ toolName, args, result }: ToolRendererProps) {
|
||||
const action = ACTION_LABELS[toolName] ?? { label: "Plan", Icon: RefreshCw };
|
||||
const ActionIcon = action.Icon;
|
||||
const res = result as Record<string, unknown> | string | null;
|
||||
|
||||
// Simple string result
|
||||
if (typeof res === "string" && res.trim()) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionIcon className="w-3.5 h-3.5 text-purple-400/60" />
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{action.label}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[#888] text-[13px]">{res.trim()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Parse structured result
|
||||
let todos: TodoItem[] = [];
|
||||
let error: string | null = null;
|
||||
let todoId: string | undefined;
|
||||
|
||||
if (res && typeof res === "object") {
|
||||
error = (res.error as string) ?? null;
|
||||
if (res.success) {
|
||||
const rawTodos = res.todos;
|
||||
todos = Array.isArray(rawTodos) ? (rawTodos as TodoItem[]) : [];
|
||||
}
|
||||
todoId = (res.id as string) ?? (args.todo_id as string) ?? undefined;
|
||||
}
|
||||
|
||||
// For mutations, highlight the affected item
|
||||
const highlightId = toolName !== "list_todos" ? todoId : undefined;
|
||||
|
||||
// No todos and no error — brief label only
|
||||
if (todos.length === 0 && !error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionIcon className="w-3.5 h-3.5 text-purple-400/60" />
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{action.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ActionIcon className="w-3.5 h-3.5 text-purple-400/60" />
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{action.label}</span>
|
||||
</div>
|
||||
{error && <div className="text-red-400/70 text-[13px] mb-2">{error}</div>}
|
||||
{todos.length > 0 && (
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/[0.015] px-3 py-2">
|
||||
<TodoList todos={todos} highlightId={highlightId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Markdown from "./Markdown";
|
||||
import hljs from "@/lib/hljs";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
|
||||
const OUTPUT_PREVIEW_LINES = 6;
|
||||
const CODE_PREVIEW_LINES = 20;
|
||||
|
||||
/** Truncatable markdown text with "Show more" */
|
||||
export function TruncatedText({ text, maxLines = 20 }: { text: string; maxLines?: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const lines = text.trimEnd().split("\n");
|
||||
const needsTruncation = lines.length > maxLines;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={expanded && needsTruncation ? "max-h-[1200px] overflow-auto" : ""}
|
||||
style={!expanded && needsTruncation ? { display: "-webkit-box", WebkitLineClamp: maxLines, WebkitBoxOrient: "vertical", overflow: "hidden" } : undefined}
|
||||
>
|
||||
<Markdown text={text} />
|
||||
</div>
|
||||
{needsTruncation && (
|
||||
<button onClick={() => setExpanded(!expanded)} className="text-xs text-[#555] hover:text-[#888] mt-1">
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Code/output block — truncates to 12 lines with "Show more", expanded view scrolls */
|
||||
export function CodeBlock({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isString = typeof children === "string";
|
||||
const lines = isString ? (children as string).trimEnd().split("\n") : null;
|
||||
const needsTruncation = lines !== null && lines.length > OUTPUT_PREVIEW_LINES;
|
||||
const displayContent = needsTruncation && !expanded
|
||||
? lines!.slice(0, OUTPUT_PREVIEW_LINES).join("\n")
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<pre className={`font-mono text-[13px] leading-relaxed whitespace-pre-wrap break-words mt-1 ${
|
||||
expanded ? "overflow-auto max-h-[1200px]" : "overflow-hidden"
|
||||
} ${className}`}>
|
||||
{displayContent}
|
||||
</pre>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-xs text-[#555] hover:text-[#888] mt-0.5"
|
||||
>
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Syntax-highlighted code block — no border, no line numbers, just highlighting.
|
||||
* Pass `collapsible` to get a "Show more" toggle instead of a scroll cap. */
|
||||
export function SyntaxBlock({ code, language, className = "", collapsible = false }: { code: string; language?: string; className?: string; collapsible?: boolean }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const lines = code.trimEnd().split("\n");
|
||||
const needsTruncation = collapsible && lines.length > CODE_PREVIEW_LINES;
|
||||
const displayCode = needsTruncation && !expanded
|
||||
? lines.slice(0, CODE_PREVIEW_LINES).join("\n")
|
||||
: code;
|
||||
|
||||
let highlighted: string;
|
||||
try {
|
||||
highlighted = language
|
||||
? hljs.highlight(displayCode, { language, ignoreIllegals: true }).value
|
||||
: hljs.highlightAuto(displayCode).value;
|
||||
} catch {
|
||||
highlighted = hljs.highlightAuto(displayCode).value;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<pre className={`font-mono text-[12px] leading-relaxed px-0 py-1 mt-1 whitespace-pre-wrap break-all ${
|
||||
collapsible
|
||||
? expanded ? "overflow-auto max-h-[1200px]" : "overflow-hidden"
|
||||
: "overflow-auto max-h-[400px]"
|
||||
} ${className}`}>
|
||||
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
</pre>
|
||||
{needsTruncation && (
|
||||
<button onClick={() => setExpanded(!expanded)} className="text-xs text-[#555] hover:text-[#888] mt-0.5">
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { shortPath } from "./utils";
|
||||
|
||||
/** Mirrors the OSS TUI `ViewImageRenderer`: surfaces load errors, otherwise a
|
||||
* compact "view image <path>" line. */
|
||||
export default function ViewImageRenderer({ args, result }: ToolRendererProps) {
|
||||
const path = ((args.path as string) ?? "").trim();
|
||||
|
||||
const res = result as Record<string, unknown> | string | null;
|
||||
let error: string | null = null;
|
||||
if (typeof res === "string") {
|
||||
const trimmed = res.trim();
|
||||
// A string result that isn't an image payload or structured data is an error message
|
||||
if (trimmed && !trimmed.toLowerCase().startsWith("data:image/") && !trimmed.startsWith("{")) {
|
||||
error = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sky-400/80 font-semibold text-sm shrink-0">view image</span>
|
||||
{path && <span className="text-[#888] font-mono text-[13px] break-all">{shortPath(path)}</span>}
|
||||
</div>
|
||||
{error && <div className="text-red-400/70 text-[13px] mt-1">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
import { MdCodeBlock } from "@/components/vulnerability/MdCodeBlock";
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
critical: "text-red-400", high: "text-orange-400", medium: "text-yellow-400",
|
||||
low: "text-blue-400", info: "text-cyan-400",
|
||||
};
|
||||
|
||||
export default function VulnReportRenderer({ args, result }: ToolRendererProps) {
|
||||
const title = (args.title as string) ?? "";
|
||||
const description = (args.description as string) ?? "";
|
||||
const impact = (args.impact as string) ?? "";
|
||||
const target = (args.target as string) ?? "";
|
||||
const endpoint = (args.endpoint as string) ?? "";
|
||||
const method = (args.method as string) ?? "";
|
||||
const technicalAnalysis = (args.technical_analysis as string) ?? "";
|
||||
const pocDescription = (args.poc_description as string) ?? "";
|
||||
const pocCode = (args.poc_script_code as string) ?? "";
|
||||
const remediation = (args.remediation_steps as string) ?? "";
|
||||
const cve = (args.cve as string) ?? "";
|
||||
const cwe = (args.cwe as string) ?? "";
|
||||
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const rawSev = (res && typeof res === "object" ? res.severity : null) ?? args.severity ?? "medium";
|
||||
const severity = String(rawSev).toLowerCase();
|
||||
const cvss = (res && typeof res === "object" ? (res.cvss_score as number) : null) ?? (args.cvss as number) ?? null;
|
||||
const sevColor = SEVERITY_COLORS[severity] ?? "text-yellow-400";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`font-semibold text-sm ${sevColor}`}>{severity.toUpperCase()}</span>
|
||||
{cvss != null && <span className="text-[#888] text-[13px]">CVSS {cvss}</span>}
|
||||
{cve && <span className="text-[#888] font-mono text-[13px]">{cve}</span>}
|
||||
{cwe && <span className="text-[#888] font-mono text-[13px]">{cwe}</span>}
|
||||
</div>
|
||||
{title && <div className="text-[15px] text-white/80 font-semibold">{title}</div>}
|
||||
{(target || endpoint) && (
|
||||
<div className="text-[13px] text-[#888] font-mono">{target}{endpoint ? ` ${method} ${endpoint}` : ""}</div>
|
||||
)}
|
||||
{description && <TruncatedText text={description} maxLines={20} />}
|
||||
{impact && (
|
||||
<div>
|
||||
<span className="text-emerald-400/60 text-sm font-semibold">Impact</span>
|
||||
<div className="mt-1"><TruncatedText text={impact} maxLines={15} /></div>
|
||||
</div>
|
||||
)}
|
||||
{technicalAnalysis && (
|
||||
<div>
|
||||
<span className="text-emerald-400/60 text-sm font-semibold">Technical Analysis</span>
|
||||
<div className="mt-1"><TruncatedText text={technicalAnalysis} maxLines={20} /></div>
|
||||
</div>
|
||||
)}
|
||||
{(pocDescription || pocCode) && (
|
||||
<div>
|
||||
<span className="text-emerald-400/60 text-sm font-semibold">Proof of Concept</span>
|
||||
{pocDescription && <div className="mt-1"><Markdown text={pocDescription} /></div>}
|
||||
{pocCode && <MdCodeBlock>{pocCode}</MdCodeBlock>}
|
||||
</div>
|
||||
)}
|
||||
{remediation && (
|
||||
<div>
|
||||
<span className="text-emerald-400/60 text-sm font-semibold">Remediation</span>
|
||||
<div className="mt-1"><TruncatedText text={remediation} maxLines={15} /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
export default function WebSearchRenderer({ args, result }: ToolRendererProps) {
|
||||
const query = (args.query as string) ?? (args.search_query as string) ?? "";
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const content = res ? (res.content as string) ?? null : null;
|
||||
const error = res && !res.success ? (res.message as string) ?? null : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-amber-400/80 font-semibold text-sm">Searching the web</span>
|
||||
{query && <div className="text-[#888] text-[13px] mt-0.5">{query}</div>}
|
||||
{error && <div className="text-red-400/70 text-[13px] mt-1.5">{error}</div>}
|
||||
{content && (
|
||||
<div className="mt-2">
|
||||
<TruncatedText text={content} maxLines={15} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import {
|
||||
Terminal, Globe, FileText, ShieldAlert, ArrowUpRight, Brain,
|
||||
Bot, MessageCircle, Flag, Eye, Search, Code, StickyNote,
|
||||
ListTodo, Crosshair, Wrench, Ban, Image,
|
||||
} from "lucide-react";
|
||||
|
||||
import TerminalRenderer from "./TerminalRenderer";
|
||||
import BrowserRenderer from "./BrowserRenderer";
|
||||
import FileEditRenderer from "./FileEditRenderer";
|
||||
import ApplyPatchRenderer from "./ApplyPatchRenderer";
|
||||
import ViewImageRenderer from "./ViewImageRenderer";
|
||||
import VulnReportRenderer from "./VulnReportRenderer";
|
||||
import ProxyRenderer from "./ProxyRenderer";
|
||||
import ThinkRenderer from "./ThinkRenderer";
|
||||
import AgentCommsRenderer from "./AgentCommsRenderer";
|
||||
import WebSearchRenderer from "./WebSearchRenderer";
|
||||
import PythonRenderer from "./PythonRenderer";
|
||||
import ScanInfoRenderer from "./ScanInfoRenderer";
|
||||
import FinishRenderer from "./FinishRenderer";
|
||||
import NotesRenderer from "./NotesRenderer";
|
||||
import TodoRenderer from "./TodoRenderer";
|
||||
import FallbackRenderer from "./FallbackRenderer";
|
||||
import LoadSkillRenderer from "./LoadSkillRenderer";
|
||||
|
||||
/**
|
||||
* Tool-renderer mapping — data-driven, keyed by the engine's tool *family*.
|
||||
*
|
||||
* The OSS strix engine (usestrix/strix) is the source of truth for tool names:
|
||||
* see `strix/tools/**` for definitions and `strix/interface/tui/renderers/` for
|
||||
* the TUI equivalents of these components. Tools come in families that share a
|
||||
* React renderer + icon (terminal, proxy, notes, todos, …), so we describe each
|
||||
* family ONCE instead of repeating a row per tool name. A new tool that joins an
|
||||
* existing family (e.g. another `*_request` proxy tool) is picked up by the
|
||||
* family prefix matcher with no code change; only genuinely-new families need an
|
||||
* entry here.
|
||||
*/
|
||||
|
||||
export type ToolCategory =
|
||||
| "terminal"
|
||||
| "python"
|
||||
| "browser"
|
||||
| "filesystem"
|
||||
| "proxy"
|
||||
| "reporting"
|
||||
| "thinking"
|
||||
| "agents"
|
||||
| "search"
|
||||
| "lifecycle"
|
||||
| "notes"
|
||||
| "skills"
|
||||
| "todos"
|
||||
| "telemetry";
|
||||
|
||||
export interface ToolIconMeta {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface CategoryMeta {
|
||||
renderer: ComponentType<ToolRendererProps>;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
/** Family matcher for graceful fallback of unknown tools in this family. */
|
||||
match?: RegExp;
|
||||
}
|
||||
|
||||
/** Per-family defaults: renderer + base icon/color + a family-name matcher. */
|
||||
const CATEGORY_META: Record<ToolCategory, CategoryMeta> = {
|
||||
terminal: { renderer: TerminalRenderer, icon: Terminal, color: "text-emerald-400" },
|
||||
python: { renderer: PythonRenderer, icon: Code, color: "text-yellow-400" },
|
||||
browser: { renderer: BrowserRenderer, icon: Globe, color: "text-blue-400" },
|
||||
filesystem: { renderer: FileEditRenderer, icon: FileText, color: "text-sky-400" },
|
||||
proxy: { renderer: ProxyRenderer, icon: ArrowUpRight, color: "text-purple-400", match: /request|sitemap|scope/ },
|
||||
reporting: { renderer: VulnReportRenderer, icon: ShieldAlert, color: "text-red-400" },
|
||||
thinking: { renderer: ThinkRenderer, icon: Brain, color: "text-purple-400" },
|
||||
agents: { renderer: AgentCommsRenderer, icon: Bot, color: "text-cyan-400", match: /agent/ },
|
||||
search: { renderer: WebSearchRenderer, icon: Search, color: "text-amber-400" },
|
||||
lifecycle: { renderer: ScanInfoRenderer, icon: Flag, color: "text-emerald-400" },
|
||||
notes: { renderer: NotesRenderer, icon: StickyNote, color: "text-amber-400", match: /note/ },
|
||||
skills: { renderer: LoadSkillRenderer, icon: Wrench, color: "text-emerald-400" },
|
||||
todos: { renderer: TodoRenderer, icon: ListTodo, color: "text-purple-400", match: /todo/ },
|
||||
telemetry: { renderer: FallbackRenderer, icon: Wrench, color: "text-[#555]" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool name → family. Grouped by family; legacy names the engine used before the
|
||||
* OSS SDK migration (terminal_execute, python_action, browser_action,
|
||||
* str_replace_editor, send_request, …) are kept as aliases so historical scan
|
||||
* data keeps rendering.
|
||||
*/
|
||||
const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
|
||||
// Shell — SDK `exec_command` / `write_stdin` (legacy: terminal_execute)
|
||||
terminal: ["exec_command", "write_stdin", "terminal_execute"],
|
||||
// Legacy Python session tool (now runs through the shell)
|
||||
python: ["python_action"],
|
||||
// Legacy browser tool (now driven via agent-browser CLI over the shell)
|
||||
browser: ["browser_action"],
|
||||
// SDK filesystem — `apply_patch` / `view_image` (legacy: str_replace_editor, list/search)
|
||||
filesystem: ["apply_patch", "view_image", "str_replace_editor", "list_files", "search_files"],
|
||||
// Caido proxy tools (legacy: send_request)
|
||||
proxy: ["list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules", "send_request"],
|
||||
reporting: ["create_vulnerability_report"],
|
||||
thinking: ["think"],
|
||||
agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_message", "view_agent_graph", "stop_agent"],
|
||||
search: ["web_search"],
|
||||
// scan_start_info / subagent_start_info are strix-app synthetic events; finish_scan is the engine's
|
||||
lifecycle: ["scan_start_info", "subagent_start_info", "finish_scan"],
|
||||
notes: ["create_note", "delete_note", "update_note", "list_notes", "get_note"],
|
||||
skills: ["load_skill"],
|
||||
todos: ["create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo"],
|
||||
telemetry: ["sandbox_error_details", "llm_error_details"],
|
||||
};
|
||||
|
||||
/** Reverse index (tool name → family), built once from CATEGORY_TOOLS. */
|
||||
const TOOL_CATEGORY: Record<string, ToolCategory> = Object.fromEntries(
|
||||
(Object.entries(CATEGORY_TOOLS) as [ToolCategory, readonly string[]][]).flatMap(
|
||||
([category, names]) => names.map((name) => [name, category] as const),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-tool renderer overrides — for the rare tool whose renderer differs from its
|
||||
* family default (finish_scan renders the final report, not the scan-start card).
|
||||
*/
|
||||
const RENDERER_OVERRIDES: Partial<Record<string, ComponentType<ToolRendererProps>>> = {
|
||||
finish_scan: FinishRenderer,
|
||||
apply_patch: ApplyPatchRenderer,
|
||||
view_image: ViewImageRenderer,
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-tool icon overrides — for tools whose icon/color differs from their family
|
||||
* default (the agents family and lifecycle family each vary per tool).
|
||||
*/
|
||||
const ICON_OVERRIDES: Partial<Record<string, ToolIconMeta>> = {
|
||||
agent_finish: { icon: Flag, color: "text-cyan-400" },
|
||||
send_message_to_agent: { icon: MessageCircle, color: "text-cyan-400" },
|
||||
wait_for_message: { icon: MessageCircle, color: "text-cyan-400" },
|
||||
view_agent_graph: { icon: Eye, color: "text-cyan-400" },
|
||||
stop_agent: { icon: Ban, color: "text-red-400" },
|
||||
scan_start_info: { icon: Crosshair, color: "text-emerald-400" },
|
||||
subagent_start_info: { icon: Bot, color: "text-purple-400" },
|
||||
view_image: { icon: Image, color: "text-sky-400" },
|
||||
};
|
||||
|
||||
const FALLBACK_META: CategoryMeta = CATEGORY_META.telemetry;
|
||||
|
||||
/** Resolve a tool name to its family, falling back to family-name matchers. */
|
||||
function resolveCategory(toolName: string): ToolCategory | null {
|
||||
const direct = TOOL_CATEGORY[toolName];
|
||||
if (direct) return direct;
|
||||
for (const [category, meta] of Object.entries(CATEGORY_META) as [ToolCategory, CategoryMeta][]) {
|
||||
if (meta.match?.test(toolName)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getToolRenderer(toolName: string): ComponentType<ToolRendererProps> {
|
||||
const override = RENDERER_OVERRIDES[toolName];
|
||||
if (override) return override;
|
||||
const category = resolveCategory(toolName);
|
||||
return category ? CATEGORY_META[category].renderer : FallbackRenderer;
|
||||
}
|
||||
|
||||
export function getToolIcon(toolName: string): ToolIconMeta {
|
||||
const override = ICON_OVERRIDES[toolName];
|
||||
if (override) return override;
|
||||
const category = resolveCategory(toolName);
|
||||
const meta = category ? CATEGORY_META[category] : FALLBACK_META;
|
||||
return { icon: meta.icon, color: meta.color };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function shortPath(p: string): string {
|
||||
return p.length > 60 ? "..." + p.slice(-57) : p;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import hljs from "@/lib/hljs";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { diffLines } from "diff";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { getLanguageFromFile, copyToClipboard } from "@/lib/vulnerability-utils";
|
||||
|
||||
function safeHighlight(code: string, lang: string): string {
|
||||
try {
|
||||
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
|
||||
} catch {
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
}
|
||||
|
||||
interface CodeDiffBlockProps {
|
||||
file: string;
|
||||
startLine: number;
|
||||
endLine?: number;
|
||||
before: string;
|
||||
after: string;
|
||||
onCopy?: () => void;
|
||||
}
|
||||
|
||||
export function CodeDiffBlock({ file, startLine, endLine, before, after, onCopy }: CodeDiffBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const lineRef =
|
||||
endLine && endLine !== startLine ? `${startLine}-${endLine}` : `${startLine}`;
|
||||
const lang = getLanguageFromFile(file) || "text";
|
||||
const changes = diffLines(before, after);
|
||||
|
||||
let oldLineNo = startLine;
|
||||
let newLineNo = startLine;
|
||||
const rows = changes.flatMap((change) =>
|
||||
change.value
|
||||
.replace(/\n$/, "")
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const highlighted =
|
||||
line === ""
|
||||
? "\n"
|
||||
: lang !== "text"
|
||||
? safeHighlight(line, lang)
|
||||
: line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
let leftNo = "";
|
||||
let rightNo = "";
|
||||
if (change.removed) {
|
||||
leftNo = String(oldLineNo++);
|
||||
} else if (change.added) {
|
||||
rightNo = String(newLineNo++);
|
||||
} else {
|
||||
leftNo = String(oldLineNo++);
|
||||
rightNo = String(newLineNo++);
|
||||
}
|
||||
return { highlighted, added: !!change.added, removed: !!change.removed, leftNo, rightNo };
|
||||
})
|
||||
);
|
||||
|
||||
const copy = () => {
|
||||
copyToClipboard(after);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
onCopy?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[#2a2a2a] overflow-hidden">
|
||||
<div className="flex items-stretch">
|
||||
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a] break-all">
|
||||
{file}:{lineRef}
|
||||
<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" />
|
||||
</span>
|
||||
<div className="flex-1 border-b border-[#2a2a2a]" />
|
||||
<button
|
||||
onClick={copy}
|
||||
className="px-3 py-2 text-[#555] hover:text-white transition-colors flex-shrink-0 border-b border-[#2a2a2a]"
|
||||
aria-label="Copy fixed code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[400px]">
|
||||
<table className="w-full border-collapse font-mono text-[12px] leading-[22px] [font-variant-ligatures:none]">
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className={row.added ? "bg-blue-500/[0.12]" : row.removed ? "bg-red-500/[0.12]" : ""}
|
||||
>
|
||||
<td className="select-none w-[1px] whitespace-nowrap pl-4 pr-1.5 text-right text-[#555] align-top text-[12px] leading-[22px]">
|
||||
{row.leftNo}
|
||||
</td>
|
||||
<td className="select-none w-[1px] whitespace-nowrap pl-1.5 pr-4 text-right text-[#555] align-top text-[12px] leading-[22px] border-r border-[#2a2a2a]">
|
||||
{row.rightNo}
|
||||
</td>
|
||||
<td
|
||||
className="pl-4 pr-4 whitespace-pre"
|
||||
dangerouslySetInnerHTML={{ __html: row.highlighted }}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { rehypeCodeMeta, mdComponents } from "./MdCodeBlock";
|
||||
|
||||
export function ContentSection({ title, content, action }: { title?: string; content: string; action?: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
{(title || action) && (
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
{title ? <h2 className="text-xl font-semibold text-white">{title}</h2> : <span />}
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
<div className="prose-markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeCodeMeta]}
|
||||
components={mdComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Clock, Globe, ChevronDown } from "lucide-react";
|
||||
import { parseTarget } from "@/lib/target-utils";
|
||||
import { ProviderIcon } from "@/components/AddRepositoryDialog";
|
||||
import { getSeverityDot } from "@/lib/vulnerability-utils";
|
||||
import { formatTimeAgo } from "@/lib/utils";
|
||||
import { FIX_EFFORT_META, type FixEffort, type Vulnerability } from "@/types/issues";
|
||||
|
||||
/* ─── Human-friendly CVSS labels ─── */
|
||||
|
||||
const HUMAN_LABELS: Record<string, Record<string, string>> = {
|
||||
attack_vector: { N: "Remotely exploitable", A: "Adjacent network", L: "Local access required", P: "Physical access required" },
|
||||
attack_complexity: { L: "Easy to exploit", H: "Requires specific conditions" },
|
||||
privileges_required: { N: "No authentication needed", L: "Low privileges needed", H: "High privileges needed" },
|
||||
user_interaction: { N: "No user action required", R: "Requires user action", P: "Passive user role", A: "Active user role" },
|
||||
scope: { U: "Impact stays contained", C: "Can spread to other systems" },
|
||||
confidentiality: { N: "No data exposure", L: "Partial data exposure", H: "Full data exposure" },
|
||||
integrity: { N: "No data modification", L: "Limited modification", H: "Full data modification" },
|
||||
availability: { N: "No service disruption", L: "Limited disruption", H: "Full service disruption" },
|
||||
};
|
||||
|
||||
const RISK_LEVEL: Record<string, Record<string, "low" | "medium" | "high">> = {
|
||||
attack_vector: { N: "high", A: "medium", L: "low", P: "low" },
|
||||
attack_complexity: { L: "high", H: "low" },
|
||||
privileges_required: { N: "high", L: "medium", H: "low" },
|
||||
user_interaction: { N: "high", R: "low", P: "medium", A: "low" },
|
||||
scope: { C: "high", U: "low" },
|
||||
confidentiality: { H: "high", L: "medium", N: "low" },
|
||||
integrity: { H: "high", L: "medium", N: "low" },
|
||||
availability: { H: "high", L: "medium", N: "low" },
|
||||
};
|
||||
|
||||
const RISK_BADGE: Record<string, string> = {
|
||||
high: "bg-red-500/15 text-red-400 border-red-500/25",
|
||||
medium: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25",
|
||||
low: "bg-[#222] text-[#666] border-[#333]",
|
||||
};
|
||||
|
||||
const FACTOR_GROUPS: { label: string; keys: string[] }[] = [
|
||||
{ label: "Exploitability", keys: ["attack_vector", "attack_complexity", "privileges_required", "user_interaction"] },
|
||||
{ label: "Impact", keys: ["scope", "confidentiality", "integrity", "availability"] },
|
||||
];
|
||||
|
||||
/* ─── Location link builder ─── */
|
||||
|
||||
export function buildLocationHref(
|
||||
repoUrl: string,
|
||||
provider: string,
|
||||
branch: string,
|
||||
file: string,
|
||||
startLine: number,
|
||||
): string | null {
|
||||
const base = repoUrl.replace(/\.git$/, "").replace(/\/+$/, "");
|
||||
const encodedFile = file.split("/").map(encodeURIComponent).join("/");
|
||||
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
|
||||
|
||||
if (provider === "github") {
|
||||
return `${base}/blob/${encodedBranch}/${encodedFile}#L${startLine}`;
|
||||
}
|
||||
if (provider === "gitlab") {
|
||||
return `${base}/-/blob/${encodedBranch}/${encodedFile}#L${startLine}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ─── Props ─── */
|
||||
|
||||
interface IssueSidebarProps {
|
||||
vulnerability: Vulnerability;
|
||||
statusSlot: React.ReactNode;
|
||||
slackThreadUrl?: string | null;
|
||||
}
|
||||
|
||||
/* ─── Component ─── */
|
||||
|
||||
export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: IssueSidebarProps) {
|
||||
const { severity, cvss, cve, cwe, fix_effort, created_at, target, endpoint, method, code_locations, cvss_breakdown, location_meta } = vulnerability;
|
||||
|
||||
const [riskOpen, setRiskOpen] = useState(true);
|
||||
|
||||
const fixLocations = code_locations?.filter((loc) => loc.fix_before && loc.fix_after);
|
||||
const hasLocations = fixLocations && fixLocations.length > 0;
|
||||
const parsed = target ? parseTarget(target) : null;
|
||||
const hasAsset = !!(target || endpoint || method || hasLocations);
|
||||
|
||||
const hasBreakdown = cvss_breakdown && Object.values(cvss_breakdown).some((v) => v != null);
|
||||
|
||||
return (
|
||||
<aside className="lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
|
||||
{/* ─── Metadata ─── */}
|
||||
<div className="pb-4">
|
||||
<div className="space-y-3">
|
||||
{/* Severity */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Severity</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${getSeverityDot(severity)}`} aria-hidden="true" />
|
||||
<span className="text-sm font-medium capitalize text-white">{severity}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CVSS */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">CVSS Score</span>
|
||||
<span className="text-sm font-semibold tabular-nums text-white">{cvss !== null ? cvss : "N/A"}</span>
|
||||
</div>
|
||||
|
||||
{/* CVE */}
|
||||
{cve && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">CVE</span>
|
||||
<span className="text-sm text-white font-mono">{cve}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CWE */}
|
||||
{cwe && cwe.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">CWE</span>
|
||||
<span className="text-xs text-white font-mono truncate max-w-[80%] text-right" title={cwe.join(" · ")}>
|
||||
{cwe.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fix Effort */}
|
||||
{fix_effort && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Fix Effort</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 text-[11px] font-medium rounded-full border ${FIX_EFFORT_META[fix_effort as FixEffort]?.color ?? "text-[#666]"}`}>
|
||||
{fix_effort.charAt(0).toUpperCase() + fix_effort.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discovered */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Discovered</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3 text-[#444]" aria-hidden="true" />
|
||||
<span className="text-sm text-white">{formatTimeAgo(created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Status</span>
|
||||
{statusSlot}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Asset ─── */}
|
||||
{hasAsset && (
|
||||
<div className="border-t border-[#191919] pt-4 pb-4">
|
||||
<p className="text-xs font-medium text-[#aaa] mb-2.5">Asset</p>
|
||||
<div className="space-y-2.5">
|
||||
{target && parsed && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{parsed.provider ? (
|
||||
<span className="flex-shrink-0 [&_svg]:w-3.5 [&_svg]:h-3.5" aria-hidden="true">
|
||||
<ProviderIcon provider={parsed.provider} />
|
||||
</span>
|
||||
) : (
|
||||
<Globe className="w-3.5 h-3.5 text-[#555] flex-shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{parsed.href ? (
|
||||
<a
|
||||
href={parsed.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-white hover:text-[#ccc] break-words min-w-0 transition-colors"
|
||||
>
|
||||
{parsed.display}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-white break-words min-w-0">{parsed.display}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{endpoint && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Endpoint</span>
|
||||
<span className="text-xs text-white font-mono truncate max-w-[75%] text-right">{endpoint}</span>
|
||||
</div>
|
||||
)}
|
||||
{method && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Method</span>
|
||||
<span className="text-xs text-white font-mono">{method}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasLocations && (
|
||||
<div>
|
||||
<span className="text-xs text-[#aaa] mb-1.5 block">Locations</span>
|
||||
<div className="space-y-0.5">
|
||||
{fixLocations!.map((loc, i) => {
|
||||
const label = `${loc.file}:${loc.start_line}`;
|
||||
const href = location_meta
|
||||
? buildLocationHref(location_meta.repo_url, location_meta.provider, location_meta.branch, loc.file, loc.start_line)
|
||||
: null;
|
||||
return href ? (
|
||||
<a
|
||||
key={`loc-${i}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[13px] text-[#888] hover:text-white font-mono break-all transition-colors block"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span key={`loc-${i}`} className="text-[13px] text-[#888] font-mono break-all block">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Risk Assessment (human-friendly CVSS) ─── */}
|
||||
{hasBreakdown && (
|
||||
<div className="border-t border-[#191919] pt-4">
|
||||
<button
|
||||
onClick={() => setRiskOpen(!riskOpen)}
|
||||
className="flex items-center justify-between w-full mb-2.5 group"
|
||||
aria-expanded={riskOpen}
|
||||
>
|
||||
<span className="text-xs font-medium text-[#aaa]">Risk Assessment</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-[#555] group-hover:text-white transition-transform ${riskOpen ? "" : "-rotate-90"}`} aria-hidden="true" />
|
||||
</button>
|
||||
<div className={`space-y-3 ${riskOpen ? "" : "hidden"}`}>
|
||||
{FACTOR_GROUPS.map((group) => {
|
||||
const factors = group.keys.filter(
|
||||
(k) => (cvss_breakdown as unknown as Record<string, string | null>)[k] != null
|
||||
);
|
||||
if (factors.length === 0) return null;
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#444] font-medium">
|
||||
{group.label}
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#444] font-medium mr-2">
|
||||
Risk
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{factors.map((key) => {
|
||||
const raw = (cvss_breakdown as unknown as Record<string, string | null>)[key];
|
||||
const level = raw ? (RISK_LEVEL[key]?.[raw] ?? "low") : "low";
|
||||
const label = raw ? (HUMAN_LABELS[key]?.[raw] ?? raw) : "N/A";
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between py-0.5">
|
||||
<span className="text-[12px] text-[#aaa]">{label}</span>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${RISK_BADGE[level]}`}>
|
||||
{level}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import hljs from "@/lib/hljs";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { copyToClipboard } from "@/lib/vulnerability-utils";
|
||||
|
||||
export function MdCodeBlock({
|
||||
className,
|
||||
children,
|
||||
node,
|
||||
}: {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
node?: { data?: { meta?: string }; properties?: { metastring?: string } };
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const raw = String(children).replace(/\n$/, "");
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const isBlock = raw.includes("\n") || match;
|
||||
|
||||
if (!isBlock) {
|
||||
return <code className={`${className || ""} bg-white/8 px-1.5 py-0.5 rounded text-[13px]`}>{children}</code>;
|
||||
}
|
||||
|
||||
const meta = node?.data?.meta || node?.properties?.metastring || "";
|
||||
const titleMatch = /title=["']?([^"'\s}]+)["']?/.exec(meta);
|
||||
const startMatch = /startLineNumber=(\d+)/.exec(meta);
|
||||
const fileName = titleMatch?.[1] || null;
|
||||
const startLine = startMatch ? parseInt(startMatch[1], 10) : 1;
|
||||
const headerLabel = fileName
|
||||
? startMatch
|
||||
? `${fileName}:${startLine}`
|
||||
: fileName
|
||||
: null;
|
||||
|
||||
let highlighted: string;
|
||||
if (match) {
|
||||
try {
|
||||
highlighted = hljs.highlight(raw, { language: match[1], ignoreIllegals: true }).value;
|
||||
} catch {
|
||||
highlighted = hljs.highlightAuto(raw).value;
|
||||
}
|
||||
} else {
|
||||
highlighted = hljs.highlightAuto(raw).value;
|
||||
}
|
||||
|
||||
const lines = highlighted.split("\n");
|
||||
|
||||
const copy = () => {
|
||||
copyToClipboard(raw);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group/code relative rounded-md border border-[#2a2a2a] my-4 text-[#ddd] overflow-hidden">
|
||||
{headerLabel ? (
|
||||
<div className="flex items-stretch">
|
||||
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]">{headerLabel}<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" /></span>
|
||||
<div className="flex-1 border-b border-[#2a2a2a]" />
|
||||
<button
|
||||
onClick={copy}
|
||||
className="px-3 py-2 text-[#555] hover:text-white transition-colors border-b border-[#2a2a2a]"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={copy}
|
||||
className="absolute top-2 right-2 z-10 p-1 rounded text-[#444] hover:text-white opacity-0 group-hover/code:opacity-100 transition-opacity"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="overflow-auto max-h-[400px]">
|
||||
<table className="w-full border-collapse font-mono text-[12px] leading-[22px] [font-variant-ligatures:none]">
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i}>
|
||||
<td className="select-none w-[1px] whitespace-nowrap px-4 text-right text-[#555] align-top text-[12px] leading-[22px] border-r border-[#2a2a2a]">
|
||||
{startLine + i}
|
||||
</td>
|
||||
<td
|
||||
className="pl-4 pr-4 whitespace-pre"
|
||||
dangerouslySetInnerHTML={{ __html: line || "\n" }}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// rehype plugin: pass code fence meta string through to code element properties
|
||||
type HastNode = {
|
||||
type: string;
|
||||
tagName?: string;
|
||||
children?: HastNode[];
|
||||
properties?: Record<string, unknown>;
|
||||
data?: { meta?: string };
|
||||
};
|
||||
|
||||
export function rehypeCodeMeta() {
|
||||
return (tree: HastNode) => {
|
||||
const visit = (node: HastNode) => {
|
||||
if (node.type === "element" && node.tagName === "pre" && node.children) {
|
||||
const codeEl = node.children.find(
|
||||
(c) => c.type === "element" && c.tagName === "code"
|
||||
);
|
||||
if (codeEl?.data?.meta) {
|
||||
codeEl.properties = codeEl.properties || {};
|
||||
codeEl.properties.metastring = codeEl.data.meta;
|
||||
}
|
||||
}
|
||||
if (node.children) {
|
||||
node.children.forEach((child) => visit(child));
|
||||
}
|
||||
};
|
||||
visit(tree);
|
||||
};
|
||||
}
|
||||
|
||||
export const mdComponents = {
|
||||
code: MdCodeBlock as React.ComponentType<React.HTMLAttributes<HTMLElement>>,
|
||||
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import hljs from "@/lib/hljs";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { copyToClipboard } from "@/lib/vulnerability-utils";
|
||||
import { rehypeCodeMeta, mdComponents } from "./MdCodeBlock";
|
||||
|
||||
interface PocBlockProps {
|
||||
description?: string | null;
|
||||
scriptCode?: string | null;
|
||||
onCopy?: () => void;
|
||||
}
|
||||
|
||||
export function PocBlock({ description, scriptCode, onCopy }: PocBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!description && !scriptCode) return null;
|
||||
|
||||
const copy = () => {
|
||||
if (!scriptCode) return;
|
||||
copyToClipboard(scriptCode);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
onCopy?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-white mb-3">Proof of Concept</h2>
|
||||
<div className="space-y-4">
|
||||
{description && (
|
||||
<div className="prose-markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeCodeMeta]}
|
||||
components={mdComponents}
|
||||
>
|
||||
{description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{scriptCode && (
|
||||
<div className="group/poc relative rounded-md border border-[#2a2a2a] overflow-hidden">
|
||||
<div className="flex items-stretch">
|
||||
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]">PoC Script<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" /></span>
|
||||
<div className="flex-1 border-b border-[#2a2a2a]" />
|
||||
<button
|
||||
onClick={copy}
|
||||
className="px-3 py-2 text-[#555] hover:text-white transition-colors flex-shrink-0 border-b border-[#2a2a2a]"
|
||||
aria-label="Copy PoC code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[400px] px-4 py-3">
|
||||
<pre className="font-mono text-[12px] leading-[22px] whitespace-pre [font-variant-ligatures:none]">
|
||||
<code
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: hljs.highlight(scriptCode, { language: "python" }).value,
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Clock, CheckCircle2, Ban, History, BellOff, Wrench, GitMerge, GitPullRequest } from "lucide-react";
|
||||
import { ProInlineCta } from "@/components/ProCta";
|
||||
import { Vulnerability, VulnerabilityStatus, SEVERITY_COLORS, STATUS_META, isSeverityOverridden } from "@/types/issues";
|
||||
import { formatTimeAgo } from "@/lib/utils";
|
||||
import { getSeverityDot } from "@/lib/vulnerability-utils";
|
||||
import { formatStrixId } from "@/lib/display-number";
|
||||
import { ContentSection } from "@/components/vulnerability/ContentSection";
|
||||
import { CodeDiffBlock } from "@/components/vulnerability/CodeDiffBlock";
|
||||
import { PocBlock } from "@/components/vulnerability/PocBlock";
|
||||
import { IssueSidebar } from "@/components/vulnerability/IssueSidebar";
|
||||
|
||||
function bannerTime(dateString: string | null): string {
|
||||
if (!dateString) return "";
|
||||
const diffInSeconds = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000);
|
||||
if (diffInSeconds < 604800) return ` ${formatTimeAgo(dateString)}`;
|
||||
return ` on ${formatTimeAgo(dateString)}`;
|
||||
}
|
||||
|
||||
const STATUS_BANNER: Record<VulnerabilityStatus, { icon: React.ElementType; label: string; iconColor: string } | null> = {
|
||||
open: null,
|
||||
in_progress: { icon: Clock, label: "Marked as In Progress", iconColor: "text-blue-400" },
|
||||
snoozed: { icon: BellOff, label: "Snoozed", iconColor: "text-purple-400" },
|
||||
fixed: { icon: CheckCircle2, label: "Marked as Fixed", iconColor: "text-emerald-400" },
|
||||
ignored: { icon: Ban, label: "Marked as Ignored", iconColor: "text-[#888]" },
|
||||
};
|
||||
|
||||
type BottomTab = "fix" | "reproduction";
|
||||
|
||||
interface VulnerabilityDetailProps {
|
||||
vulnerability: Vulnerability;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained finding detail (header + status banners + content grid),
|
||||
* without page chrome. Shared by the public /share/issues page and the local
|
||||
* /results view so both render findings identically.
|
||||
*/
|
||||
export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDetailProps) {
|
||||
const currentMeta = STATUS_META[vulnerability.status];
|
||||
const hasCodeLocations = vulnerability.code_locations && vulnerability.code_locations.length > 0;
|
||||
const hasFix = hasCodeLocations || vulnerability.remediation_steps;
|
||||
const hasReproduction = !!(vulnerability.evidence || vulnerability.assumptions || vulnerability.poc_description || vulnerability.poc_script_code);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<BottomTab>("fix");
|
||||
|
||||
const bottomTabs: { id: BottomTab; label: string; show: boolean }[] = [
|
||||
{ id: "fix", label: "Fix", show: !!hasFix },
|
||||
{ id: "reproduction", label: "Reproduction", show: hasReproduction },
|
||||
];
|
||||
const visibleTabs = bottomTabs.filter((t) => t.show);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
{vulnerability.display_number && (
|
||||
<span className="text-xs font-mono text-[#555] block mb-1">
|
||||
{formatStrixId(vulnerability.display_number)}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold text-white">{vulnerability.title}</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full border ${currentMeta.color}`}>
|
||||
{currentMeta.label}
|
||||
</span>
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-semibold rounded-full border ${SEVERITY_COLORS[vulnerability.severity]}`}
|
||||
title={isSeverityOverridden(vulnerability) ? `Adjusted from ${vulnerability.original_severity}` : undefined}
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${getSeverityDot(vulnerability.severity)}`} />
|
||||
<span className="capitalize">
|
||||
{vulnerability.severity}
|
||||
{!isSeverityOverridden(vulnerability) && vulnerability.cvss ? ` ${vulnerability.cvss}` : ""}
|
||||
</span>
|
||||
{isSeverityOverridden(vulnerability) && (
|
||||
<History className="w-3 h-3 opacity-70" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
{vulnerability.cve && (
|
||||
<>
|
||||
<span className="text-[#333]">·</span>
|
||||
<span className="text-sm text-[#666] font-mono">{vulnerability.cve}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status banner */}
|
||||
{vulnerability.status !== "open" && (() => {
|
||||
const banner = STATUS_BANNER[vulnerability.status];
|
||||
if (!banner) return null;
|
||||
const BannerIcon = banner.icon;
|
||||
return (
|
||||
<div className="rounded-lg px-4 py-3.5 flex gap-3" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<BannerIcon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${banner.iconColor}`} aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{banner.label}{bannerTime(vulnerability.status_changed_at)}
|
||||
</p>
|
||||
{vulnerability.status_note && (
|
||||
<p className="text-sm text-[#666] italic mt-1">
|
||||
“{vulnerability.status_note}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Severity override banner */}
|
||||
{isSeverityOverridden(vulnerability) && (
|
||||
<div className="rounded-lg px-4 py-3.5 flex gap-3" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<History className="w-5 h-5 flex-shrink-0 mt-0.5 text-orange-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
Severity changed manually from{" "}
|
||||
<span className="capitalize">{vulnerability.original_severity}</span>
|
||||
{vulnerability.cvss != null ? ` (${vulnerability.cvss})` : ""} to{" "}
|
||||
<span className="capitalize">{vulnerability.severity}</span>
|
||||
{bannerTime(vulnerability.severity_changed_at)}
|
||||
</p>
|
||||
{vulnerability.severity_override_reason && (
|
||||
<p className="text-sm text-[#666] italic mt-1">
|
||||
“{vulnerability.severity_override_reason}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-8">
|
||||
{/* Main content */}
|
||||
<div className="min-w-0">
|
||||
<div className="space-y-8">
|
||||
<ContentSection title="TL;DR" content={vulnerability.description} />
|
||||
|
||||
{vulnerability.impact && <ContentSection title="Impact" content={vulnerability.impact} />}
|
||||
|
||||
{vulnerability.technical_analysis && (
|
||||
<ContentSection title="Technical Details" content={vulnerability.technical_analysis} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom tabs */}
|
||||
{visibleTabs.length > 0 && (
|
||||
<div className="mt-10">
|
||||
<div className="border-b border-[#2a2a2a]">
|
||||
<nav className="flex gap-6" aria-label="Tabs">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative min-w-[80px] text-center pb-3 text-[16px] font-semibold transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "text-white"
|
||||
: "text-[#666] hover:text-white"
|
||||
}`}
|
||||
aria-current={activeTab === tab.id ? "page" : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<span className="absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Fix tab */}
|
||||
{hasFix && (
|
||||
<div className={`pt-6 space-y-6 ${activeTab === "fix" ? "animate-tab-in" : "hidden"}`}>
|
||||
{vulnerability.remediation_steps && (
|
||||
<ContentSection title="How do I fix it?" content={vulnerability.remediation_steps} />
|
||||
)}
|
||||
|
||||
{hasCodeLocations &&
|
||||
vulnerability.code_locations!
|
||||
.filter((loc) => loc.fix_before && loc.fix_after)
|
||||
.map((loc, i) => (
|
||||
<CodeDiffBlock
|
||||
key={`fix-${i}`}
|
||||
file={loc.file}
|
||||
startLine={loc.start_line}
|
||||
endLine={loc.end_line}
|
||||
before={loc.fix_before!}
|
||||
after={loc.fix_after!}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reproduction tab */}
|
||||
{hasReproduction && (
|
||||
<div className={`pt-6 space-y-8 ${activeTab === "reproduction" ? "animate-tab-in" : "hidden"}`}>
|
||||
{vulnerability.assumptions && (
|
||||
<ContentSection title="Assumptions" content={vulnerability.assumptions} />
|
||||
)}
|
||||
|
||||
{vulnerability.evidence && (
|
||||
<ContentSection title="Evidence" content={vulnerability.evidence} />
|
||||
)}
|
||||
|
||||
<PocBlock
|
||||
description={vulnerability.poc_description}
|
||||
scriptCode={vulnerability.poc_script_code}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="lg:border-l lg:border-[#2a2a2a] lg:pl-6">
|
||||
<IssueSidebar
|
||||
vulnerability={vulnerability}
|
||||
statusSlot={
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border ${currentMeta.color}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${currentMeta.dotColor}`} />
|
||||
{currentMeta.label}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team-workflow CTAs (Pro). Highest-intent surface: act on this finding. */}
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-semibold text-white">Ship the fix with your team</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">
|
||||
Take this finding into your team's workflow.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Auto-fix & open a PR"
|
||||
desc="Fix it for you and open a PR, retested."
|
||||
slug="autofix"
|
||||
icon={Wrench}
|
||||
surface="finding_detail"
|
||||
/>
|
||||
<ProInlineCta
|
||||
label="Sync to Jira / Linear"
|
||||
desc="Two-way sync findings to Jira, Linear, and Slack."
|
||||
slug="integrations"
|
||||
icon={GitMerge}
|
||||
surface="finding_detail"
|
||||
/>
|
||||
<ProInlineCta
|
||||
label="Catch this in PR reviews"
|
||||
desc="Pentest every pull request your team opens."
|
||||
slug="pr_reviews"
|
||||
icon={GitPullRequest}
|
||||
surface="finding_detail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Vulnerability } from "@/types/issues";
|
||||
import {
|
||||
parseRunJson,
|
||||
parseVulnerabilitiesJson,
|
||||
type ParsedRunSummary,
|
||||
} from "@/lib/local-run-parser";
|
||||
|
||||
/**
|
||||
* Data seam for the local viewer. Replaces strix-app's browser file-picker
|
||||
* (`loadFromTexts`) with fetches against the local Python server's JSON
|
||||
* endpoints (same origin, relative URLs). Produces the same in-memory
|
||||
* `LoadedRun` shape the UI renders, plus a `finished` flag driving live polling.
|
||||
*
|
||||
* The server serves a live in-progress run and a finished one identically; the
|
||||
* only signal is `run.finished`.
|
||||
*/
|
||||
|
||||
/** A transcript agent as emitted by GET /api/transcript (already parsed). */
|
||||
export interface TranscriptAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Chat/tool event data as emitted by GET /api/transcript. */
|
||||
export interface TranscriptEvent {
|
||||
id: string;
|
||||
type: "chat" | "tool";
|
||||
agent_id: string;
|
||||
timestamp: string;
|
||||
version: number;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Transcript {
|
||||
agents: TranscriptAgent[];
|
||||
events: TranscriptEvent[];
|
||||
}
|
||||
|
||||
export interface LoadedRun {
|
||||
summary: ParsedRunSummary;
|
||||
/** Whole raw run record (for llm_usage, targets_info details, etc.). */
|
||||
raw: Record<string, unknown>;
|
||||
finished: boolean;
|
||||
vulnerabilities: Vulnerability[];
|
||||
reportMarkdown: string | null;
|
||||
transcript: Transcript;
|
||||
}
|
||||
|
||||
async function getJson(path: string): Promise<unknown> {
|
||||
const res = await fetch(path, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Build a ``?run=<name>`` suffix for run-scoped data endpoints. */
|
||||
function runQuery(runName?: string | null): string {
|
||||
return runName ? `?run=${encodeURIComponent(runName)}` : "";
|
||||
}
|
||||
|
||||
export async function fetchRunSummary(runName?: string | null): Promise<{
|
||||
summary: ParsedRunSummary;
|
||||
raw: Record<string, unknown>;
|
||||
finished: boolean;
|
||||
}> {
|
||||
const raw = (await getJson("/api/run" + runQuery(runName))) as Record<string, unknown>;
|
||||
// parseRunJson tolerates extra keys and takes raw TEXT.
|
||||
const summary = parseRunJson(JSON.stringify(raw));
|
||||
const finished = raw.finished === true;
|
||||
return { summary, raw, finished };
|
||||
}
|
||||
|
||||
export async function fetchVulnerabilities(
|
||||
runId: string | null,
|
||||
runName?: string | null
|
||||
): Promise<Vulnerability[]> {
|
||||
const arr = await getJson("/api/vulnerabilities" + runQuery(runName));
|
||||
return parseVulnerabilitiesJson(JSON.stringify(arr), runId);
|
||||
}
|
||||
|
||||
export async function fetchReportMarkdown(runName?: string | null): Promise<string | null> {
|
||||
const obj = (await getJson("/api/report" + runQuery(runName))) as { markdown?: string };
|
||||
return obj?.markdown ?? null;
|
||||
}
|
||||
|
||||
export async function fetchTranscript(runName?: string | null): Promise<Transcript> {
|
||||
const obj = (await getJson("/api/transcript" + runQuery(runName))) as Partial<Transcript>;
|
||||
return {
|
||||
agents: Array.isArray(obj?.agents) ? obj.agents : [],
|
||||
events: Array.isArray(obj?.events) ? obj.events : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** One-shot fetch of every endpoint (used on mount and on final settle). */
|
||||
export async function fetchAll(runName?: string | null): Promise<LoadedRun> {
|
||||
const { summary, raw, finished } = await fetchRunSummary(runName);
|
||||
const [vulnerabilities, reportMarkdown, transcript] = await Promise.all([
|
||||
fetchVulnerabilities(summary.runId, runName).catch(() => [] as Vulnerability[]),
|
||||
fetchReportMarkdown(runName).catch(() => null),
|
||||
fetchTranscript(runName).catch(() => ({ agents: [], events: [] }) as Transcript),
|
||||
]);
|
||||
return { summary, raw, finished, vulnerabilities, reportMarkdown, transcript };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run history + email auth + report send
|
||||
//
|
||||
// These endpoints back the "Your runs" sidebar section. Auth and report-send
|
||||
// responses carry a meaningful JSON body on non-2xx statuses (an ``error``
|
||||
// code), so they read the body regardless of status rather than throwing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RunSeverityCounts {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
export interface RunListEntry {
|
||||
name: string;
|
||||
target: string | null;
|
||||
scan_mode: string | null;
|
||||
status: string | null;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
finished: boolean;
|
||||
severity_counts: RunSeverityCounts;
|
||||
}
|
||||
|
||||
export interface RunsPayload {
|
||||
locked: boolean;
|
||||
count: number;
|
||||
runs: RunListEntry[];
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
verified: boolean;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export type OtpStartResult = { ok: true } | { ok: false; error: string };
|
||||
export type OtpVerifyResult =
|
||||
| { verified: true; email: string }
|
||||
| { verified: false; error: string };
|
||||
export type SendReportResult =
|
||||
| { ok: true; password: string; filename: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
async function postJson(
|
||||
path: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<{ ok: boolean; status: number; data: Record<string, unknown> }> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = await res.json();
|
||||
if (parsed && typeof parsed === "object") data = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
/* empty or non-JSON body */
|
||||
}
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
export async function fetchRuns(): Promise<RunsPayload> {
|
||||
const obj = (await getJson("/api/runs")) as Partial<RunsPayload>;
|
||||
return {
|
||||
locked: obj?.locked ?? true,
|
||||
count: typeof obj?.count === "number" ? obj.count : 0,
|
||||
runs: Array.isArray(obj?.runs) ? (obj.runs as RunListEntry[]) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export interface Capabilities {
|
||||
can_steer: boolean;
|
||||
}
|
||||
|
||||
export type SteerResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/** GET /api/capabilities. can_steer is true only inside a live in-TUI scan. */
|
||||
export async function fetchCapabilities(): Promise<Capabilities> {
|
||||
const obj = (await getJson("/api/capabilities")) as Partial<Capabilities>;
|
||||
return { can_steer: obj?.can_steer === true };
|
||||
}
|
||||
|
||||
/** POST /api/agents/steer. Sends a steering instruction to a running agent. */
|
||||
export async function steerAgent(agentId: string, message: string): Promise<SteerResult> {
|
||||
const { ok, data } = await postJson("/api/agents/steer", {
|
||||
agent_id: agentId,
|
||||
message,
|
||||
});
|
||||
if (ok && data.ok === true) return { ok: true };
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
|
||||
export async function fetchAuthStatus(): Promise<AuthStatus> {
|
||||
const obj = (await getJson("/api/auth/status")) as Partial<AuthStatus>;
|
||||
return { verified: obj?.verified === true, email: obj?.email ?? null };
|
||||
}
|
||||
|
||||
export async function otpStart(email: string): Promise<OtpStartResult> {
|
||||
const { ok, data } = await postJson("/api/auth/otp/start", { email });
|
||||
if (ok && data.ok === true) return { ok: true };
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
|
||||
export async function otpVerify(email: string, code: string): Promise<OtpVerifyResult> {
|
||||
const { ok, data } = await postJson("/api/auth/otp/verify", { email, code });
|
||||
if (ok && data.verified === true) {
|
||||
return { verified: true, email: String(data.email ?? email) };
|
||||
}
|
||||
return { verified: false, error: String(data.error ?? "invalid_code") };
|
||||
}
|
||||
|
||||
export async function forgetAuth(): Promise<void> {
|
||||
await postJson("/api/auth/forget", {});
|
||||
}
|
||||
|
||||
export async function sendReport(runName?: string | null): Promise<SendReportResult> {
|
||||
const { ok, data } = await postJson("/api/report/send", runName ? { run: runName } : {});
|
||||
if (ok && data.ok === true) {
|
||||
return {
|
||||
ok: true,
|
||||
password: String(data.password ?? ""),
|
||||
filename: String(data.filename ?? "strix-report.pdf"),
|
||||
};
|
||||
}
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--font-geist-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
--font-geist-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
/* Tab content transition (lifted from strix-app globals.css) */
|
||||
@keyframes tab-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-tab-in {
|
||||
animation: tab-in 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Markdown prose styling (lifted from strix-app globals.css) */
|
||||
.prose-markdown {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #999;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.prose-markdown p {
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose-markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose-markdown strong {
|
||||
color: #ccc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose-markdown em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prose-markdown code {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #111;
|
||||
border-radius: 4px;
|
||||
padding: 0.15em 0.4em;
|
||||
font-size: 0.9em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: #ccc;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.prose-markdown pre {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.prose-markdown pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.prose-markdown ul,
|
||||
.prose-markdown ol {
|
||||
padding-left: 1.5em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.prose-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.prose-markdown li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.prose-markdown li > ul,
|
||||
.prose-markdown li > ol {
|
||||
padding-left: 1.5em;
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.prose-markdown ol + ul {
|
||||
padding-left: 3em;
|
||||
margin-top: -0.5em;
|
||||
}
|
||||
|
||||
.prose-markdown h1,
|
||||
.prose-markdown h2,
|
||||
.prose-markdown h3,
|
||||
.prose-markdown h4,
|
||||
.prose-markdown h5,
|
||||
.prose-markdown h6 {
|
||||
color: #ddd;
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.prose-markdown a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.prose-markdown blockquote {
|
||||
border-left: 3px solid #333;
|
||||
padding-left: 1em;
|
||||
color: #777;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.prose-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid #222;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.prose-markdown > table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.prose-markdown > table th,
|
||||
.prose-markdown > table td {
|
||||
border: 1px solid #333;
|
||||
padding: 0.4em 0.75em;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.prose-markdown > table th {
|
||||
background: #1a1a1a;
|
||||
color: #ccc;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// All upsell / sign-up CTAs route anonymous local-viewer users to the public
|
||||
// cloud sign-up. Open in a new tab so the local results stay put.
|
||||
export const SIGNUP_URL = "https://app.strix.ai/api/auth/signup";
|
||||
export const DEMO_URL = "https://strix.ai/demo";
|
||||
export const PRICING_URL = "https://strix.ai/pricing";
|
||||
|
||||
// Attribution params appended to every outbound CTA link so the destination
|
||||
// analytics can see the local viewer drove the click, with utm_content carrying
|
||||
// the CTA slug so we know which one.
|
||||
const CTA_PARAMS =
|
||||
"ref=oss_viewer&utm_source=oss_viewer&utm_medium=local_viewer&utm_campaign=oss_viewer";
|
||||
|
||||
export function ctaUrl(base: string, slug: string): string {
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
return `${base}${sep}${CTA_PARAMS}&utm_content=${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
// Best-effort, anonymous beacon. The local server forwards this to PostHog only
|
||||
// if the user has telemetry enabled; it never blocks navigation. Undefined
|
||||
// props are dropped so we only send what is set. NEVER pass PII here (no email,
|
||||
// code, or report content) - the props are limited to anonymous metadata.
|
||||
export function track(event: string, props: Record<string, string | undefined> = {}): void {
|
||||
try {
|
||||
const body: Record<string, string> = { event };
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (value !== undefined) body[key] = value;
|
||||
}
|
||||
const payload = JSON.stringify(body);
|
||||
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
|
||||
navigator.sendBeacon("/api/event", payload);
|
||||
} else {
|
||||
void fetch("/api/event", { method: "POST", body: payload, keepalive: true });
|
||||
}
|
||||
} catch {
|
||||
/* analytics is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous conversion tracking for a sign-up/upsell click. `surface` records
|
||||
// where the click happened so one CTA slug can be reused across placements.
|
||||
export function trackCta(cta: string, surface?: string): void {
|
||||
track("cta_clicked", { cta, surface });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Slim, dependency-free extract of strix-app's display-number helper. The full
|
||||
// version queries Supabase to compute org-wide finding numbers; the local viewer
|
||||
// only ever needs the pure formatter, so the supabase-backed functions are
|
||||
// intentionally omitted (a local run has no org context).
|
||||
export function formatStrixId(num: number): string {
|
||||
return `STRIX-${num}`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import hljs from "highlight.js/lib/common";
|
||||
import http from "highlight.js/lib/languages/http";
|
||||
import nginx from "highlight.js/lib/languages/nginx";
|
||||
import apache from "highlight.js/lib/languages/apache";
|
||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||
import properties from "highlight.js/lib/languages/properties";
|
||||
|
||||
hljs.registerLanguage("http", http);
|
||||
hljs.registerLanguage("nginx", nginx);
|
||||
hljs.registerLanguage("apache", apache);
|
||||
hljs.registerLanguage("dockerfile", dockerfile);
|
||||
hljs.registerLanguage("properties", properties);
|
||||
|
||||
export default hljs;
|
||||
@@ -0,0 +1,325 @@
|
||||
import type {
|
||||
Vulnerability,
|
||||
VulnerabilitySeverity,
|
||||
VulnerabilityStatus,
|
||||
} from "@/types/issues";
|
||||
|
||||
/**
|
||||
* Pure, dependency-free parsers that turn a Strix CLI local run
|
||||
* (`strix_runs/<run>/{run.json,vulnerabilities.json}`) into the app's own
|
||||
* types, so the /results view can reuse the dashboard's finding components.
|
||||
*
|
||||
* These run entirely client-side against files the user picked from disk —
|
||||
* nothing here uploads or persists anything.
|
||||
*/
|
||||
|
||||
export class RunParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "RunParseError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedRunSummary {
|
||||
runId: string | null;
|
||||
runName: string | null;
|
||||
targets: string[];
|
||||
scanMode: string | null;
|
||||
status: string | null;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
durationSeconds: number | null;
|
||||
executiveSummary: string | null;
|
||||
technicalAnalysis: string | null;
|
||||
methodology: string | null;
|
||||
recommendations: string | null;
|
||||
}
|
||||
|
||||
const KNOWN_SEVERITIES: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
|
||||
|
||||
function coerceSeverity(raw: unknown): VulnerabilitySeverity {
|
||||
const s = String(raw ?? "").toLowerCase().trim();
|
||||
if ((KNOWN_SEVERITIES as string[]).includes(s)) return s as VulnerabilitySeverity;
|
||||
// The app's severity type has no "info"/"informational" bucket; fold those
|
||||
// (and anything unrecognized) into "low" so the shared UI renders cleanly.
|
||||
return "low";
|
||||
}
|
||||
|
||||
function toIsoTimestamp(raw: unknown): string {
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
// CLI writes e.g. "2025-01-02 03:04:05 UTC".
|
||||
const normalized = raw.trim().replace(" UTC", "Z").replace(" ", "T");
|
||||
const d = new Date(normalized);
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||||
const direct = new Date(raw);
|
||||
if (!Number.isNaN(direct.getTime())) return direct.toISOString();
|
||||
}
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function asStringOrNull(v: unknown): string | null {
|
||||
return typeof v === "string" && v.length > 0 ? v : null;
|
||||
}
|
||||
|
||||
function asNumberOrNull(v: unknown): number | null {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function parseJson(text: string, label: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new RunParseError(
|
||||
`${label} isn't valid JSON. Make sure you selected a Strix run directory.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRunJson(text: string): ParsedRunSummary {
|
||||
const data = parseJson(text, "run.json");
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
throw new RunParseError("run.json is not an object.");
|
||||
}
|
||||
const record = data as Record<string, unknown>;
|
||||
|
||||
const targets: string[] = [];
|
||||
const targetsInfo = record.targets_info;
|
||||
if (Array.isArray(targetsInfo)) {
|
||||
for (const t of targetsInfo) {
|
||||
if (t && typeof t === "object") {
|
||||
const original = (t as Record<string, unknown>).original;
|
||||
if (typeof original === "string" && original) targets.push(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = asStringOrNull(record.start_time);
|
||||
const endTime = asStringOrNull(record.end_time);
|
||||
let durationSeconds: number | null = null;
|
||||
if (startTime && endTime) {
|
||||
const s = new Date(startTime).getTime();
|
||||
const e = new Date(endTime).getTime();
|
||||
if (!Number.isNaN(s) && !Number.isNaN(e) && e >= s) {
|
||||
durationSeconds = Math.round((e - s) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
let executiveSummary: string | null = null;
|
||||
let technicalAnalysis: string | null = null;
|
||||
let methodology: string | null = null;
|
||||
let recommendations: string | null = null;
|
||||
const scanResults = record.scan_results;
|
||||
if (scanResults && typeof scanResults === "object") {
|
||||
const sr = scanResults as Record<string, unknown>;
|
||||
executiveSummary = asStringOrNull(sr.executive_summary);
|
||||
technicalAnalysis = asStringOrNull(sr.technical_analysis);
|
||||
methodology = asStringOrNull(sr.methodology);
|
||||
recommendations = asStringOrNull(sr.recommendations);
|
||||
}
|
||||
|
||||
return {
|
||||
runId: asStringOrNull(record.run_id),
|
||||
runName: asStringOrNull(record.run_name),
|
||||
targets,
|
||||
scanMode: asStringOrNull(record.scan_mode),
|
||||
status: asStringOrNull(record.status),
|
||||
startTime,
|
||||
endTime,
|
||||
durationSeconds,
|
||||
executiveSummary,
|
||||
technicalAnalysis,
|
||||
methodology,
|
||||
recommendations,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fields on the app's Vulnerability type that a local run never provides. */
|
||||
function emptyVulnerabilityDefaults(): Omit<
|
||||
Vulnerability,
|
||||
| "id"
|
||||
| "title"
|
||||
| "description"
|
||||
| "severity"
|
||||
| "created_at"
|
||||
| "scan_id"
|
||||
| "status"
|
||||
> {
|
||||
return {
|
||||
pr_review_id: null,
|
||||
cve: null,
|
||||
cvss: null,
|
||||
potential_risk_saving: null,
|
||||
risk_saving_description: null,
|
||||
impact: null,
|
||||
endpoint: null,
|
||||
method: null,
|
||||
target: null,
|
||||
technical_analysis: null,
|
||||
poc_description: null,
|
||||
poc_script_code: null,
|
||||
code_diff: null,
|
||||
code_file: null,
|
||||
code_before: null,
|
||||
code_after: null,
|
||||
cwe: null,
|
||||
code_locations: null,
|
||||
remediation_steps: null,
|
||||
fix_pr_body: null,
|
||||
evidence: null,
|
||||
assumptions: null,
|
||||
fix_effort: null,
|
||||
cvss_breakdown: null,
|
||||
status_changed_at: null,
|
||||
status_changed_by: null,
|
||||
status_note: null,
|
||||
snoozed_until: null,
|
||||
reopened_at: null,
|
||||
reopened_by: null,
|
||||
original_severity: null,
|
||||
severity_changed_at: null,
|
||||
severity_changed_by: null,
|
||||
severity_override_reason: null,
|
||||
retest_of_vulnerability_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOneVulnerability(
|
||||
raw: Record<string, unknown>,
|
||||
index: number,
|
||||
runId: string | null
|
||||
): Vulnerability {
|
||||
const cweRaw = raw.cwe;
|
||||
const cwe =
|
||||
typeof cweRaw === "string" && cweRaw.trim()
|
||||
? [cweRaw.trim()]
|
||||
: Array.isArray(cweRaw)
|
||||
? (cweRaw.filter((c) => typeof c === "string" && c) as string[])
|
||||
: null;
|
||||
|
||||
const status: VulnerabilityStatus = "open";
|
||||
|
||||
return {
|
||||
...emptyVulnerabilityDefaults(),
|
||||
id: asStringOrNull(raw.id) ?? `vuln-${index + 1}`,
|
||||
scan_id: runId,
|
||||
title: asStringOrNull(raw.title) ?? "Untitled finding",
|
||||
description: asStringOrNull(raw.description) ?? "",
|
||||
severity: coerceSeverity(raw.severity),
|
||||
status,
|
||||
created_at: toIsoTimestamp(raw.timestamp),
|
||||
cve: asStringOrNull(raw.cve),
|
||||
cvss: asNumberOrNull(raw.cvss),
|
||||
impact: asStringOrNull(raw.impact),
|
||||
endpoint: asStringOrNull(raw.endpoint),
|
||||
method: asStringOrNull(raw.method),
|
||||
target: asStringOrNull(raw.target),
|
||||
technical_analysis: asStringOrNull(raw.technical_analysis),
|
||||
poc_description: asStringOrNull(raw.poc_description),
|
||||
poc_script_code: asStringOrNull(raw.poc_script_code),
|
||||
cwe,
|
||||
code_locations: Array.isArray(raw.code_locations)
|
||||
? (raw.code_locations as Vulnerability["code_locations"])
|
||||
: null,
|
||||
remediation_steps: asStringOrNull(raw.remediation_steps),
|
||||
fix_pr_body: asStringOrNull(raw.fix_pr_body),
|
||||
evidence: asStringOrNull(raw.evidence),
|
||||
assumptions: asStringOrNull(raw.assumptions),
|
||||
fix_effort: (asStringOrNull(raw.fix_effort) as Vulnerability["fix_effort"]) ?? null,
|
||||
cvss_breakdown: (raw.cvss_breakdown as Vulnerability["cvss_breakdown"]) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseVulnerabilitiesJson(
|
||||
text: string,
|
||||
runId: string | null = null
|
||||
): Vulnerability[] {
|
||||
const data = parseJson(text, "vulnerabilities.json");
|
||||
if (!Array.isArray(data)) {
|
||||
throw new RunParseError("vulnerabilities.json is not a JSON array.");
|
||||
}
|
||||
return data.map((item, i) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
throw new RunParseError(`vulnerabilities.json entry #${i + 1} is not an object.`);
|
||||
}
|
||||
return parseOneVulnerability(item as Record<string, unknown>, i, runId);
|
||||
});
|
||||
}
|
||||
|
||||
export interface ParsedAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
parentId: string | null;
|
||||
task: string | null;
|
||||
skills: string[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the agent execution trace from `.state/agents.json` into a pre-ordered
|
||||
* tree (children follow their parent; `depth` drives indentation). Rendered
|
||||
* 100% client-side and never uploaded — traces contain target details, so they
|
||||
* must stay on the user's machine. The heavier `.state/agents.db` (SQLite) is
|
||||
* intentionally ignored; `agents.json` has everything the panel needs.
|
||||
*/
|
||||
export function parseAgentsJson(text: string): ParsedAgent[] {
|
||||
const data = parseJson(text, "agents.json");
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
|
||||
const record = data as Record<string, unknown>;
|
||||
const statuses = (record.statuses ?? {}) as Record<string, unknown>;
|
||||
const parentOf = (record.parent_of ?? {}) as Record<string, unknown>;
|
||||
const names = (record.names ?? {}) as Record<string, unknown>;
|
||||
const metadata = (record.metadata ?? {}) as Record<string, unknown>;
|
||||
|
||||
const agents = new Map<string, ParsedAgent>();
|
||||
for (const id of Object.keys(statuses)) {
|
||||
const meta = (metadata[id] ?? {}) as Record<string, unknown>;
|
||||
const skillsRaw = meta.skills;
|
||||
agents.set(id, {
|
||||
id,
|
||||
name: asStringOrNull(names[id]) ?? id,
|
||||
status: asStringOrNull(statuses[id]) ?? "unknown",
|
||||
parentId: asStringOrNull(parentOf[id]),
|
||||
task: asStringOrNull(meta.task),
|
||||
skills: Array.isArray(skillsRaw)
|
||||
? skillsRaw.filter((s): s is string => typeof s === "string")
|
||||
: [],
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
if (agents.size === 0) return [];
|
||||
|
||||
const childrenOf = new Map<string | null, string[]>();
|
||||
for (const a of agents.values()) {
|
||||
const key = a.parentId && agents.has(a.parentId) ? a.parentId : null;
|
||||
(childrenOf.get(key) ?? childrenOf.set(key, []).get(key)!).push(a.id);
|
||||
}
|
||||
|
||||
const ordered: ParsedAgent[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (id: string, depth: number): void => {
|
||||
const a = agents.get(id);
|
||||
if (!a || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
a.depth = depth;
|
||||
ordered.push(a);
|
||||
for (const childId of childrenOf.get(id) ?? []) visit(childId, depth + 1);
|
||||
};
|
||||
for (const rootId of childrenOf.get(null) ?? []) visit(rootId, 0);
|
||||
// Defensive: include any agents not reachable from a root.
|
||||
for (const a of agents.values()) if (!seen.has(a.id)) ordered.push(a);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function severityCounts(
|
||||
vulns: Vulnerability[]
|
||||
): Record<VulnerabilitySeverity, number> {
|
||||
const counts: Record<VulnerabilitySeverity, number> = {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
for (const v of vulns) counts[v.severity] += 1;
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type React from "react";
|
||||
import {
|
||||
GitPullRequest,
|
||||
Layers,
|
||||
Globe,
|
||||
Puzzle,
|
||||
Users,
|
||||
Search,
|
||||
LayoutDashboard,
|
||||
AlertTriangle,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Platform (Pro / Enterprise) feature catalog. Powers both the unified sidebar
|
||||
* nav rows and the in-app FeatureDetail upsell view. Everything is "Pro" except
|
||||
* Networks, which is "Enterprise". No lock icons anywhere.
|
||||
*/
|
||||
|
||||
export type FeatureTier = "Pro" | "Enterprise";
|
||||
|
||||
export interface ProFeature {
|
||||
slug: string;
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
tier: FeatureTier;
|
||||
/** Short one-liner for the sidebar nav row (two-line layout). */
|
||||
navDesc: string;
|
||||
/** Headline shown on the FeatureDetail upsell page. */
|
||||
headline: string;
|
||||
/** Longer sentence shown on the FeatureDetail upsell page. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Flat catalog of every platform feature, keyed by slug for routing. The
|
||||
// sidebar groups these into capability themes (see PLATFORM_THEMES); nothing
|
||||
// here implies a tier ordering.
|
||||
export const PLATFORM_FEATURES: ProFeature[] = [
|
||||
{
|
||||
slug: "pr_reviews",
|
||||
title: "PR Reviews",
|
||||
icon: GitPullRequest,
|
||||
tier: "Pro",
|
||||
navDesc: "Pentest every pull request",
|
||||
headline: "Pentest every pull request",
|
||||
description:
|
||||
"Strix reviews every pull request your team opens and catches exploitable changes before they merge.",
|
||||
},
|
||||
{
|
||||
slug: "repositories",
|
||||
title: "Repositories",
|
||||
icon: Layers,
|
||||
tier: "Pro",
|
||||
navDesc: "Connect your team's repos",
|
||||
headline: "Connect your team's repositories",
|
||||
description:
|
||||
"Link your org's repositories so Strix can scan them continuously and track findings over time.",
|
||||
},
|
||||
{
|
||||
slug: "domains",
|
||||
title: "Domains",
|
||||
icon: Globe,
|
||||
tier: "Pro",
|
||||
navDesc: "Cover the domains you own",
|
||||
headline: "Cover every domain you own",
|
||||
description:
|
||||
"Add the domains your team owns and let Strix watch them for newly exposed paths and drift.",
|
||||
},
|
||||
{
|
||||
slug: "integrations",
|
||||
title: "Integrations",
|
||||
icon: Puzzle,
|
||||
tier: "Pro",
|
||||
navDesc: "Sync to Jira, Linear, Slack",
|
||||
headline: "Sync findings to your tools",
|
||||
description:
|
||||
"Two-way sync findings to Jira, Linear, and Slack so fixes happen where your team already works.",
|
||||
},
|
||||
{
|
||||
slug: "members",
|
||||
title: "Members",
|
||||
icon: Users,
|
||||
tier: "Pro",
|
||||
navDesc: "Invite your team, set roles",
|
||||
headline: "Bring your whole team",
|
||||
description:
|
||||
"Invite your team, set roles, and share findings and run history across your org.",
|
||||
},
|
||||
{
|
||||
slug: "pentests",
|
||||
title: "Pentests",
|
||||
icon: Search,
|
||||
tier: "Pro",
|
||||
navDesc: "Deeper scans on managed infra",
|
||||
headline: "Launch deeper pentests",
|
||||
description:
|
||||
"Run deeper, longer pentests on managed infrastructure whenever you need them.",
|
||||
},
|
||||
{
|
||||
slug: "dashboard",
|
||||
title: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
tier: "Pro",
|
||||
navDesc: "Everything in one place",
|
||||
headline: "See everything in one place",
|
||||
description:
|
||||
"Track every project, run, and finding across your org from a single dashboard.",
|
||||
},
|
||||
{
|
||||
slug: "platform_issues",
|
||||
title: "Issues",
|
||||
icon: AlertTriangle,
|
||||
tier: "Pro",
|
||||
navDesc: "Triage across your org",
|
||||
headline: "Triage findings across your org",
|
||||
description:
|
||||
"Manage and triage findings across every project and repository in one queue.",
|
||||
},
|
||||
{
|
||||
slug: "chat",
|
||||
title: "Chat",
|
||||
icon: MessageSquare,
|
||||
tier: "Pro",
|
||||
navDesc: "Ask about any finding",
|
||||
headline: "Ask Strix anything",
|
||||
description:
|
||||
"Ask the agents about any finding, run, or part of your app in natural language.",
|
||||
},
|
||||
{
|
||||
slug: "networks",
|
||||
title: "Networks",
|
||||
icon: Network,
|
||||
tier: "Enterprise",
|
||||
navDesc: "Reach internal, VPN-only targets",
|
||||
headline: "Scan internal networks",
|
||||
description:
|
||||
"Connect private networks to scan internal applications, VPN-only services, and RFC1918 targets.",
|
||||
},
|
||||
{
|
||||
slug: "knowledge",
|
||||
title: "Knowledge",
|
||||
icon: Database,
|
||||
tier: "Pro",
|
||||
navDesc: "Give agents context",
|
||||
headline: "Give agents context",
|
||||
description:
|
||||
"Teach Strix about your systems and business logic so every run gets smarter.",
|
||||
},
|
||||
];
|
||||
|
||||
export const FEATURES: Record<string, ProFeature> = Object.fromEntries(
|
||||
PLATFORM_FEATURES.map((f) => [f.slug, f])
|
||||
);
|
||||
|
||||
/**
|
||||
* Order the platform rows appear in the sidebar's single, ungrouped nav list
|
||||
* (after the run/local rows). No section headers; tier is shown only by each
|
||||
* row's inline tag.
|
||||
*/
|
||||
export const PLATFORM_ORDER: string[] = [
|
||||
"pr_reviews",
|
||||
"repositories",
|
||||
"domains",
|
||||
"integrations",
|
||||
"members",
|
||||
"pentests",
|
||||
"networks",
|
||||
"chat",
|
||||
"dashboard",
|
||||
"platform_issues",
|
||||
"knowledge",
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface ParsedTarget {
|
||||
display: string;
|
||||
href: string | null;
|
||||
provider: "github" | "gitlab" | "bitbucket" | null;
|
||||
}
|
||||
|
||||
export function parseTarget(target: string): ParsedTarget {
|
||||
// GitHub URL
|
||||
const ghMatch = target.match(
|
||||
/(?:https?:\/\/)?(?:www\.)?github\.com\/([^\s/]+\/[^\s/]+)/
|
||||
);
|
||||
if (ghMatch) {
|
||||
const slug = ghMatch[1].replace(/\.git$/, "");
|
||||
return { display: slug, href: `https://github.com/${slug}`, provider: "github" };
|
||||
}
|
||||
|
||||
// GitLab URL
|
||||
const glMatch = target.match(
|
||||
/(?:https?:\/\/)?(?:www\.)?gitlab\.com\/([^\s/]+\/[^\s/]+)/
|
||||
);
|
||||
if (glMatch) {
|
||||
const slug = glMatch[1].replace(/\.git$/, "");
|
||||
return { display: slug, href: `https://gitlab.com/${slug}`, provider: "gitlab" };
|
||||
}
|
||||
|
||||
// Bitbucket URL
|
||||
const bbMatch = target.match(
|
||||
/(?:https?:\/\/)?(?:www\.)?bitbucket\.org\/([^\s/]+\/[^\s/]+)/
|
||||
);
|
||||
if (bbMatch) {
|
||||
const slug = bbMatch[1].replace(/\.git$/, "");
|
||||
return { display: slug, href: `https://bitbucket.org/${slug}`, provider: "bitbucket" };
|
||||
}
|
||||
|
||||
// URL with protocol
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
return {
|
||||
display: target.replace(/^https?:\/\/(www\.)?/, ""),
|
||||
href: target,
|
||||
provider: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Bare domain (e.g. "example.com" or "example.com/path")
|
||||
if (/^[a-zA-Z0-9][\w.-]*\.[a-zA-Z]{2,}/.test(target)) {
|
||||
return { display: target, href: `https://${target}`, provider: null };
|
||||
}
|
||||
|
||||
// Not a URL
|
||||
return { display: target, href: null, provider: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* A clean, human-readable run title derived from the scan target (e.g.
|
||||
* "arch.co") rather than the raw run dir name ("arch-co_ca3f"). Falls back to
|
||||
* the raw name, then a generic label.
|
||||
*/
|
||||
export function runTitle(target: string | null, fallback: string): string {
|
||||
if (target) return parseTarget(target).display.replace(/\/$/, "");
|
||||
return fallback || "Untitled scan";
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function isValidUrl(url: string): boolean {
|
||||
if (!url || !url.trim()) return false;
|
||||
|
||||
try {
|
||||
// Add protocol if missing
|
||||
let urlWithProtocol = url.trim();
|
||||
if (!urlWithProtocol.startsWith("http://") && !urlWithProtocol.startsWith("https://")) {
|
||||
urlWithProtocol = `https://${urlWithProtocol}`;
|
||||
}
|
||||
const parsed = new URL(urlWithProtocol);
|
||||
// Check if it has a valid hostname with at least one dot (domain)
|
||||
return Boolean(parsed.hostname) && parsed.hostname.includes(".");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidDomain(domain: string | null): boolean {
|
||||
if (!domain) return false;
|
||||
|
||||
// Basic domain validation regex
|
||||
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
|
||||
// Check basic format
|
||||
if (!domainRegex.test(domain)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check length constraints
|
||||
if (domain.length > 253) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have at least one dot (TLD required)
|
||||
if (!domain.includes(".")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each label length (max 63 chars per label)
|
||||
const labels = domain.split(".");
|
||||
for (const label of labels) {
|
||||
if (label.length === 0 || label.length > 63) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// TLD should be at least 2 characters
|
||||
const tld = labels[labels.length - 1];
|
||||
if (tld.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatTimeAgo(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return "just now";
|
||||
}
|
||||
if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60);
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
if (diffInSeconds < 604800) {
|
||||
const days = Math.floor(diffInSeconds / 86400);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
return formatDate(dateString);
|
||||
}
|
||||
|
||||
export function formatTimeUntil(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((date.getTime() - now.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 0) return "now";
|
||||
if (diffInSeconds < 60) return "in <1m";
|
||||
if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60);
|
||||
return `in ${minutes}m`;
|
||||
}
|
||||
if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `in ${hours}h`;
|
||||
}
|
||||
if (diffInSeconds < 604800) {
|
||||
const days = Math.round(diffInSeconds / 86400);
|
||||
return `in ${days || 1}d`;
|
||||
}
|
||||
return formatDate(dateString);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
const LANGUAGE_MAP: Record<string, string> = {
|
||||
js: "javascript", ts: "typescript", tsx: "typescript", jsx: "javascript",
|
||||
py: "python", rb: "ruby", go: "go", rs: "rust", java: "java", php: "php",
|
||||
cs: "csharp", cpp: "cpp", c: "c", sh: "bash", bash: "bash", sql: "sql",
|
||||
html: "html", css: "css", json: "json", yaml: "yaml", yml: "yaml", xml: "xml",
|
||||
};
|
||||
|
||||
export function getLanguageFromFile(filename: string | null | undefined): string | null {
|
||||
if (!filename) return null;
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
return ext ? LANGUAGE_MAP[ext] || null : null;
|
||||
}
|
||||
|
||||
export function getSeverityDot(severity: string): string {
|
||||
switch (severity) {
|
||||
case "critical": return "bg-red-500";
|
||||
case "high": return "bg-orange-500";
|
||||
case "medium": return "bg-yellow-500";
|
||||
default: return "bg-blue-500";
|
||||
}
|
||||
}
|
||||
|
||||
import type { Vulnerability } from "@/types/issues";
|
||||
|
||||
export function buildMarkdown(v: Vulnerability): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`# ${v.title}`);
|
||||
parts.push("");
|
||||
const cwePart = v.cwe && v.cwe.length > 0 ? ` · **CWE:** ${v.cwe.join(", ")}` : "";
|
||||
parts.push(`**Severity:** ${v.severity.toUpperCase()}${v.cvss ? ` · **CVSS:** ${v.cvss}` : ""}${v.cve ? ` · **CVE:** ${v.cve}` : ""}${cwePart}${v.fix_effort ? ` · **Fix Effort:** ${v.fix_effort}` : ""}`);
|
||||
parts.push(`**Status:** ${v.status}`);
|
||||
if (v.target) parts.push(`**Target:** ${v.target}`);
|
||||
if (v.endpoint) parts.push(`**Endpoint:** ${v.method ? `${v.method} ` : ""}${v.endpoint}`);
|
||||
parts.push("");
|
||||
if (v.description) {
|
||||
parts.push("## Description");
|
||||
parts.push("");
|
||||
parts.push(v.description);
|
||||
parts.push("");
|
||||
}
|
||||
if (v.impact) {
|
||||
parts.push("## Impact");
|
||||
parts.push("");
|
||||
parts.push(v.impact);
|
||||
parts.push("");
|
||||
}
|
||||
if (v.evidence) {
|
||||
parts.push("## Evidence");
|
||||
parts.push("");
|
||||
parts.push(v.evidence);
|
||||
parts.push("");
|
||||
}
|
||||
if (v.assumptions) {
|
||||
parts.push("## Assumptions");
|
||||
parts.push("");
|
||||
parts.push(v.assumptions);
|
||||
parts.push("");
|
||||
}
|
||||
if (v.technical_analysis) {
|
||||
parts.push("## Technical Details");
|
||||
parts.push("");
|
||||
parts.push(v.technical_analysis);
|
||||
parts.push("");
|
||||
}
|
||||
if (v.remediation_steps) {
|
||||
parts.push("## How to Fix");
|
||||
parts.push("");
|
||||
parts.push(v.remediation_steps);
|
||||
parts.push("");
|
||||
}
|
||||
if (v.poc_description) {
|
||||
parts.push("## Proof of Concept");
|
||||
parts.push("");
|
||||
parts.push(v.poc_description);
|
||||
if (v.poc_script_code) {
|
||||
parts.push("");
|
||||
parts.push("```");
|
||||
parts.push(v.poc_script_code);
|
||||
parts.push("```");
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
if (v.code_locations?.length) {
|
||||
parts.push("## Code Locations");
|
||||
parts.push("");
|
||||
if (v.location_meta) {
|
||||
parts.push(`**Repository:** ${v.location_meta.repo_url} (${v.location_meta.branch})`);
|
||||
parts.push("");
|
||||
}
|
||||
for (const loc of v.code_locations) {
|
||||
if (!loc.file) continue;
|
||||
const lineRef = loc.end_line && loc.end_line !== loc.start_line
|
||||
? `${loc.file}:${loc.start_line}-${loc.end_line}`
|
||||
: `${loc.file}:${loc.start_line}`;
|
||||
parts.push(`### \`${lineRef}\``);
|
||||
if (loc.label) parts.push(loc.label);
|
||||
if (loc.snippet) {
|
||||
parts.push("");
|
||||
parts.push("```");
|
||||
parts.push(loc.snippet);
|
||||
parts.push("```");
|
||||
}
|
||||
if (loc.fix_before && loc.fix_after) {
|
||||
parts.push("");
|
||||
parts.push("```diff");
|
||||
for (const l of loc.fix_before.split("\n")) parts.push(`- ${l}`);
|
||||
for (const l of loc.fix_after.split("\n")) parts.push(`+ ${l}`);
|
||||
parts.push("```");
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
} else if (v.code_file && (v.code_before || v.code_after)) {
|
||||
parts.push("## Code");
|
||||
parts.push("");
|
||||
parts.push(`**File:** \`${v.code_file}\``);
|
||||
if (v.code_before && v.code_after) {
|
||||
parts.push("");
|
||||
parts.push("```diff");
|
||||
for (const l of v.code_before.split("\n")) parts.push(`- ${l}`);
|
||||
for (const l of v.code_after.split("\n")) parts.push(`+ ${l}`);
|
||||
parts.push("```");
|
||||
} else if (v.code_diff) {
|
||||
parts.push("");
|
||||
parts.push("```diff");
|
||||
parts.push(v.code_diff);
|
||||
parts.push("```");
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export function buildAIFixPrompt(v: Vulnerability): string {
|
||||
const parts: string[] = [];
|
||||
parts.push("This is a security vulnerability found during a code review.");
|
||||
parts.push("");
|
||||
parts.push(`Vulnerability: ${v.title}`);
|
||||
parts.push(`Severity: ${v.severity.toUpperCase()}`);
|
||||
if (v.cwe && v.cwe.length > 0) {
|
||||
parts.push(`CWE: ${v.cwe.join(", ")}`);
|
||||
}
|
||||
parts.push("");
|
||||
parts.push(v.description);
|
||||
|
||||
if (v.evidence) {
|
||||
parts.push("");
|
||||
parts.push("Evidence:");
|
||||
parts.push(v.evidence);
|
||||
}
|
||||
|
||||
const allLocations = v.code_locations || [];
|
||||
for (const fixLoc of allLocations) {
|
||||
if (!fixLoc.file) continue;
|
||||
const startLine = fixLoc.start_line || 0;
|
||||
const endLine = fixLoc.end_line || startLine;
|
||||
parts.push("");
|
||||
parts.push(`Location: ${fixLoc.file}:${startLine}-${endLine}`);
|
||||
if (fixLoc.label) {
|
||||
parts.push(`Context: ${fixLoc.label}`);
|
||||
}
|
||||
if (fixLoc.fix_before && fixLoc.fix_after) {
|
||||
parts.push("```");
|
||||
parts.push(`// Before:\n${fixLoc.fix_before}`);
|
||||
parts.push(`// After:\n${fixLoc.fix_after}`);
|
||||
parts.push("```");
|
||||
} else if (fixLoc.snippet) {
|
||||
parts.push("```");
|
||||
parts.push(fixLoc.snippet);
|
||||
parts.push("```");
|
||||
}
|
||||
}
|
||||
|
||||
if (v.remediation_steps) {
|
||||
parts.push("");
|
||||
parts.push("How to fix:");
|
||||
parts.push(v.remediation_steps);
|
||||
}
|
||||
parts.push("");
|
||||
parts.push("Please fix this vulnerability. If you propose a fix, make it concise and minimal.");
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "absolute";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
// Local stub of the strix-app auth store. The local viewer has no accounts:
|
||||
// there is never a signed-in user and no feature is entitled, so every upsell
|
||||
// CTA routes to the external cloud sign-up link.
|
||||
interface AuthState {
|
||||
user: null;
|
||||
hasFeature: (feature: string) => boolean;
|
||||
}
|
||||
|
||||
const STATE: AuthState = {
|
||||
user: null,
|
||||
hasFeature: () => false,
|
||||
};
|
||||
|
||||
export function useAuthStore<T>(selector: (s: AuthState) => T): T {
|
||||
return selector(STATE);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Types matching the Python tracer's event shapes stored in Convex
|
||||
|
||||
export type EventType =
|
||||
| "run.started"
|
||||
| "run.configured"
|
||||
| "run.completed"
|
||||
| "agent.created"
|
||||
| "agent.status.updated"
|
||||
| "tool.execution.started"
|
||||
| "tool.execution.updated"
|
||||
| "chat.message"
|
||||
| "finding.created"
|
||||
| "finding.reviewed"
|
||||
| "traffic.batch";
|
||||
|
||||
export interface EventActor {
|
||||
agent_id?: string;
|
||||
agent_name?: string;
|
||||
tool_name?: string;
|
||||
execution_id?: number;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface ConvexEvent {
|
||||
_id: string;
|
||||
_creationTime: number;
|
||||
timestamp: string;
|
||||
event_type: EventType;
|
||||
run_id: string;
|
||||
trace_id?: string | null;
|
||||
span_id?: string | null;
|
||||
parent_span_id?: string | null;
|
||||
actor: EventActor | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
status: string | null;
|
||||
error: unknown | null;
|
||||
source?: string;
|
||||
run_metadata?: RunMetadata;
|
||||
}
|
||||
|
||||
export interface RunMetadata {
|
||||
run_id: string;
|
||||
run_name: string | null;
|
||||
start_time: string;
|
||||
end_time: string | null;
|
||||
targets: string[];
|
||||
status: string;
|
||||
user_instructions?: string;
|
||||
max_iterations?: number;
|
||||
}
|
||||
|
||||
export interface AgentNode {
|
||||
id: string;
|
||||
name: string;
|
||||
task: string;
|
||||
status: "running" | "completed" | "failed" | "error";
|
||||
parentId: string | null;
|
||||
children: string[];
|
||||
createdAt: string;
|
||||
toolCount: number;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
export interface ToolExecution {
|
||||
executionId: number;
|
||||
agentId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result: unknown;
|
||||
status: "running" | "completed" | "failed" | "error";
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
messageId: number;
|
||||
agentId: string | null;
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
id: string;
|
||||
title: string;
|
||||
severity: "critical" | "high" | "medium" | "low";
|
||||
description?: string;
|
||||
target?: string;
|
||||
endpoint?: string;
|
||||
method?: string;
|
||||
cvss?: number;
|
||||
cve?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ToolRendererProps {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result: unknown;
|
||||
status: "running" | "completed" | "failed" | "error";
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
export type VulnerabilitySeverity = "critical" | "high" | "medium" | "low";
|
||||
export type VulnerabilityStatus = "open" | "in_progress" | "snoozed" | "fixed" | "ignored";
|
||||
export type FixEffort = "trivial" | "low" | "medium" | "high";
|
||||
|
||||
export const ACTIVE_STATUSES: VulnerabilityStatus[] = ["open", "in_progress", "snoozed"];
|
||||
export const RESOLVED_STATUSES: VulnerabilityStatus[] = ["fixed", "ignored"];
|
||||
|
||||
// Statuses worth retesting in a "retest all" — everything except ignored
|
||||
// (fixed issues are still re-verified; ignored issues are intentionally skipped).
|
||||
export const RETESTABLE_STATUSES: VulnerabilityStatus[] = ["open", "in_progress", "snoozed", "fixed"];
|
||||
|
||||
export const ALL_STATUSES: VulnerabilityStatus[] = ["open", "in_progress", "snoozed", "fixed", "ignored"];
|
||||
|
||||
export interface StatusCounts {
|
||||
all: number;
|
||||
open: number;
|
||||
in_progress: number;
|
||||
snoozed: number;
|
||||
fixed: number;
|
||||
ignored: number;
|
||||
}
|
||||
|
||||
export interface StatusMeta {
|
||||
label: string;
|
||||
color: string;
|
||||
dotColor: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const STATUS_META: Record<VulnerabilityStatus, StatusMeta> = {
|
||||
open: {
|
||||
label: "Open",
|
||||
color: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
dotColor: "bg-red-500",
|
||||
description: "Newly discovered, awaiting triage",
|
||||
},
|
||||
in_progress: {
|
||||
label: "In Progress",
|
||||
color: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||
dotColor: "bg-blue-500",
|
||||
description: "Someone is working on this",
|
||||
},
|
||||
snoozed: {
|
||||
label: "Snoozed",
|
||||
color: "bg-purple-500/10 text-purple-400 border-purple-500/20",
|
||||
dotColor: "bg-purple-500",
|
||||
description: "Temporarily hidden until a follow-up date",
|
||||
},
|
||||
fixed: {
|
||||
label: "Fixed",
|
||||
color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||
dotColor: "bg-emerald-500",
|
||||
description: "This vulnerability has been fixed",
|
||||
},
|
||||
ignored: {
|
||||
label: "Ignored",
|
||||
color: "bg-gray-500/10 text-gray-400 border-gray-500/20",
|
||||
dotColor: "bg-gray-500",
|
||||
description: "Acknowledged but accepted",
|
||||
},
|
||||
};
|
||||
|
||||
export const FIX_EFFORT_META: Record<FixEffort, { label: string; color: string }> = {
|
||||
trivial: { label: "Trivial", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
|
||||
low: { label: "Low", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
|
||||
medium: { label: "Medium", color: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20" },
|
||||
high: { label: "High", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
|
||||
};
|
||||
|
||||
export interface CodeLocation {
|
||||
file: string;
|
||||
start_line: number;
|
||||
end_line?: number;
|
||||
snippet?: string;
|
||||
label?: string;
|
||||
fix_before?: string;
|
||||
fix_after?: string;
|
||||
}
|
||||
|
||||
export interface CVSSBreakdown {
|
||||
attack_vector: string | null;
|
||||
attack_complexity: string | null;
|
||||
privileges_required: string | null;
|
||||
user_interaction: string | null;
|
||||
scope: string | null;
|
||||
confidentiality: string | null;
|
||||
integrity: string | null;
|
||||
availability: string | null;
|
||||
}
|
||||
|
||||
export interface Vulnerability {
|
||||
id: string;
|
||||
scan_id: string | null;
|
||||
pr_review_id: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
cve: string | null;
|
||||
cvss: number | null;
|
||||
created_at: string;
|
||||
potential_risk_saving: number | null;
|
||||
risk_saving_description: string | null;
|
||||
status: VulnerabilityStatus;
|
||||
severity: VulnerabilitySeverity;
|
||||
impact: string | null;
|
||||
endpoint: string | null;
|
||||
method: string | null;
|
||||
target: string | null;
|
||||
technical_analysis: string | null;
|
||||
poc_description: string | null;
|
||||
poc_script_code: string | null;
|
||||
code_diff: string | null;
|
||||
code_file: string | null;
|
||||
code_before: string | null;
|
||||
code_after: string | null;
|
||||
cwe: string[] | null;
|
||||
code_locations: CodeLocation[] | null;
|
||||
remediation_steps: string | null;
|
||||
fix_pr_body: string | null;
|
||||
evidence: string | null;
|
||||
assumptions: string | null;
|
||||
fix_effort: FixEffort | null;
|
||||
cvss_breakdown: CVSSBreakdown | null;
|
||||
status_changed_at: string | null;
|
||||
status_changed_by: string | null;
|
||||
status_note: string | null;
|
||||
snoozed_until: string | null;
|
||||
reopened_at: string | null;
|
||||
reopened_by: string | null;
|
||||
original_severity: VulnerabilitySeverity | null;
|
||||
severity_changed_at: string | null;
|
||||
severity_changed_by: string | null;
|
||||
severity_override_reason: string | null;
|
||||
retest_of_vulnerability_id: string | null;
|
||||
slack_thread_url?: string;
|
||||
display_number?: number | null;
|
||||
location_meta?: {
|
||||
branch: string;
|
||||
provider: string;
|
||||
repo_url: string;
|
||||
} | null;
|
||||
fix_pr_eligible?: boolean;
|
||||
fix_pr_reason?: string | null;
|
||||
fix_pr_url?: string | null;
|
||||
}
|
||||
|
||||
export interface VulnerabilityFilters {
|
||||
scan_id?: string;
|
||||
severity?: VulnerabilitySeverity;
|
||||
status?: VulnerabilityStatus;
|
||||
search?: string;
|
||||
sortBy?: "cvss" | "created_at";
|
||||
sortOrder?: "asc" | "desc";
|
||||
domain_id?: string;
|
||||
repository_id?: string;
|
||||
}
|
||||
|
||||
export interface VulnerabilityAction {
|
||||
type: "status_change" | "generate_report" | "create_ticket";
|
||||
notes?: string;
|
||||
verification?: string;
|
||||
reason?: string;
|
||||
explanation?: string;
|
||||
report_type?: string;
|
||||
system?: string;
|
||||
priority?: string;
|
||||
assignee?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export const SEVERITY_COLORS: Record<VulnerabilitySeverity, string> = {
|
||||
critical: "bg-red-500/20 text-red-500 border-red-500/30",
|
||||
high: "bg-orange-500/20 text-orange-500 border-orange-500/30",
|
||||
medium: "bg-yellow-500/20 text-yellow-500 border-yellow-500/30",
|
||||
low: "bg-blue-500/20 text-blue-500 border-blue-500/30",
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<VulnerabilityStatus, string> = {
|
||||
open: STATUS_META.open.color,
|
||||
in_progress: STATUS_META.in_progress.color,
|
||||
snoozed: STATUS_META.snoozed.color,
|
||||
fixed: STATUS_META.fixed.color,
|
||||
ignored: STATUS_META.ignored.color,
|
||||
};
|
||||
|
||||
export function isSeverityOverridden(
|
||||
v: Pick<Vulnerability, "original_severity" | "severity">
|
||||
): boolean {
|
||||
return v.original_severity != null && v.original_severity !== v.severity;
|
||||
}
|
||||
|
||||
export function formatCvssLabel(cvss: number | null): string {
|
||||
if (cvss === null) return "N/A";
|
||||
if (cvss >= 9.0) return "Critical";
|
||||
if (cvss >= 7.0) return "High";
|
||||
if (cvss >= 4.0) return "Medium";
|
||||
return "Low";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
// The viewer is served as static files by a stdlib Python server on an
|
||||
// arbitrary ephemeral port, so all asset URLs must be relative (base: "./").
|
||||
// The build output is committed at strix/viewer/viewer_dist and shipped.
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../viewer/viewer_dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for building and encrypting the viewer PDF report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from pypdf import PdfReader
|
||||
from pypdf.errors import WrongPasswordError
|
||||
|
||||
from strix.viewer.report_pdf import (
|
||||
build_encrypted_report,
|
||||
encrypt_pdf,
|
||||
generate_password,
|
||||
generate_report_pdf,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _make_run(base: Path, name: str = "sample") -> Path:
|
||||
run_dir = base / "strix_runs" / name
|
||||
run_dir.mkdir(parents=True)
|
||||
record = {
|
||||
"run_name": name,
|
||||
"targets_info": [{"original": "https://example.com"}],
|
||||
"scan_mode": "deep",
|
||||
"status": "completed",
|
||||
"start_time": "2026-01-01T00:00:00Z",
|
||||
"end_time": "2026-01-01T01:02:03Z",
|
||||
"scan_results": {
|
||||
"executive_summary": "Summary with an ampersand & an <angle> bracket.",
|
||||
"recommendations": "Patch things.",
|
||||
},
|
||||
}
|
||||
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
|
||||
vulns = [
|
||||
{
|
||||
"title": "SQL Injection",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.8,
|
||||
"description": "User input reaches the query.",
|
||||
"impact": "Full database read.",
|
||||
"technical_analysis": "Details here.",
|
||||
"poc_description": "Send a crafted parameter.",
|
||||
"poc_script_code": "print('exploit')",
|
||||
"evidence": "HTTP 500 with SQL error.",
|
||||
"remediation_steps": ["Use parameterized queries", "Validate input"],
|
||||
"target": "https://example.com",
|
||||
"endpoint": "/login",
|
||||
"method": "POST",
|
||||
},
|
||||
{"title": "Informational note", "severity": "info"},
|
||||
]
|
||||
(run_dir / "vulnerabilities.json").write_text(json.dumps(vulns), encoding="utf-8")
|
||||
return run_dir
|
||||
|
||||
|
||||
def test_generate_report_pdf_has_pdf_header(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path)
|
||||
pdf = generate_report_pdf(run_dir)
|
||||
assert pdf.startswith(b"%PDF-")
|
||||
assert len(pdf) > 1000
|
||||
|
||||
|
||||
def test_generate_password_is_long_and_random() -> None:
|
||||
first = generate_password()
|
||||
second = generate_password()
|
||||
assert len(first) >= 20
|
||||
assert first != second
|
||||
|
||||
|
||||
def test_encrypt_pdf_roundtrip(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path)
|
||||
pdf = generate_report_pdf(run_dir)
|
||||
password = generate_password()
|
||||
encrypted = encrypt_pdf(pdf, password)
|
||||
|
||||
reader = PdfReader(BytesIO(encrypted))
|
||||
assert reader.is_encrypted
|
||||
assert reader.decrypt(password)
|
||||
# A correct password unlocks the pages.
|
||||
assert len(reader.pages) >= 1
|
||||
|
||||
|
||||
def test_wrong_password_is_rejected(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path)
|
||||
encrypted = encrypt_pdf(generate_report_pdf(run_dir), "correct-horse-battery")
|
||||
with pytest.raises(WrongPasswordError):
|
||||
PdfReader(BytesIO(encrypted), password="not-the-password")
|
||||
|
||||
|
||||
def test_build_encrypted_report(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path, name="run-42")
|
||||
pdf_bytes, password, filename = build_encrypted_report(run_dir)
|
||||
|
||||
assert filename == "strix-report-run-42.pdf"
|
||||
assert len(password) >= 20
|
||||
reader = PdfReader(BytesIO(pdf_bytes))
|
||||
assert reader.is_encrypted
|
||||
assert reader.decrypt(password)
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Tests for the local run viewer (strix.viewer) and its path helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from strix.core.paths import latest_run_dir, runs_base_dir
|
||||
from strix.viewer.server import serve
|
||||
from strix.viewer.transcript import (
|
||||
build_run_state,
|
||||
read_report_markdown,
|
||||
read_run_summary,
|
||||
read_vulnerabilities,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_run(base: Path, name: str, *, status: str, end_time: str | None) -> Path:
|
||||
run_dir = base / "strix_runs" / name
|
||||
state_dir = run_dir / ".state"
|
||||
state_dir.mkdir(parents=True)
|
||||
record = {"run_name": name, "status": status, "end_time": end_time}
|
||||
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
|
||||
agents = {
|
||||
"statuses": {"root": "completed", "child": "running"},
|
||||
"names": {"root": "strix", "child": "recon"},
|
||||
"parent_of": {"root": None, "child": "root"},
|
||||
}
|
||||
(state_dir / "agents.json").write_text(json.dumps(agents), encoding="utf-8")
|
||||
return run_dir
|
||||
|
||||
|
||||
def test_latest_run_dir_none_when_no_runs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert latest_run_dir() is None
|
||||
assert runs_base_dir() == tmp_path / "strix_runs"
|
||||
|
||||
|
||||
def test_latest_run_dir_picks_newest_by_record_mtime(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
older = _make_run(tmp_path, "old", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
newer = _make_run(tmp_path, "new", status="running", end_time=None)
|
||||
# Force a newer mtime on the second run's record.
|
||||
os.utime(newer / "run.json", (2_000_000_000, 2_000_000_000))
|
||||
os.utime(older / "run.json", (1_000_000_000, 1_000_000_000))
|
||||
assert latest_run_dir() == newer
|
||||
|
||||
|
||||
def test_read_run_summary_finished_flag(tmp_path: Path) -> None:
|
||||
finished = _make_run(tmp_path, "done", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
live = _make_run(tmp_path, "live", status="running", end_time=None)
|
||||
assert read_run_summary(finished)["finished"] is True
|
||||
assert read_run_summary(live)["finished"] is False
|
||||
# A terminal status without an end_time is not "finished".
|
||||
partial = _make_run(tmp_path, "partial", status="failed", end_time=None)
|
||||
assert read_run_summary(partial)["finished"] is False
|
||||
|
||||
|
||||
def test_read_missing_artifacts_return_defaults(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path, "empty", status="running", end_time=None)
|
||||
assert read_vulnerabilities(run_dir) == []
|
||||
assert read_report_markdown(run_dir) == ""
|
||||
|
||||
|
||||
def test_build_run_state_from_agents_json(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path, "graph", status="running", end_time=None)
|
||||
state = build_run_state(run_dir)
|
||||
ids = {a["id"] for a in state["agents"]}
|
||||
assert ids == {"root", "child"}
|
||||
child = next(a for a in state["agents"] if a["id"] == "child")
|
||||
assert child["parent_id"] == "root"
|
||||
assert child["name"] == "recon"
|
||||
# No agents.db, so no message/tool events.
|
||||
assert state["events"] == []
|
||||
|
||||
|
||||
def _get(url: str) -> tuple[int, str, bytes]:
|
||||
with urllib.request.urlopen(url) as resp: # noqa: S310 - localhost test server
|
||||
return resp.status, resp.headers.get("Content-Type", ""), resp.read()
|
||||
|
||||
|
||||
def test_server_serves_api_and_static(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "served", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
|
||||
assets = tmp_path / "bundle"
|
||||
(assets / "assets").mkdir(parents=True)
|
||||
(assets / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8")
|
||||
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
status, ctype, body = _get(f"{url}/api/run")
|
||||
assert status == 200
|
||||
assert "application/json" in ctype
|
||||
assert json.loads(body)["finished"] is True
|
||||
|
||||
status, _, body = _get(f"{url}/api/transcript")
|
||||
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
|
||||
|
||||
# Real asset is served.
|
||||
status, ctype, _ = _get(f"{url}/assets/app.js")
|
||||
assert status == 200
|
||||
|
||||
# Unknown non-API route falls back to index.html (SPA routing).
|
||||
status, ctype, body = _get(f"{url}/agents/root")
|
||||
assert status == 200
|
||||
assert b"<div id=root>" in body
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_server_event_endpoint_forwards_cta(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "evt", status="running", end_time=None)
|
||||
assets = tmp_path / "bundle"
|
||||
assets.mkdir()
|
||||
(assets / "index.html").write_text("x", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
seen: list[tuple[str, str | None]] = []
|
||||
monkeypatch.setattr(
|
||||
"strix.telemetry.posthog.viewer_cta_clicked",
|
||||
lambda cta, surface=None: seen.append((cta, surface)),
|
||||
)
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
body = json.dumps(
|
||||
{"event": "cta_clicked", "cta": "PR reviews", "surface": "sidebar_nav"}
|
||||
).encode()
|
||||
req = urllib.request.Request( # noqa: S310 - localhost test server
|
||||
f"{url}/api/event", data=body, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310
|
||||
assert resp.status == 204
|
||||
assert seen == [("PR reviews", "sidebar_nav")]
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_server_event_endpoint_forwards_email_funnel(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "evt2", status="running", end_time=None)
|
||||
assets = tmp_path / "bundle"
|
||||
assets.mkdir()
|
||||
(assets / "index.html").write_text("x", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
seen: list[tuple[str, str | None]] = []
|
||||
monkeypatch.setattr(
|
||||
"strix.telemetry.posthog.viewer_email_event",
|
||||
lambda step, purpose=None: seen.append((step, purpose)),
|
||||
)
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
# A whitelisted funnel event is forwarded; an unknown event is ignored.
|
||||
for payload, expected in (
|
||||
({"event": "email_verified", "purpose": "report"}, [("email_verified", "report")]),
|
||||
({"event": "not_a_real_event"}, [("email_verified", "report")]),
|
||||
):
|
||||
req = urllib.request.Request( # noqa: S310 - localhost test server
|
||||
f"{url}/api/event",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310
|
||||
assert resp.status == 204
|
||||
assert seen == expected
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def _post(
|
||||
url: str, path: str, payload: Mapping[str, object], *, cookie: str | None = None
|
||||
) -> tuple[int, bytes]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
req = urllib.request.Request( # noqa: S310 - localhost test server
|
||||
url + path, data=json.dumps(payload).encode(), headers=headers, method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read()
|
||||
|
||||
|
||||
def _session_cookie(url: str, token: str) -> str:
|
||||
"""Bootstrap a session via the tokened URL and return its ``name=value`` cookie."""
|
||||
bootstrap = f"{url}/?token={token}"
|
||||
with urllib.request.urlopen(bootstrap) as resp: # noqa: S310 - localhost test server
|
||||
raw = str(resp.headers.get("Set-Cookie", ""))
|
||||
return raw.split(";", 1)[0]
|
||||
|
||||
|
||||
def _get_status(url: str) -> int:
|
||||
try:
|
||||
with urllib.request.urlopen(url) as resp: # noqa: S310 - localhost test server
|
||||
return int(resp.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return int(exc.code)
|
||||
|
||||
|
||||
def _bundle(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assets = tmp_path / "bundle"
|
||||
assets.mkdir()
|
||||
(assets / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
|
||||
def test_capability_issued_only_for_tokened_bootstrap(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "cookie", status="running", end_time=None)
|
||||
assets = tmp_path / "bundle"
|
||||
(assets / "assets").mkdir(parents=True)
|
||||
(assets / "index.html").write_text("<!doctype html>index", encoding="utf-8")
|
||||
(assets / "assets" / "app.js").write_text("1", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
# A bare index load -- all a reachable client can do -- hands out nothing.
|
||||
with urllib.request.urlopen(url + "/") as resp: # noqa: S310
|
||||
assert resp.headers.get("Set-Cookie") is None
|
||||
|
||||
# A wrong token is likewise refused the capability.
|
||||
with urllib.request.urlopen(f"{url}/?token=wrong") as resp: # noqa: S310
|
||||
assert resp.headers.get("Set-Cookie") is None
|
||||
|
||||
# Only the correct bootstrap token mints the session cookie.
|
||||
with urllib.request.urlopen(f"{url}/?token={token}") as resp: # noqa: S310
|
||||
cookie = str(resp.headers.get("Set-Cookie", ""))
|
||||
assert "strix_viewer_session=" in cookie
|
||||
assert "HttpOnly" in cookie and "SameSite=Strict" in cookie
|
||||
|
||||
# Static assets never carry it.
|
||||
with urllib.request.urlopen(url + "/assets/app.js") as resp: # noqa: S310
|
||||
assert resp.headers.get("Set-Cookie") is None
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_unauthorized_client_cannot_acquire_capability(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "exposed", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
delivered: list[tuple[str, str]] = []
|
||||
|
||||
def handler(agent_id: str, message: str) -> bool:
|
||||
delivered.append((agent_id, message))
|
||||
return True
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False, steer_handler=handler)
|
||||
try:
|
||||
# A direct network client can reach the page but is handed no capability,
|
||||
# so replaying an empty/guessed cookie cannot steer a live scan.
|
||||
with urllib.request.urlopen(url + "/") as resp: # noqa: S310
|
||||
assert resp.headers.get("Set-Cookie") is None
|
||||
status, _ = _post(
|
||||
url,
|
||||
"/api/agents/steer",
|
||||
{"agent_id": "root", "message": "pwn"},
|
||||
cookie="strix_viewer_session=",
|
||||
)
|
||||
assert status == 403
|
||||
assert delivered == []
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"})
|
||||
verified = {"value": True}
|
||||
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: verified["value"])
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
_, _, body = _get(f"{url}/api/auth/status")
|
||||
assert json.loads(body) == {"verified": True, "email": "a@b.com"}
|
||||
|
||||
# Once expired, status must advertise unverified so the SPA re-prompts.
|
||||
verified["value"] = False
|
||||
_, _, body = _get(f"{url}/api/auth/status")
|
||||
assert json.loads(body)["verified"] is False
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_steer_requires_session_cookie(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "steer", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
delivered: list[tuple[str, str]] = []
|
||||
|
||||
def handler(agent_id: str, message: str) -> bool:
|
||||
delivered.append((agent_id, message))
|
||||
return True
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False, steer_handler=handler)
|
||||
try:
|
||||
body = {"agent_id": "root", "message": "focus on auth"}
|
||||
# No cookie: rejected before reaching the live coordinator.
|
||||
status, _ = _post(url, "/api/agents/steer", body)
|
||||
assert status == 403
|
||||
assert delivered == []
|
||||
|
||||
# With the session cookie the message is delivered.
|
||||
status, raw = _post(url, "/api/agents/steer", body, cookie=_session_cookie(url, token))
|
||||
assert status == 200
|
||||
assert json.loads(raw)["ok"] is True
|
||||
assert delivered == [("root", "focus on auth")]
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_report_send_requires_session_cookie(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "report", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
# A verified machine token exists, but that alone must not authorize a caller.
|
||||
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"})
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
# No cookie: forbidden before the machine token is ever consulted.
|
||||
status, _ = _post(url, "/api/report/send", {})
|
||||
assert status == 403
|
||||
|
||||
# With the cookie the request clears the session gate; it then reaches
|
||||
# the run resolver, so an unknown run is a 404 rather than a 403.
|
||||
status, _ = _post(
|
||||
url, "/api/report/send", {"run": "does-not-exist"}, cookie=_session_cookie(url, token)
|
||||
)
|
||||
assert status == 404
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_historical_run_data_requires_verification(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
verified = {"value": False}
|
||||
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: verified["value"])
|
||||
|
||||
httpd, url, _ = serve(launched, open_browser=False)
|
||||
try:
|
||||
# The launched run is always viewable, no verification required.
|
||||
status, _, _ = _get(f"{url}/api/run")
|
||||
assert status == 200
|
||||
|
||||
# A different run's data is gated behind verification.
|
||||
assert _get_status(f"{url}/api/run?run=other") == 401
|
||||
|
||||
# Once verified, the historical run resolves.
|
||||
verified["value"] = True
|
||||
status, _, _ = _get(f"{url}/api/run?run=other")
|
||||
assert status == 200
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_server_rejects_path_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "guard", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("top secret", encoding="utf-8")
|
||||
|
||||
assets = tmp_path / "bundle"
|
||||
assets.mkdir()
|
||||
(assets / "index.html").write_text("<!doctype html>index", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
# A traversal target must never leak the file; it falls back to index.html.
|
||||
_, _, body = _get(f"{url}/..%2f..%2fsecret.txt")
|
||||
assert b"top secret" not in body
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tests for viewer auth state and the relay client mapping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.viewer import auth
|
||||
|
||||
|
||||
def _iso(delta: timedelta) -> str:
|
||||
return (datetime.now(UTC) + delta).isoformat()
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.setattr(auth, "AUTH_PATH", home / ".strix" / "viewer-auth.json")
|
||||
return auth.AUTH_PATH
|
||||
|
||||
|
||||
def test_write_read_forget_roundtrip() -> None:
|
||||
assert auth.read_auth() is None
|
||||
assert auth.is_verified() is False
|
||||
|
||||
auth.write_auth(email="user@example.com", token="tok-123", verified_at=_iso(timedelta(days=30)))
|
||||
|
||||
record = auth.read_auth()
|
||||
assert record is not None
|
||||
assert record["email"] == "user@example.com"
|
||||
assert record["token"] == "tok-123"
|
||||
assert auth.is_verified() is True
|
||||
|
||||
auth.forget()
|
||||
assert auth.read_auth() is None
|
||||
assert auth.is_verified() is False
|
||||
# Forget is a no-op when the file is already gone.
|
||||
auth.forget()
|
||||
|
||||
|
||||
def test_is_verified_enforces_expiry() -> None:
|
||||
# An expired record still reads back, but no longer unlocks history.
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at=_iso(timedelta(hours=-1)))
|
||||
assert auth.read_auth() is not None
|
||||
assert auth.is_verified() is False
|
||||
|
||||
# A future expiry unlocks it.
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at=_iso(timedelta(hours=1)))
|
||||
assert auth.is_verified() is True
|
||||
|
||||
|
||||
def test_is_verified_when_expiry_absent_or_unparseable() -> None:
|
||||
# No/blank expiry: cannot enforce locally, so treat as valid.
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at="")
|
||||
assert auth.is_verified() is True
|
||||
|
||||
# Garbage expiry is ignored rather than locking the user out.
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at="not-a-date")
|
||||
assert auth.is_verified() is True
|
||||
|
||||
|
||||
def test_write_auth_is_0600() -> None:
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at="")
|
||||
mode = stat.S_IMODE(auth.AUTH_PATH.stat().st_mode)
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
def test_read_auth_rejects_incomplete_record() -> None:
|
||||
auth.AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
auth.AUTH_PATH.write_text('{"email": "a@b.com"}', encoding="utf-8")
|
||||
assert auth.read_auth() is None
|
||||
assert auth.is_verified() is False
|
||||
|
||||
|
||||
def _stub_post(monkeypatch: pytest.MonkeyPatch, status: int, body: dict[str, Any]) -> None:
|
||||
def fake(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int, dict[str, Any]]:
|
||||
return status, body
|
||||
|
||||
monkeypatch.setattr(auth, "_post_json", fake)
|
||||
|
||||
|
||||
def test_otp_start_maps_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_stub_post(monkeypatch, 200, {"ok": True})
|
||||
auth.otp_start("a@b.com") # no raise
|
||||
|
||||
_stub_post(monkeypatch, 429, {"error": "rate_limited"})
|
||||
with pytest.raises(auth.RelayError) as exc:
|
||||
auth.otp_start("a@b.com")
|
||||
assert exc.value.code == "rate_limited"
|
||||
|
||||
_stub_post(monkeypatch, 400, {})
|
||||
with pytest.raises(auth.RelayError) as exc:
|
||||
auth.otp_start("bad")
|
||||
assert exc.value.code == "invalid_email"
|
||||
|
||||
|
||||
def test_otp_verify_success_and_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": "later"})
|
||||
result = auth.otp_verify("a@b.com", "123456")
|
||||
assert result["token"] == "t"
|
||||
|
||||
_stub_post(monkeypatch, 403, {"error": "invalid_code"})
|
||||
with pytest.raises(auth.RelayError) as exc:
|
||||
auth.otp_verify("a@b.com", "000000")
|
||||
assert exc.value.code == "invalid_code"
|
||||
|
||||
|
||||
def test_report_send_never_includes_password(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int, dict[str, Any]]:
|
||||
captured["payload"] = payload
|
||||
return 200, {"ok": True}
|
||||
|
||||
monkeypatch.setattr(auth, "_post_json", fake)
|
||||
auth.report_send("tok", b"%PDF-fake", "strix-report-x.pdf", "x", "https://example.com")
|
||||
|
||||
payload = captured["payload"]
|
||||
assert set(payload) == {"token", "pdf_base64", "filename", "run_name", "target"}
|
||||
# The password is generated locally and must never appear in the relay body.
|
||||
assert "password" not in payload
|
||||
assert all("password" not in str(k).lower() for k in payload)
|
||||
|
||||
|
||||
def test_report_send_reverify_on_401(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_stub_post(monkeypatch, 401, {"error": "invalid_token"})
|
||||
with pytest.raises(auth.RelayError) as exc:
|
||||
auth.report_send("tok", b"x", "f.pdf", "r", "t")
|
||||
assert exc.value.code == "reverify"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for the /api/runs gating and the ?run= resolver (pure functions)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from strix.viewer.server import build_runs_payload, resolve_run_dir
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _make_run(base: Path, name: str, *, severity: str = "high") -> Path:
|
||||
run_dir = base / "strix_runs" / name
|
||||
run_dir.mkdir(parents=True)
|
||||
record = {
|
||||
"run_name": name,
|
||||
"targets_info": [{"original": f"https://{name}.example.com"}],
|
||||
"scan_mode": "deep",
|
||||
"status": "completed",
|
||||
"start_time": "2026-01-01T00:00:00Z",
|
||||
"end_time": "2026-01-01T00:10:00Z",
|
||||
}
|
||||
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
|
||||
(run_dir / "vulnerabilities.json").write_text(
|
||||
json.dumps([{"title": "v", "severity": severity}]), encoding="utf-8"
|
||||
)
|
||||
return run_dir
|
||||
|
||||
|
||||
def test_runs_payload_locked_when_unverified(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_make_run(tmp_path, "alpha")
|
||||
_make_run(tmp_path, "beta")
|
||||
|
||||
payload = build_runs_payload(base, verified=False)
|
||||
assert payload["locked"] is True
|
||||
assert payload["count"] == 2
|
||||
assert payload["runs"] == []
|
||||
|
||||
|
||||
def test_runs_payload_lists_when_verified(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_make_run(tmp_path, "alpha", severity="critical")
|
||||
_make_run(tmp_path, "beta", severity="info")
|
||||
|
||||
payload = build_runs_payload(base, verified=True)
|
||||
assert payload["locked"] is False
|
||||
assert payload["count"] == 2
|
||||
assert len(payload["runs"]) == 2
|
||||
entry = next(r for r in payload["runs"] if r["name"] == "alpha")
|
||||
assert entry["target"] == "https://alpha.example.com"
|
||||
assert entry["severity_counts"]["critical"] == 1
|
||||
# "info" folds into low, matching the SPA's bucketing.
|
||||
beta = next(r for r in payload["runs"] if r["name"] == "beta")
|
||||
assert beta["severity_counts"]["low"] == 1
|
||||
|
||||
|
||||
def test_runs_payload_empty_base(tmp_path: Path) -> None:
|
||||
payload = build_runs_payload(tmp_path / "strix_runs", verified=True)
|
||||
assert payload == {"locked": False, "count": 0, "runs": []}
|
||||
|
||||
|
||||
def test_resolve_run_dir_defaults_when_absent(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
default = _make_run(tmp_path, "alpha")
|
||||
assert resolve_run_dir(base, None, default) == default
|
||||
assert resolve_run_dir(base, "", default) == default
|
||||
|
||||
|
||||
def test_resolve_run_dir_valid_named_run(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
default = _make_run(tmp_path, "alpha")
|
||||
other = _make_run(tmp_path, "beta")
|
||||
assert resolve_run_dir(base, "beta", default) == other
|
||||
|
||||
|
||||
def test_resolve_run_dir_rejects_unknown_and_traversal(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
default = _make_run(tmp_path, "alpha")
|
||||
secret = tmp_path / "secret"
|
||||
secret.mkdir()
|
||||
(secret / "run.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert resolve_run_dir(base, "nope", default) is None
|
||||
assert resolve_run_dir(base, "../secret", default) is None
|
||||
assert resolve_run_dir(base, "../../etc", default) is None
|
||||
Reference in New Issue
Block a user