From 475f4838f6de47a23fdcf062c4f3e3ad0e4de10f Mon Sep 17 00:00:00 2001 From: bearsyankees Date: Wed, 16 Sep 2026 18:20:27 -0400 Subject: [PATCH 1/6] feat(report): add local false-positive triage in TUI and viewer --- docs/design/false-positive-triage.md | 155 ++++++ docs/usage/cli.mdx | 10 + docs/usage/viewer.mdx | 24 +- strix/interface/tui/backend/controller.py | 78 ++- strix/interface/tui/backend/server.py | 24 +- strix/interface/tui/internal/app/model.go | 17 + strix/interface/tui/internal/app/triage.go | 453 ++++++++++++++++ .../interface/tui/internal/app/triage_test.go | 231 ++++++++ strix/interface/tui/internal/app/update.go | 59 +- strix/interface/tui/internal/app/view.go | 21 +- .../interface/tui/internal/app/vuln_report.go | 3 + .../tui/internal/app/vulnerabilities.go | 84 ++- strix/interface/tui/internal/app/wire.go | 13 +- strix/interface/viewer/frontend/src/App.tsx | 214 +++++--- .../src/components/EmailReportView.tsx | 5 +- .../frontend/src/components/PastRunsView.tsx | 3 +- .../frontend/src/components/Sidebar.tsx | 2 +- .../vulnerability/TriageControls.tsx | 142 +++++ .../vulnerability/VulnerabilityDetail.tsx | 25 +- .../viewer/frontend/src/data/serverSource.ts | 61 ++- .../frontend/src/lib/local-run-parser.ts | 15 +- .../viewer/frontend/src/types/issues.ts | 30 +- strix/interface/viewer/report_pdf.py | 23 +- strix/interface/viewer/server.py | 138 ++++- .../viewer/static/assets/index-B94ANU8d.js | 512 ------------------ .../viewer/static/assets/index-CBqNY5AA.css | 10 + .../viewer/static/assets/index-CY2QniKa.js | 512 ++++++++++++++++++ .../viewer/static/assets/index-DN__rVv3.css | 10 - strix/interface/viewer/static/index.html | 4 +- strix/report/triage.py | 339 ++++++++++++ strix/report/triage_store.py | 190 +++++++ strix/telemetry/README.md | 21 +- strix/telemetry/posthog.py | 5 + strix/telemetry/triage.py | 113 ++++ tests/test_report_pdf.py | 50 +- tests/test_triage.py | 325 +++++++++++ tests/test_triage_telemetry.py | 393 ++++++++++++++ tests/test_tui_triage.py | 168 ++++++ tests/test_viewer_triage.py | 271 +++++++++ 39 files changed, 4099 insertions(+), 654 deletions(-) create mode 100644 docs/design/false-positive-triage.md create mode 100644 strix/interface/tui/internal/app/triage.go create mode 100644 strix/interface/tui/internal/app/triage_test.go create mode 100644 strix/interface/viewer/frontend/src/components/vulnerability/TriageControls.tsx delete mode 100644 strix/interface/viewer/static/assets/index-B94ANU8d.js create mode 100644 strix/interface/viewer/static/assets/index-CBqNY5AA.css create mode 100644 strix/interface/viewer/static/assets/index-CY2QniKa.js delete mode 100644 strix/interface/viewer/static/assets/index-DN__rVv3.css create mode 100644 strix/report/triage.py create mode 100644 strix/report/triage_store.py create mode 100644 strix/telemetry/triage.py create mode 100644 tests/test_triage.py create mode 100644 tests/test_triage_telemetry.py create mode 100644 tests/test_tui_triage.py create mode 100644 tests/test_viewer_triage.py diff --git a/docs/design/false-positive-triage.md b/docs/design/false-positive-triage.md new file mode 100644 index 00000000..57a5f58d --- /dev/null +++ b/docs/design/false-positive-triage.md @@ -0,0 +1,155 @@ +# Local false-positive triage + +Design and implementation record, 2026-09-16. Implemented for the local terminal UI and web viewer. This change adds no standalone triage CLI command or Cloud behavior. + +**User experience** + +The finding detail offers **False positive (f)** in the terminal UI and **Mark as false positive** in the web viewer. Users can close a finding with an optional reason and local note, see **Closed · False positive**, and use **Undo** or **Reopen**. Closing works offline and with telemetry disabled. The original finding is preserved, with the human decision stored separately. + +When telemetry is enabled, a small classification event follows a successfully saved decision. Written explanations and finding content stay local. The telemetry policy and feature docs describe this collection; the terminal UI and web viewer contain no telemetry copy, indicators, prompts or toggles. Detailed examples for improving detection belong in a separate, explicit sharing flow. + +**Findings from the initial investigation** + +| Area | Observed behavior | Implication | +| --- | --- | --- | +| Local viewer | The finding detail renders a status badge and banners, but offers no mutation. `parseOneVulnerability` hardcodes `open` and discards status metadata. | Adding a button alone will not persist a decision. Both the data path and UI need changes. | +| Finding storage | `ReportState` owns the findings list, assigns run-local sequential IDs, and rewrites scan artifacts from memory. | Editing `vulnerabilities.json` from the viewer can be overwritten by a live scan or resume. Deleting findings can also undermine ID allocation and deduplication. | +| Terminal UI | Its finding collection comes directly from `ReportState`, not the viewer's disk reads. | Native close/reopen commands and the collection projection must use the shared triage service. | +| Telemetry | Optional, enabled by default, configured through environment/saved settings. Identity is a random UUID per process. Existing finding events contain severity, CWE and CVE presence. | Respect the effective setting in the backend. There is no existing persistent user/finding identity for longitudinal feedback joins. | +| General feedback | The viewer's support form sends a message and email to a remote relay. | It is unsuitable for closing a local issue: closure should require neither email nor a network call. | + +Code anchors: [viewer parser](../../strix/interface/viewer/frontend/src/lib/local-run-parser.ts), [finding detail](../../strix/interface/viewer/frontend/src/components/vulnerability/VulnerabilityDetail.tsx), [report state](../../strix/report/state.py), [terminal collection](../../strix/interface/tui/backend/controller.py), [telemetry policy](../../strix/telemetry/README.md), [feedback form](../../strix/interface/viewer/frontend/src/components/FeedbackView.tsx). + +**The user flow** + +1. Open an issue in the terminal UI or web viewer and choose **Mark as false positive**. The terminal detail has a visible action and keyboard shortcut; the viewer places the action beside the status. +2. Show a compact form with a reason selector and a note of up to 2,000 characters, rendered natively in the active interface. Primary action: **Close issue**. No required essay, email, account signup, additional confirmation, browser launch, or scan rerun. +3. On successful save, keep the detail open, show **Closed · False positive**, the date and local explanation, and offer **Reopen**. A brief **Undo** action restores the prior state. +4. The issue moves out of the active list. Offer **Open / Closed / All**, retaining the original severity and all evidence in the closed view. + +Viewer form copy: + +```text +Mark as false positive + +This finding is incorrect or does not apply to your target. +Applies to this finding in this run. + +Reason (optional) [ Select a reason ] +Note (optional) [ Add context for your future review ] + +[Cancel] [Close issue] +``` + +Reason choices: **Incorrect assumption**, **Existing protection prevents the reported exploit**, **Code or dependency is not affected**, **Expected behavior, not a vulnerability**, and **Other**. Omission becomes `unspecified`. These are user assessments. Inconclusive reproduction alone should not be offered as a false-positive reason; provide “Not sure? Keep open for review” as the form's cancel path. Accepted residual risk belongs under accepted risk, even when a mitigation exists. + +The form, status banner, terminal footer and success messages contain no telemetry disclosure or controls. Keep that information in documentation. The backend still honors the effective telemetry setting and never enables it as a side effect of closing. + +Display counts such as **8 open · 2 false positives · 10 found**. Never count false-positive closures as fixes or show “no vulnerabilities found” when findings were dismissed. If every issue is closed, use “No open findings. 2 marked as false positives.” + +A save failure keeps the original state and preserves entered text for retry. An ambiguous response triggers a read of the saved state before another mutation. Keep focus and selection on the reviewed finding instead of making the page jump when it leaves the active list. + +**Local status semantics** + +| User meaning | Status | Structured resolution | +| --- | --- | --- | +| Awaiting action | `open` | none | +| Incorrect finding | `closed` | `false_positive` | + +Use `resolution_reason: false_positive` and a separate optional `reason_code` for the form's category. Keep lifecycle status separate from the reason for closing. The first release offers this false-positive resolution and reopening; accepted-risk and fixed workflows are not required. Never infer false positives from another status or prose notes. Reopening clears the current resolution but preserves history. A user label is not an independently verified scanner error. + +**Persistence and integration** + +A shared local triage service handles viewer mutations, native terminal commands and the terminal projection. It stores a versioned `triage.json` alongside the run artifacts, keyed by the real finding ID within that run and including the run identity in the document. Legacy runs without this file remain open. Mutations of missing, duplicate or synthetic finding IDs are rejected. + +Each decision records current status, resolution reason, reason code, optional note, timestamp, revision, and an audit history of transitions. The actor label is `local_operator`; do not collect an OS username or imply a verified human identity. Record the reviewed finding revision/digest locally so material evidence changes can be detected. Local digests must never enter telemetry. + +Protect read-modify-write with a stable per-run cross-process lock and a thread lock, write a temporary file, then atomically replace it. Lock a separate lockfile: replacing the data file must not replace the inode being locked. Validate an expected revision to reject stale edits. If a platform cannot provide the required locking, fail the mutation clearly rather than silently proceeding without it. + +The scanner continues to own its original reports, IDs and dedupe inputs. Human triage is not an agent-editable reporting field. Compose the latest triage into reads instead of copying it into the agent's mutable finding dictionary. Closing cannot cause resume or dedupe to re-report the same finding as a new issue. + +The viewer uses `POST /api/vulnerabilities/{id}/triage?run=` with both the expected triage revision and the digest of the finding the user reviewed, returning the saved record. A changed finding while the form was open must require fresh review. Reuse the process session capability and run authorization, enforce same-origin browser writes, bound request/note sizes, and reject traversal or symlink escape for writable paths. A note is untrusted text in HTML, terminal and exported reports. Local closure of the launched run needs no email verification; preserve the existing access rule when selecting historical runs through the viewer. + +Update the parser, detail controls, active/closed filtering and every displayed count. The terminal backend must read the same overlay and expose native close/reopen commands through its existing command protocol. The terminal finding detail gets a keyboard-accessible action, an optional reason/note form, save errors, Undo and Reopen. Terminal badges, counts, filters and copied reports reflect the same saved decisions. This flow works entirely inside the terminal; **Ctrl+O** remains an optional way to view the run in a browser. + +Terminal controls: **F2** opens findings, including on narrow terminals where the sidebar is hidden. **f** opens the false-positive form, **r** reopens a closed finding, and **u** undoes a recent change, scoped to the finding-detail modal. In the detail or focused findings panel, **v** cycles **Open / Closed / All** so closed findings remain reachable. If the current filter is empty, **F2** opens All. Show focusable buttons as well as these shortcuts; preserve **c** to copy, arrow navigation, **Tab / Shift+Tab** to move button focus, **Enter** to activate and **Esc** to go back. Use a separate form state for reason/note input so typing `f`, `r` or `v` cannot trigger a mutation, and preserve the scan composer's draft. See [native triage controls](../../strix/interface/tui/internal/app/triage.go). + +The `vulnerability.triage` command in the [terminal backend dispatcher](../../strix/interface/tui/backend/controller.py) handles the native controls. Its request carries the real finding ID, desired status, resolution reason, optional reason/note, expected triage revision and reviewed finding digest. The run is resolved from the terminal session. Saved state returns through the existing request/result protocol, and the collection refreshes only after success. Pending/error state is bound to the finding ID, not its list index. Closing or reopening never sends a chat message to the pentesting agent. + +The web viewer refreshes on mutation, window focus and run switch, and checks a small file revision token to pick up terminal or other-tab changes, including for finished runs. The terminal's sidecar revision watcher notifies it of viewer edits even when the scanner is idle. Older in-flight updates cannot overwrite newer successful mutations. UI updates remain scoped to run ID and finding ID when users switch runs mid-request. Read-only run directories expose an unavailable write capability. + +**Scope, later evidence and reports** + +V1 dismissal applies to one finding in one run and survives restarts/resume. A new scan may rediscover it. Do not silently suppress another finding by title, CWE or file alone. The existing SARIF fingerprints are useful matching inputs, but they do not establish safe cross-scan identity for changing AI-generated findings. + +If an agent materially changes the evidence or affected location under an already-reviewed ID, preserve the saved human decision in `triage_status` but project `review_stale: true` and `status: open`. Show **Needs review** in the Open filter and active counts, excluding it from closed counts. Reclosing requires a fresh reviewed digest and creates a new review even if its status/reason are unchanged. This derived staleness is not a human reopen event. Presentation-only edits should not invalidate review. Compare structured evidence/claim inputs, not just the title. + +The original scan report and artifacts remain available with an explicit “Original scan report” label. Current triage counts appear beside that report so its original narrative is not mistaken for today's open issue count. The emailed PDF's send/download action, filename and document cover identify it as the original scan report, say subsequent triage is excluded, and label counts as detected findings. The current PDF reads the original findings directly ([PDF generation](../../strix/interface/viewer/report_pdf.py)). + +A later curated export should include open findings and an appendix of closed findings/reasons with a triage-as-of timestamp. Freeform notes should be included only when the user selects that export option. Generate exports from one consistent triage revision. + +Preserve the original `findings.sarif`. A separately named triaged SARIF export can represent external suppressions where supported, or offer an explicitly selected active-only export. Do not claim that SARIF suppression metadata closes existing GitHub alerts: consumer support and native alert updates need separate verification. Do not retroactively alter a scan's exit code, completion status or coverage assessment after human triage. + +**Telemetry contract** + +Use PostHog for the richer event through the existing gate. There is no need to duplicate a new classification event across both analytics vendors in v1. Emit from the triage service after a successful, real transition, not from a click handler or the generic `/api/event` endpoint. The latter is currently unauthenticated and cannot prove that a decision was saved; the authenticated successful-feedback handler is a better precedent. See [server events](../../strix/interface/viewer/server.py) and [feedback completion](../../strix/interface/viewer/server.py). + +Classification event: + +```json +{ + "event": "finding_triage_changed", + "properties": { + "schema_version": 1, + "surface": "viewer", + "previous_status": "open", + "new_status": "closed", + "previous_resolution_reason": null, + "resolution_reason": "false_positive", + "reason_code": "incorrect_assumption", + "severity": "high", + "cwe": "cwe-79", + "is_cve": false, + "scan_mode": "standard" + } +} +``` + +Reuse current common app-version/OS properties and the process-only anonymous session identifier. `surface` is `viewer` or `tui`. Validate every category against a closed vocabulary, and validate CWE syntax. Derive classification attributes from persisted data on the backend. Do not forward arbitrary interface fields or arbitrary strings from findings. Missing legacy metadata is `unknown` or omitted. + +Exclude notes, titles, descriptions, PoCs, evidence, source code, email, targets, paths, URLs, run names, finding IDs and target-derived hashes. Do not attach the viewer's current model as if it produced an old scan. Model/version comparisons require reliable original-scan provenance and an approved bounded model identifier; leave that dimension out until it exists. + +Check the effective telemetry setting before enqueueing and before sending. The sender uses a bounded in-memory background queue; closure should complete without waiting for network timeouts. Delivery may be dropped when the process exits or the queue is full. Failed delivery never reverses the local decision. Do not persist an analytics backlog, replay old triage when telemetry is enabled later, or emit events for read-only loads, failed writes, conflicts and repeated no-op submissions. Notes-only edits need no analytics event. Human reopen, reason-category changes and fresh review of changed evidence are real classification transitions. + +Settings are memoized and resolve environment → saved configuration → defaults. Keep the existing controls; add no UI toggle, setting indicator or telemetry copy. The [telemetry policy](../../strix/telemetry/README.md) and local feature documentation describe the event fields, exclusions, best-effort delivery and `STRIX_TELEMETRY=0` opt-out. See [settings loader](../../strix/config/loader.py), [PostHog gate](../../strix/telemetry/posthog.py), and [identity](../../strix/telemetry/_common.py). + +This yields useful **user-reported false-positive trends** by reason, severity, CWE and Strix version. It does not yield a defensible scanner false-positive rate: later viewer sessions have different identities, no durable finding correlation exists, users self-select what to review, and undo/reopen actions can repeat. Start with transition counts and reason distributions, labeled honestly. A measured precision/FP rate needs a reviewed sample with a denominator and independent validation. + +If detailed examples become necessary, add **Share this finding with Strix** as a separate optional action with a concrete payload preview and deliberate submission. Do not reinterpret enabled basic telemetry as permission to upload content. No backend for this detailed triage submission was verified in this research. + +**Why this interaction** + +The recommendation follows established review workflows while keeping Strix's run scope explicit. GitHub offers dismissal reasons, optional comments, a closed list and reopening; its API separates state from reason. [GitHub alert management](https://docs.github.com/en/code-security/how-tos/manage-security-alerts/manage-code-scanning-alerts/resolve-alerts), [GitHub alert update API](https://docs.github.com/en/rest/code-scanning/code-scanning#update-a-code-scanning-alert). + +Semgrep distinguishes false positives, accepted risk and deferral, and keeps fixed separate. Snyk explicitly names repository-wide ignore scope and supports undoing it. Those are good precedents for precise labels and visible scope, but cross-scan persistence requires identity support Strix has not yet established. [Semgrep triage](https://semgrep.dev/docs/for-developers/resolve-findings-through-app), [Snyk consistent ignores](https://docs.snyk.io/manage-risk/prioritize-issues-for-fixing/ignore-issues/consistent-ignores-for-snyk-code). + +Semgrep's metrics documentation also separates counts from source content and warns that deterministic hashes can be pseudonymous. That supports a minimal metadata event rather than uploading the dismissed report. [Semgrep metrics](https://docs.semgrep.dev/metrics). + +**Delivery and acceptance** + +The implementation includes the local triage service, native terminal close/reopen flow, viewer flow, terminal status projection, original-report labeling (including the emailed PDF), consent-gated metadata event and documentation. Decisions are durable and reversible across both local interfaces. Curated exports, bulk triage, detailed sharing and cross-scan carryover can follow as separate changes. + +Acceptance checks: + +- Close, reload, restart viewer, resume scan and reopen all preserve evidence, IDs, decisions and audit history. +- A live scan artifact rewrite cannot erase triage; simultaneous terminal and viewer edits either serialize or return a conflict without losing decisions. +- Close and reopen directly inside the terminal, with optional reason/note input, without starting a web viewer. The web viewer reads those same decisions, and viewer changes appear in the terminal. +- Active/closed counts, the selected detail, terminal output and report labels agree. A materially changed finding becomes active for review. +- Unknown findings, duplicate IDs, stale revisions, malformed sidecars, unauthorized/cross-origin requests, path escapes, read-only directories and interrupted writes cannot produce a successful closure. +- Telemetry disabled through either environment or saved settings causes zero analytics HTTP calls while closure and reopening still succeed. +- Sentinel secrets in every freeform field never appear in event payloads; malformed categories cannot bypass allowlisting. +- Telemetry failure cannot delay/fail closure; failed/no-op mutations emit nothing; re-enabling telemetry never replays prior disabled actions. +- Both interfaces omit telemetry copy, prompts and indicators. Documentation describes the collection and opt-out accurately. +- Keyboard/focus behavior, small screens, errors and live-poll/run-switch races work. Build/type-check the viewer and run the relevant Python/Go tests plus repository checks. + +Implementation lives in the [shared triage service](../../strix/report/triage.py), [atomic store](../../strix/report/triage_store.py), [native terminal controls](../../strix/interface/tui/internal/app/triage.go), [viewer controls](../../strix/interface/viewer/frontend/src/components/vulnerability/TriageControls.tsx), and [classification telemetry](../../strix/telemetry/triage.py). Tests cover local persistence and real scanner resume, simultaneous processes, invalid storage and failed writes, authenticated viewer and native terminal mutations, and the telemetry network boundary. Synthetic browser and real 80×24 PTY workflows were exercised; original PDF pages were rendered and visually checked. No pentest or cloud account mutation was required. diff --git a/docs/usage/cli.mdx b/docs/usage/cli.mdx index 699fb1cb..dbee074e 100644 --- a/docs/usage/cli.mdx +++ b/docs/usage/cli.mdx @@ -155,6 +155,16 @@ strix --target ./my-project --workspace-file ./wordlist.txt strix --target https://app.com --workspace-file ./openapi.yaml:specs/openapi.yaml ``` +## Reviewing Findings In The Terminal + +In the interactive terminal UI, press **F2** to open findings, then **f** to mark one as a false positive, **r** to reopen it, or **u** to undo a recent change. You can also click **False positive (f)** in the finding detail. The native form accepts an optional reason and note of up to 2,000 characters. In the finding detail or focused findings panel, press **v** to cycle **Open / Closed / All**. The same actions are available through focusable buttons; **Tab / Shift+Tab** moves focus, **Enter** activates a button, and **Esc** goes back. **c** still copies the finding, and **Ctrl+O** opens the optional web viewer. + +Triage applies to one finding in one run and is saved in the run's `triage.json`. It survives restarts and scan resume, and is visible in the terminal and [local web viewer](/usage/viewer#marking-false-positives). Closing preserves the finding's severity and evidence. A new scan has its own review state; material changes to a closed finding show **Needs review** and return it to the Open filter while preserving the previous decision. The original scan artifacts and emailed **Original scan report** PDF retain all detected findings and exclude subsequent local triage. + +Closing and reopening work with telemetry disabled. When enabled, the existing telemetry setting permits an asynchronous, best-effort classification event containing only the interface, status/resolution transition, reason category, severity, CWE, CVE presence, scan mode, and common system/version information. Written notes, finding content, targets, run and finding IDs, and local digests are excluded. Actions taken with telemetry disabled are not sent later. Set `STRIX_TELEMETRY=0` before starting Strix to opt out. + +Triage decisions do not change the original scan's exit code, completion status, or coverage assessment. + ## Exit Codes | Code | Meaning | diff --git a/docs/usage/viewer.mdx b/docs/usage/viewer.mdx index 01f42c57..438d94c3 100644 --- a/docs/usage/viewer.mdx +++ b/docs/usage/viewer.mdx @@ -11,7 +11,7 @@ strix view my-run-name # a specific run under ./strix_run strix view --host 0.0.0.0 --port 8080 --no-open ``` -The UI ships prebuilt with Strix, so there is no extra install and no JavaScript build step. The dashboard reads the run files straight off disk. Nothing leaves your machine, and you do not need a cloud account. +The UI ships prebuilt with Strix, so there is no extra install and no JavaScript build step. The dashboard reads the run files straight off disk. You do not need a cloud account to review or triage the launched run. Anonymous usage telemetry follows your [telemetry setting](/advanced/configuration); emailing a report and sending feedback use the remote services described by those actions. ## Options @@ -33,17 +33,31 @@ The UI ships prebuilt with Strix, so there is no extra install and no JavaScript ## What Is 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. +- **Overview** — run status, target, a severity breakdown of open findings, and counts of false positives and total findings detected. +- **Vulnerabilities** — each validated finding with its severity, details, and reproduction steps. Mark incorrect findings as false positives, reopen them, and filter by **Open / Closed / All**. - **Agent graph** — a live map of the multi-agent team, and what each agent is doing. - **Steering** — send instructions to a live scan to redirect the agents during the run. Steering works only in the dashboard the running scan opens. A standalone `strix view` has no live scan to steer. - **History** — browse past runs on this machine and move between them. Verify your email address in the dashboard to unlock the other runs. -- **Reports** — generate a shareable report and send it by email. Verify your email address first. +- **Reports** — generate an **Original scan report** and send it by email. Verify your email address first. This PDF includes all detected findings and excludes subsequent local triage decisions and notes. + +## Marking False Positives + +Open a finding and choose **Mark as false positive** when it is incorrect or does not apply to your target. Choose an optional reason, add an optional note of up to 2,000 characters for future review, and select **Close issue**. The finding shows **Closed · False positive**. Use **Undo** or **Reopen** to reverse the decision. + +The **Open / Closed / All** filters keep closed findings available with their original severity and evidence. Counts distinguish open findings from false positives and the total detected. Closing an issue does not mean it was fixed. + +In the terminal UI, press **F2** to open findings, including on narrow terminals where the sidebar is hidden. In a finding, click **False positive (f)** or press **f** to open the native false-positive form; **r** reopens a closed issue, and **u** undoes a recent change. Use **Tab / Shift+Tab** to move through the optional reason, note and buttons, **Enter** to activate a button, and **Esc** to return. Press **v** in the finding detail or focused findings panel to cycle **Open / Closed / All**. If the selected filter is empty, **F2** opens **All** so closed issues remain accessible. No browser is required. + +Decisions apply to that finding in that run. They are saved locally in `triage.json`, survive restarts and scan resume, and are shared by the viewer and terminal UI. Closing and reopening work offline and with telemetry disabled. They do not require an email address for the launched run. A separate scan starts with its own review state; material changes to a closed finding show **Needs review** and return it to the Open filter while preserving the previous decision. + +The original scan report, emailed PDF, raw finding files, and SARIF retain the scanner's findings. The PDF cover and filename identify it as the **Original scan report**; its counts describe detected findings, including those you later closed. Local decisions do not change the original scan's exit code or coverage assessment. + +When telemetry is enabled, a saved classification change sends anonymous metadata: the interface used, previous and new status and resolution, optional reason category, severity, CWE, CVE presence, scan mode, and common system/version information. Notes, finding content, targets, run and finding IDs, and local digests are excluded. Delivery is asynchronous and best effort; actions taken with telemetry off are not queued for later delivery. Set `STRIX_TELEMETRY=0` to opt out. ## Sharing The Link - The token in the printed URL grants access to the run data, and to the steering of a live scan. Share it only with trusted users. + The token in the printed URL grants access to the run data, changes to local finding triage, and steering of a live scan. Share it only with trusted users. To reach the viewer from another machine, start it with `--host 0.0.0.0` and replace `0.0.0.0` in the printed URL with a reachable IP address or hostname. Restrict the port with your firewall. A request without the token-derived session cannot read run data. diff --git a/strix/interface/tui/backend/controller.py b/strix/interface/tui/backend/controller.py index b2f1eb75..66bd0ad4 100644 --- a/strix/interface/tui/backend/controller.py +++ b/strix/interface/tui/backend/controller.py @@ -34,6 +34,20 @@ if TYPE_CHECKING: _STOPPABLE_AGENT_STATUSES = frozenset({"running", "waiting", "budget_paused"}) +_TRIAGE_FIELDS = ( + "status", + "triage_status", + "resolution_reason", + "reason_code", + "status_note", + "status_changed_at", + "status_changed_by", + "triage_revision", + "finding_digest", + "review_stale", + "can_triage", + "triage_error", +) ChangeCallback = Callable[[], None] StartCallback = Callable[[], Awaitable[None]] @@ -264,9 +278,29 @@ class TuiController: reports = ( self.report_state.vulnerability_reports if self.report_state is not None else [] )[-MAX_TERMINAL_VULNERABILITIES:] + if self.report_state is not None and hasattr(self.report_state, "get_run_dir"): + from strix.report.triage import TriageError, read_triaged_vulnerabilities + + try: + reports = read_triaged_vulnerabilities(self.report_state.get_run_dir(), reports) + except TriageError as exc: + # A broken sidecar must not hide evidence or stop the scan UI. + reports = [ + {**report, "can_triage": False, "triage_error": str(exc)} + for report in reports + ] result: list[dict[str, Any]] = [] for index, report in enumerate(reports): - projected = collection_item_projection(report) + projected = collection_item_projection( + {key: value for key, value in report.items() if key != "triage_history"} + ) + # The optional evidence projection may truncate a large finding; + # its small revision and write-capability fields must survive. + projected.update( + terminal_projection( + {key: report[key] for key in _TRIAGE_FIELDS if key in report} + ) + ) report_id = projected.get("id") if not isinstance(report_id, str) or not report_id: projected["id"] = f"vulnerability-{index}" @@ -303,6 +337,7 @@ class TuiController: "agent.send_message": self._send_message, "agent.stop": self._stop_agent, "viewer.open": self._open_viewer, + "vulnerability.triage": self._triage_vulnerability, "app.quit": self._quit, } handler = handlers.get(command) @@ -312,6 +347,47 @@ class TuiController: self.notify_changed() return result + def triage_stamp(self) -> tuple[int, int] | None: + """Observe external decisions even while the scan has nothing to broadcast.""" + if self.report_state is None or not hasattr(self.report_state, "get_run_dir"): + return None + from strix.report.triage import triage_stamp + + return triage_stamp(self.report_state.get_run_dir()) + + async def _triage_vulnerability(self, payload: dict[str, Any]) -> dict[str, Any]: + from strix.report.triage import TriageError, triage_finding + + if self.report_state is None: + raise TriageError("unavailable", "Scan output is not ready") + finding_id = self._required_string(payload, "finding_id") + status = self._required_string(payload, "status") + if payload.get("resolution_reason") not in (None, "false_positive"): + raise TriageError("invalid_request", "Unsupported resolution reason") + revision = payload.get("expected_revision") + digest = payload.get("reviewed_digest") + if ( + not isinstance(revision, int) + or isinstance(revision, bool) + or not isinstance(digest, str) + ): + raise TriageError("invalid_request", "Review revision and finding digest are required") + result = await asyncio.to_thread( + triage_finding, + self.report_state.get_run_dir(), + finding_id, + status=status, + expected_revision=revision, + reviewed_digest=digest, + reason_code=payload.get("reason_code", "unspecified"), + note=payload.get("note", ""), + surface="tui", + ) + # The evidence travels in collection frames; keep the acknowledgement + # below the command-result size limit even for very large findings. + finding = {key: result["finding"].get(key) for key in ("id", *_TRIAGE_FIELDS)} + return {"changed": result["changed"], "finding": terminal_projection(finding)} + async def _add_target(self, payload: dict[str, Any]) -> dict[str, Any]: self._require_setup_mutable() target = self._required_string(payload, "target") diff --git a/strix/interface/tui/backend/server.py b/strix/interface/tui/backend/server.py index f884b3b6..eaedca4c 100644 --- a/strix/interface/tui/backend/server.py +++ b/strix/interface/tui/backend/server.py @@ -20,6 +20,7 @@ from strix.interface.tui.backend.protocol import ( ProtocolHandshakeError, envelope, ) +from strix.report.triage import TriageError if TYPE_CHECKING: @@ -60,6 +61,7 @@ class TuiBackendServer: self._reader_task: asyncio.Task[None] | None = None self._broadcast_event = asyncio.Event() self._broadcast_task: asyncio.Task[None] | None = None + self._triage_watch_task: asyncio.Task[None] | None = None self._write_lock = asyncio.Lock() self._sync_lock = asyncio.Lock() self._state_revision = 0 @@ -89,10 +91,15 @@ class TuiBackendServer: self.activated = True self._reader_task = asyncio.create_task(self._read_loop()) self._broadcast_task = asyncio.create_task(self._broadcast_loop()) + self._triage_watch_task = asyncio.create_task(self._watch_triage()) self.notify_changed() async def close(self) -> None: - tasks = [task for task in (self._reader_task, self._broadcast_task) if task is not None] + tasks = [ + task + for task in (self._reader_task, self._broadcast_task, self._triage_watch_task) + if task is not None + ] for task in tasks: task.cancel() for task in tasks: @@ -102,6 +109,7 @@ class TuiBackendServer: await task self._reader_task = None self._broadcast_task = None + self._triage_watch_task = None self._close_socket() def _close_socket(self) -> None: @@ -188,6 +196,8 @@ class TuiBackendServer: @staticmethod def _structured_error(exc: Exception) -> dict[str, object]: + if isinstance(exc, TriageError): + return {"code": exc.code, "message": str(exc), "retryable": False} if isinstance(exc, OSError): return {"code": "persistence_error", "message": str(exc), "retryable": True} if isinstance(exc, TypeError | ValueError | json.JSONDecodeError | UnicodeDecodeError): @@ -529,3 +539,15 @@ class TuiBackendServer: self._close_socket() except (ConnectionError, OSError): self._close_socket() + + async def _watch_triage(self) -> None: + previous: tuple[int, int] | None = None + while True: + await asyncio.sleep(0.5) + try: + current = self.controller.triage_stamp() + except (OSError, TriageError): + current = None + if current != previous: + previous = current + self.notify_changed() diff --git a/strix/interface/tui/internal/app/model.go b/strix/interface/tui/internal/app/model.go index e7cc8975..315fecca 100644 --- a/strix/interface/tui/internal/app/model.go +++ b/strix/interface/tui/internal/app/model.go @@ -64,6 +64,7 @@ const ( modalStop modalConfirmMount modalVulnerability + modalTriage ) type focusMode int @@ -134,6 +135,12 @@ type Model struct { seenMessages map[string]bool vulnerabilityCopied bool vulnerabilityCopyError string + findingFilter int + triage triageForm + triagePending *triageRequest + triageUndo *triageUndoState + triageError string + triageErrorID string } var ( @@ -335,6 +342,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.resizeVulnerabilityViewport() m.ensureAgentVisible() m.ensureVulnerabilityVisible() + m.resizeTriageForm() case wireErrMsg: if !m.quitting { m.errorText = "Backend disconnected: " + msg.err.Error() @@ -354,6 +362,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, readWire(m.client)) case sentMsg: if msg.err != nil { + if msg.command == "vulnerability.triage" { + if m.triagePending != nil { + m.triageErrorID = m.triagePending.findingID + } + m.triagePending = nil + m.triageError = msg.err.Error() + } m.errorText = msg.err.Error() if msg.command == "collection.resync" && msg.collection != "" { m.resyncRequested[msg.collection] = false @@ -407,6 +422,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd if m.modal == modalNone { m.input, cmd = m.input.Update(msg) + } else if m.modal == modalTriage { + m.triage.note, cmd = m.triage.note.Update(msg) } cmds = append(cmds, cmd) return m, tea.Batch(cmds...) diff --git a/strix/interface/tui/internal/app/triage.go b/strix/interface/tui/internal/app/triage.go new file mode 100644 index 00000000..825ae589 --- /dev/null +++ b/strix/interface/tui/internal/app/triage.go @@ -0,0 +1,453 @@ +package app + +import ( + "encoding/json" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/usestrix/strix/tui/internal/protocol" + "github.com/usestrix/strix/tui/internal/render" +) + +var triageReasons = []struct{ code, label string }{ + {"unspecified", "Select a reason (optional)"}, + {"incorrect_assumption", "Incorrect assumption"}, + {"existing_protection", "Existing protection prevents the exploit"}, + {"not_affected", "Code or dependency is not affected"}, + {"expected_behavior", "Expected behavior, not a vulnerability"}, + {"other", "Other"}, +} + +var triageFields = []string{"status", "triage_status", "resolution_reason", "reason_code", + "status_note", "status_changed_at", "status_changed_by", "triage_revision", "finding_digest", "review_stale", "can_triage"} + +type triageForm struct { + findingID string + revision int64 + digest string + reason int + focus int // reason, note, cancel, submit + note textarea.Model +} + +type triageRequest struct { + findingID string + previous map[string]any + undo bool +} + +type triageUndoState struct { + findingID string + previous map[string]any + revision int64 + digest string + expires time.Time +} + +func boolField(finding map[string]any, key string) bool { + value, _ := finding[key].(bool) + return value +} + +func findingStatus(finding map[string]any) string { + if render.StringValue(finding["status"]) == "closed" { + return "closed" + } + return "open" +} + +func findingStatusLabel(finding map[string]any) string { + if boolField(finding, "review_stale") { + return "Needs review · evidence changed since review" + } + if findingStatus(finding) == "closed" { + return "Closed · False positive" + } + return "Open" +} + +func triageReasonLabel(code string) string { + if code == "" || code == "unspecified" { + return "" + } + for _, reason := range triageReasons { + if reason.code == code { + return reason.label + } + } + return "" +} + +func (m Model) selectedFinding() map[string]any { + if m.selectedVuln < 0 || m.selectedVuln >= len(m.snapshot.Vulnerabilities) { + return nil + } + return m.snapshot.Vulnerabilities[m.selectedVuln] +} + +func (m Model) selectedFindingID() string { return collectionItemID(m.selectedFinding()) } + +func (m Model) findingFilterLabel() string { return []string{"Open", "Closed", "All"}[m.findingFilter] } + +func (m Model) findingVisible(index int) bool { + if index < 0 || index >= len(m.snapshot.Vulnerabilities) { + return false + } + return m.findingFilter == 2 || (findingStatus(m.snapshot.Vulnerabilities[index]) == "closed") == (m.findingFilter == 1) +} + +func (m Model) visibleFindingIndices() []int { + indices := make([]int, 0, len(m.snapshot.Vulnerabilities)) + for i := range m.snapshot.Vulnerabilities { + if m.findingVisible(i) { + indices = append(indices, i) + } + } + return indices +} + +func (m *Model) selectVisibleFinding() { + if m.findingVisible(m.selectedVuln) { + return + } + indices := m.visibleFindingIndices() + if len(indices) > 0 { + m.selectedVuln = indices[0] + } +} + +func (m *Model) restoreFindingSelection(id string) { + for i, finding := range m.snapshot.Vulnerabilities { + if collectionItemID(finding) == id { + m.selectedVuln = i + return + } + } + // Never show a different issue under an already-open review form. + if m.modal == modalTriage { + m.triageError = "This finding is no longer available." + } + m.selectedVuln = min(m.selectedVuln, max(0, len(m.snapshot.Vulnerabilities)-1)) + if m.modal != modalTriage { + m.selectVisibleFinding() + } +} + +func (m *Model) stepVulnerability(direction int) { + indices := m.visibleFindingIndices() + if direction < 0 { + for i := len(indices) - 1; i >= 0; i-- { + if indices[i] < m.selectedVuln { + m.showVulnerability(indices[i]) + return + } + } + } else { + for _, index := range indices { + if index > m.selectedVuln { + m.showVulnerability(index) + return + } + } + } +} + +func (m *Model) openTriageForm() tea.Cmd { + finding := m.selectedFinding() + if m.triagePending != nil || !boolField(finding, "can_triage") || findingStatus(finding) == "closed" { + return nil + } + input := textarea.New() + input.ShowLineNumbers = false + input.CharLimit = 2000 + input.Prompt = "" + input.Placeholder = "Optional context for your future review" + input.SetHeight(4) + reason := 0 + if m.triage.findingID == collectionItemID(finding) { + input.SetValue(m.triage.note.Value()) + reason = m.triage.reason + } + m.triage = triageForm{findingID: collectionItemID(finding), revision: numberValue(finding["triage_revision"]), + digest: render.StringValue(finding["finding_digest"]), note: input, reason: reason} + m.triageError = "" + m.triageErrorID = collectionItemID(finding) + m.modal = modalTriage + m.input.Blur() + m.resizeTriageForm() + return nil +} + +func (m *Model) resizeTriageForm() { + if m.modal != modalTriage { + return + } + m.triage.note.SetWidth(max(12, min(66, m.width-12))) + m.triage.note.SetHeight(max(2, min(4, m.height-18))) +} + +func (m Model) updateTriageForm(key tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.triagePending != nil { + return m, nil + } + switch key.String() { + case "esc": + m.modal = modalVulnerability + m.triage.note.Blur() + m.triageError = "" + m.resizeVulnerabilityViewport() + return m, nil + case "tab", "shift+tab": + delta := 1 + if key.String() == "shift+tab" { + delta = -1 + } + m.triage.focus = clampCycle(m.triage.focus+delta, 4) + if m.triage.focus == 1 { + return m, m.triage.note.Focus() + } + m.triage.note.Blur() + return m, nil + case "left", "up", "right", "down": + if m.triage.focus == 0 { + delta := 1 + if key.String() == "left" || key.String() == "up" { + delta = -1 + } + m.triage.reason = clampCycle(m.triage.reason+delta, len(triageReasons)) + return m, nil + } + case "enter": + switch m.triage.focus { + case 0: + m.triage.focus = 1 + return m, m.triage.note.Focus() + case 2: + return m.updateTriageForm(tea.KeyMsg{Type: tea.KeyEsc}) + case 3: + return m, m.submitTriage("closed", triageReasons[m.triage.reason].code, m.triage.note.Value()) + } + } + if m.triage.focus == 1 { + var cmd tea.Cmd + m.triage.note, cmd = m.triage.note.Update(key) + return m, cmd + } + return m, nil +} + +func (m Model) triageFormView() string { + width := max(20, min(72, m.width-6)) + inner := width - 6 + gap, padding := "\n\n", 1 + uncertain := "Not sure? Keep open for review." + if m.height < 26 { + gap, padding, uncertain = "\n", 0, "Not sure? Keep open." + } + button := func(text string, focus int) string { + style := lipgloss.NewStyle().Foreground(textColor) + if m.triage.focus == focus { + style = style.Bold(true).Background(dark).Foreground(white) + } + return style.Render(" " + text + " ") + } + content := render.Bold(white).Render("Mark as false positive") + "\n" + + wrapBlock("Applies to this finding in this run.", inner) + gap + + "Reason (optional)\n" + button("‹ "+triageReasons[m.triage.reason].label+" ›", 0) + gap + + "Note (optional)\n" + m.triage.note.View() + "\n" + + wrapBlock(uncertain, inner) + gap + + button("Cancel", 2) + " " + button("Close issue", 3) + "\n" + + render.Dim().Render("Tab: next field · Esc: back") + content = wrapBlock(content, inner) + if m.triagePending != nil { + content += "\nSaving…" + } else if m.triageError != "" { + lines := strings.Split(wrapBlock(m.triageError, inner), "\n") + room := max(1, m.height-lipgloss.Height(content)-padding*2-3) + if len(lines) > room { + lines = lines[:room] + lines[room-1] = truncate(lines[room-1], max(1, inner-1)) + "…" + } + content += "\n" + render.Col(red).Render(strings.Join(lines, "\n")) + } + return lipgloss.NewStyle().Width(width-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Background(black).Padding(padding, 2).Render(content) +} + +func (m *Model) submitTriage(status, reason, note string) tea.Cmd { + if m.triagePending != nil || m.client == nil { + return nil + } + finding := m.selectedFinding() + if !boolField(finding, "can_triage") { + return nil + } + id := collectionItemID(finding) + revision := numberValue(finding["triage_revision"]) + digest := render.StringValue(finding["finding_digest"]) + if m.modal == modalTriage { + id, revision, digest = m.triage.findingID, m.triage.revision, m.triage.digest + if id != collectionItemID(finding) { + m.triageError = "This finding changed. Review it again." + return nil + } + } + previous := make(map[string]any, len(finding)) + for key, value := range finding { + previous[key] = value + } + m.triagePending = &triageRequest{findingID: id, previous: previous} + m.triageError = "" + m.triageErrorID = id + return send(m.client, "vulnerability.triage", map[string]any{"finding_id": id, "status": status, + "resolution_reason": "false_positive", "expected_revision": revision, "reviewed_digest": digest, + "reason_code": reason, "note": note}) +} + +func (m Model) canUndoTriage() bool { + return m.triageUndo != nil && time.Now().Before(m.triageUndo.expires) && m.selectedFindingID() == m.triageUndo.findingID && + numberValue(m.selectedFinding()["triage_revision"]) == m.triageUndo.revision && + render.StringValue(m.selectedFinding()["finding_digest"]) == m.triageUndo.digest +} + +func (m *Model) undoTriage() tea.Cmd { + if !m.canUndoTriage() || m.triagePending != nil { + return nil + } + previous := m.triageUndo.previous + cmd := m.submitTriage(findingStatus(previous), normalizeTriageReason(render.StringValue(previous["reason_code"])), render.StringValue(previous["status_note"])) + if m.triagePending != nil { + m.triagePending.undo = true + } + return cmd +} + +func (m *Model) handleTriageResult(result protocol.CommandResult) tea.Cmd { + pending := m.triagePending + if pending == nil { + return nil + } + m.triagePending = nil + m.triageErrorID = pending.findingID + if !result.OK { + m.triageError = "Could not save this decision." + if result.Error != nil { + m.triageError = result.Error.Message + } + if result.Error != nil && result.Error.Code == "conflict" { + m.triageError += " Press Esc and review the updated finding before retrying." + } + return m.collectionMismatch("vulnerabilities") + } + var data struct { + Changed bool `json:"changed"` + Finding map[string]any `json:"finding"` + } + if err := json.Unmarshal(result.Result, &data); err != nil || collectionItemID(data.Finding) != pending.findingID { + m.triageError = "Save outcome unknown. Refreshing the finding before another decision." + return m.collectionMismatch("vulnerabilities") + } + for i, finding := range m.snapshot.Vulnerabilities { + if collectionItemID(finding) == pending.findingID { + updated := make(map[string]any, len(finding)) + for key, value := range finding { + updated[key] = value + } + if numberValue(data.Finding["triage_revision"]) >= numberValue(finding["triage_revision"]) { + for key, value := range data.Finding { + updated[key] = value + } + // A command acknowledges a decision, not a new evidence snapshot. + // Preserve evidence that arrived while the save was in flight. + preserveCurrentEvidence(finding, updated) + } + m.snapshot.Vulnerabilities[i] = updated + } + } + m.triageError = "" + if m.modal == modalTriage && m.triage.findingID == pending.findingID { + m.modal = modalVulnerability + m.triage.note.Blur() + } + if m.triage.findingID == pending.findingID { + m.triage.findingID = "" + } + if data.Changed && !pending.undo { + m.triageUndo = &triageUndoState{findingID: pending.findingID, previous: pending.previous, + revision: numberValue(data.Finding["triage_revision"]), digest: render.StringValue(data.Finding["finding_digest"]), expires: time.Now().Add(15 * time.Second)} + } else if pending.undo { + m.triageUndo = nil + } + m.resizeViewport() + m.resizeVulnerabilityViewport() + return nil +} + +// A queued collection frame predating the save cannot roll its acknowledgement back. +func keepNewerTriage(current, incoming map[string]any) map[string]any { + if render.StringValue(incoming["triage_error"]) != "" { + return incoming + } + if numberValue(current["triage_revision"]) > numberValue(incoming["triage_revision"]) { + evidence := map[string]any{"finding_digest": incoming["finding_digest"]} + for _, key := range triageFields { + incoming[key] = current[key] + } + preserveCurrentEvidence(evidence, incoming) + } + return incoming +} + +func preserveCurrentEvidence(evidence, decision map[string]any) { + digest := render.StringValue(evidence["finding_digest"]) + if digest != "" && digest != render.StringValue(decision["finding_digest"]) { + decision["finding_digest"] = digest + if render.StringValue(decision["triage_status"]) == "closed" { + decision["status"], decision["review_stale"] = "open", true + } + } +} + +func (m *Model) preserveTriageUpdates(incoming []map[string]any) { + current := map[string]map[string]any{} + for _, finding := range m.snapshot.Vulnerabilities { + current[collectionItemID(finding)] = finding + } + for _, finding := range incoming { + keepNewerTriage(current[collectionItemID(finding)], finding) + } +} + +func (m Model) updateTriageMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress || m.triagePending != nil { + return m, nil + } + view := m.triageFormView() + if m.centeredLabelHit(view, "Close issue", msg.X, msg.Y) { + m.triage.focus = 3 + return m.updateTriageForm(tea.KeyMsg{Type: tea.KeyEnter}) + } + if m.centeredLabelHit(view, "Cancel", msg.X, msg.Y) { + return m.updateTriageForm(tea.KeyMsg{Type: tea.KeyEsc}) + } + if m.centeredLabelHit(view, triageReasons[m.triage.reason].label, msg.X, msg.Y) { + m.triage.focus = 0 + m.triage.reason = (m.triage.reason + 1) % len(triageReasons) + m.triage.note.Blur() + } + if m.centeredLabelHit(view, "Note (optional)", msg.X, msg.Y) { + m.triage.focus = 1 + return m, m.triage.note.Focus() + } + return m, nil +} + +// Legacy decisions can omit the optional category. +func normalizeTriageReason(reason string) string { + if strings.TrimSpace(reason) == "" { + return "unspecified" + } + return reason +} diff --git a/strix/interface/tui/internal/app/triage_test.go b/strix/interface/tui/internal/app/triage_test.go new file mode 100644 index 00000000..67981762 --- /dev/null +++ b/strix/interface/tui/internal/app/triage_test.go @@ -0,0 +1,231 @@ +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/usestrix/strix/tui/internal/protocol" +) + +func triageModel(t *testing.T) Model { + t.Helper() + m := reportModel(t, 2) + m.client = newClient(&recordingConn{}) + for _, finding := range m.snapshot.Vulnerabilities { + finding["status"] = "open" + finding["triage_revision"] = 0 + finding["finding_digest"] = strings.Repeat("a", 64) + finding["can_triage"] = true + } + return m +} + +func triageKey(m Model, key tea.KeyMsg) Model { + updated, _ := m.updateModal(key) + return updated.(Model) +} + +func TestNativeTriageFormKeepsLettersAndComposerDraft(t *testing.T) { + m := triageModel(t) + m.input.SetValue("unfinished scan instruction") + m = triageKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + if m.modal != modalTriage { + t.Fatal("f did not open a native form") + } + view := ansi.Strip(m.triageFormView()) + for _, label := range []string{"Mark as false positive", "Reason (optional)", "Note (optional)", "Close issue", "Cancel"} { + if !strings.Contains(view, label) { + t.Fatalf("form lacks %q", label) + } + } + if strings.Contains(strings.ToLower(view), "telemetry") { + t.Fatal("form contains telemetry copy") + } + m = triageKey(m, tea.KeyMsg{Type: tea.KeyTab}) + for _, letter := range "frv" { + m = triageKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{letter}}) + } + if m.triage.note.Value() != "frv" || m.triagePending != nil { + t.Fatal("note typing triggered a command") + } + m = triageKey(m, tea.KeyMsg{Type: tea.KeyEsc}) + if m.modal != modalVulnerability || m.input.Value() != "unfinished scan instruction" { + t.Fatal("cancel lost detail or composer draft") + } +} + +func TestNativeTriageRequestUsesOriginallyReviewedDigest(t *testing.T) { + m := triageModel(t) + connection := &recordingConn{} + m.client = newClient(connection) + m.openTriageForm() + m.triage.reason = 1 + m.triage.note.SetValue("The claim is incorrect") + m.selectedFinding()["finding_digest"] = strings.Repeat("b", 64) + m.triage.focus = 3 + updated, cmd := m.updateModal(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if cmd == nil { + t.Fatal("close did not send a command") + } + message := cmd().(sentMsg) + if message.err != nil || message.command != "vulnerability.triage" { + t.Fatalf("wrong command: %#v", message) + } + frame, err := readEnvelopeFrame(bytes.NewReader(connection.Bytes())) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(frame.Payload, &payload); err != nil { + t.Fatal(err) + } + if payload["reviewed_digest"] != strings.Repeat("a", 64) || payload["finding_id"] != "a" || payload["status"] != "closed" || payload["note"] != "The claim is incorrect" { + t.Fatalf("review request changed: %#v", payload) + } + if retry := m.submitTriage("closed", "unspecified", ""); retry != nil { + t.Fatal("sent duplicate pending review") + } + result := protocol.CommandResult{OK: false, Command: "vulnerability.triage", Error: &protocol.CommandError{Code: "conflict", Message: "Evidence changed"}} + m.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "command_result", RequestID: message.requestID, Payload: rawJSON(t, result)}) + if m.modal != modalTriage || m.triage.note.Value() != "The claim is incorrect" || !strings.Contains(m.triageError, "review") { + t.Fatal("conflict lost the editable form") + } + m = triageKey(m, tea.KeyMsg{Type: tea.KeyEsc}) + m.openTriageForm() + if m.triage.note.Value() != "The claim is incorrect" || m.triage.digest != strings.Repeat("b", 64) { + t.Fatal("fresh review lost the failed submission draft") + } +} + +func TestSavedTriagePinsDetailOffersUndoAndFiltersClosedFinding(t *testing.T) { + m := triageModel(t) + m.openTriageForm() + m.submitTriage("closed", "incorrect_assumption", "note") + closed := map[string]any{"id": "a", "status": "closed", "triage_status": "closed", "resolution_reason": "false_positive", "reason_code": "incorrect_assumption", "status_note": "note", "triage_revision": 1, "finding_digest": strings.Repeat("a", 64), "can_triage": true} + m.handleTriageResult(protocol.CommandResult{OK: true, Command: "vulnerability.triage", Result: rawJSON(t, map[string]any{"changed": true, "finding": closed})}) + if m.modal != modalVulnerability || m.selectedFindingID() != "a" || !m.canUndoTriage() { + t.Fatal("saved closure lost the detail or undo") + } + if rows := m.vulnerabilityRows(60); len(rows) != 1 || rows[0].index != 1 { + t.Fatalf("closed finding remained active: %#v", rows) + } + if !strings.Contains(ansi.Strip(m.vulnerabilityDetail()), "Reopen (r)") { + t.Fatal("native reopen is missing") + } + if !strings.Contains(vulnerabilityMarkdownReport(m.selectedFinding()), "Closed · False positive") { + t.Fatal("copy lacks triage") + } + if cmd := m.undoTriage(); cmd == nil || m.triagePending == nil || !m.triagePending.undo { + t.Fatal("undo unavailable") + } + // A saved reopen is active again and consumes the brief undo action. + closed["status"], closed["triage_revision"] = "open", 2 + m.handleTriageResult(protocol.CommandResult{OK: true, Command: "vulnerability.triage", Result: rawJSON(t, map[string]any{"changed": true, "finding": closed})}) + if len(m.visibleFindingIndices()) != 2 || m.triageUndo != nil { + t.Fatal("undo did not restore active finding") + } +} + +func TestNativeClosedFilterIsReachableWithoutViewer(t *testing.T) { + m := triageModel(t) + m.snapshot.Vulnerabilities[0]["status"] = "closed" + m.closeModal() + m.focus = focusVulnerabilities + updated, cmd := m.updateMain(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}}) + m = updated.(Model) + if cmd != nil || m.findingFilterLabel() != "Closed" || m.selectedFindingID() != "a" { + t.Fatal("v did not select the closed view locally") + } + updated, _ = m.updateMain(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + updated, cmd = m.updateModal(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + m = updated.(Model) + if cmd == nil || m.triagePending == nil { + t.Fatal("r did not submit native reopen") + } +} + +func TestCollectionRefreshPreservesReviewSelectionAndNewerAcknowledgement(t *testing.T) { + m := triageModel(t) + m.selectedVuln = 1 + m.openTriageForm() + m.snapshot.Vulnerabilities[1]["triage_revision"] = 2 + m.snapshot.Vulnerabilities[1]["status"] = "closed" + items := []json.RawMessage{rawJSON(t, map[string]any{"id": "b", "title": "Updated", "status": "open", "triage_revision": 1}), rawJSON(t, m.snapshot.Vulnerabilities[0])} + m.handleCollectionBootstrap(rawJSON(t, protocol.CollectionBootstrap{Collection: "vulnerabilities", Revision: 2, Cursor: 0, NextCursor: 2, Done: true, Items: items})) + if m.selectedFindingID() != "b" || m.triage.findingID != "b" || m.selectedVuln != 0 { + t.Fatal("reorder changed the finding being reviewed") + } + if findingStatus(m.selectedFinding()) != "closed" || numberValue(m.selectedFinding()["triage_revision"]) != 2 { + t.Fatal("old queued projection overwrote saved decision") + } +} + +func TestStaleClosureRemainsActiveAndCanBeReviewedAgain(t *testing.T) { + m := triageModel(t) + finding := m.selectedFinding() + finding["triage_status"], finding["status"], finding["review_stale"] = "closed", "open", true + if !m.findingVisible(0) || !strings.Contains(findingStatusLabel(finding), "Needs review") { + t.Fatal("stale review remained suppressed") + } + m.openTriageForm() + if m.modal != modalTriage { + t.Fatal("fresh review unavailable") + } +} + +func TestNativeTriageFitsSmallTerminal(t *testing.T) { + for _, size := range [][2]int{{130, 30}, {80, 24}, {60, 22}, {40, 18}} { + m := triageModel(t) + m.width, m.height = size[0], size[1] + m.openTriageForm() + m.triage.reason = 2 + m.triageError = "Finding evidence changed. Reload and review it again. Press Esc and review the updated finding before retrying." + view := m.triageFormView() + if lipgloss.Width(view) > m.width || lipgloss.Height(view) > m.height { + t.Errorf("%dx%d form is %dx%d:\n%s", m.width, m.height, lipgloss.Width(view), lipgloss.Height(view), ansi.Strip(view)) + } + } +} + +func TestF2OpensClosedOnlyFindingsOnNarrowTerminal(t *testing.T) { + m := triageModel(t) + m.width, m.height = 80, 24 + for _, finding := range m.snapshot.Vulnerabilities { + finding["status"] = "closed" + } + m.closeModal() + updated, cmd := m.updateMain(tea.KeyMsg{Type: tea.KeyF2}) + m = updated.(Model) + if cmd != nil || m.modal != modalVulnerability || m.findingFilterLabel() != "All" { + t.Fatal("F2 did not reach closed findings without a sidebar") + } + if !strings.Contains(ansi.Strip(m.statusView(80)), "F2 findings") { + t.Fatal("entry point is not discoverable") + } +} + +func TestSaveAcknowledgementCannotDismissEvidenceArrivingInFlight(t *testing.T) { + m := triageModel(t) + m.openTriageForm() + m.submitTriage("closed", "unspecified", "") + finding := m.selectedFinding() + finding["finding_digest"] = strings.Repeat("b", 64) + finding["evidence"] = "Changed after submission" + finding["triage_revision"] = 1 + finding["review_stale"] = true + m.handleTriageResult(protocol.CommandResult{OK: true, Command: "vulnerability.triage", Result: rawJSON(t, map[string]any{"changed": true, "finding": map[string]any{ + "id": "a", "status": "closed", "triage_status": "closed", "triage_revision": 1, "finding_digest": strings.Repeat("a", 64), "review_stale": false}})}) + if findingStatus(m.selectedFinding()) != "open" || !boolField(m.selectedFinding(), "review_stale") || m.selectedFinding()["finding_digest"] != strings.Repeat("b", 64) { + t.Fatal("save hid evidence the user never reviewed") + } + if m.canUndoTriage() { + t.Fatal("undo should be unavailable after evidence changed") + } +} diff --git a/strix/interface/tui/internal/app/update.go b/strix/interface/tui/internal/app/update.go index 3b962495..f7e2a928 100644 --- a/strix/interface/tui/internal/app/update.go +++ b/strix/interface/tui/internal/app/update.go @@ -10,9 +10,26 @@ import ( func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { switch key.String() { + case "v": + if m.focus == focusVulnerabilities { + m.findingFilter = (m.findingFilter + 1) % 3 + m.selectVisibleFinding() + m.vulnOffset = 0 + m.resizeViewport() + return m, nil + } case "f1": m.openModal(modalHelp) return m, nil + case "f2": + if len(m.snapshot.Vulnerabilities) > 0 { + if len(m.visibleFindingIndices()) == 0 { + m.findingFilter = 2 + } + m.selectVisibleFinding() + m.openModal(modalVulnerability) + } + return m, nil case "ctrl+c", "ctrl+q": // Nothing to lose on the start screen; quit without confirmation. if m.snapshot.SetupMode { @@ -70,6 +87,9 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter", " ": if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 { if key.String() == "enter" { + if !m.findingVisible(m.selectedVuln) { + return m, nil + } m.openModal(modalVulnerability) return m, nil } @@ -128,12 +148,16 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { case "home": if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 { m.selectedVuln = 0 + m.selectVisibleFinding() m.ensureVulnerabilityVisible() return m, nil } case "end": if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 { m.selectedVuln = len(m.snapshot.Vulnerabilities) - 1 + if indices := m.visibleFindingIndices(); len(indices) > 0 { + m.selectedVuln = indices[len(indices)-1] + } m.ensureVulnerabilityVisible() return m, nil } @@ -466,9 +490,15 @@ func (m Model) updateSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m Model) pressReportButton(button string) (tea.Model, tea.Cmd) { switch button { case reportPrev: - m.showVulnerability(m.selectedVuln - 1) + m.stepVulnerability(-1) case reportNext: - m.showVulnerability(m.selectedVuln + 1) + m.stepVulnerability(1) + case reportTriage: + return m, m.openTriageForm() + case reportReopen: + return m, m.submitTriage("open", "unspecified", "") + case reportUndo: + return m, m.undoTriage() case reportCopy: m.reportFocus = reportCopy return m, m.startVulnerabilityCopy() @@ -479,6 +509,9 @@ func (m Model) pressReportButton(button string) (tea.Model, tea.Cmd) { } func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.modal == modalTriage { + return m.updateTriageMouse(msg) + } if m.modal == modalVulnerability { view := m.modalView() left, top, _, _ := m.centeredViewBounds(view) @@ -610,6 +643,9 @@ func clampCycle(value, length int) int { } func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.modal == modalTriage { + return m.updateTriageForm(key) + } if m.modal == modalHelp { if key.String() != "" { m.closeModal() @@ -622,9 +658,23 @@ func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) { m.closeModal() // The arrows step between reports directly; tab walks the button row. case "left": - m.showVulnerability(m.selectedVuln - 1) + m.stepVulnerability(-1) case "right": - m.showVulnerability(m.selectedVuln + 1) + m.stepVulnerability(1) + case "f": + return m, m.openTriageForm() + case "v": + m.findingFilter = (m.findingFilter + 1) % 3 + m.selectVisibleFinding() + m.vulnOffset = 0 + m.resizeVulnerabilityViewport() + return m, nil + case "r": + if findingStatus(m.selectedFinding()) == "closed" { + return m, m.submitTriage("open", "unspecified", "") + } + case "u": + return m, m.undoTriage() case "tab": m.stepReportFocus(1) case "shift+tab": @@ -706,6 +756,7 @@ func (m *Model) openModal(mode modalMode) { func (m *Model) closeModal() { m.modal = modalNone + m.selectVisibleFinding() if m.focus == focusInput { m.input.Focus() } diff --git a/strix/interface/tui/internal/app/view.go b/strix/interface/tui/internal/app/view.go index 8588df18..287623d0 100644 --- a/strix/interface/tui/internal/app/view.go +++ b/strix/interface/tui/internal/app/view.go @@ -266,7 +266,7 @@ func (m Model) viewInner() string { } else if m.modal != modalNone { // Only the vulnerability detail dims its backdrop (#000000 80%); Help, // Quit and Stop are transparent. - main = m.overlay(main, m.modalView(), m.modal == modalVulnerability) + main = m.overlay(main, m.modalView(), m.modal == modalVulnerability || m.modal == modalTriage) } return m.toastOverlay(main) } @@ -569,7 +569,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.viewerContentWidth()).Render(m.statsView())) statsHeight = min(15, statsRows+2) if len(m.snapshot.Vulnerabilities) > 0 { - vulnHeight = min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+2) + vulnHeight = min(12, max(3, len(m.vulnerabilityRows(m.vulnerabilityListWidth())))+2) } // One header line + one line per connection + the box border (2). Capped so a // long roster cannot crowd out the agent tree; a roster past the cap scrolls @@ -614,6 +614,15 @@ func (m Model) viewerView(width int) string { func (m Model) statsView() string { w := lipgloss.NewStyle().Foreground(white) var b strings.Builder + if total := len(m.snapshot.Vulnerabilities); total > 0 { + closed := 0 + for _, finding := range m.snapshot.Vulnerabilities { + if findingStatus(finding) == "closed" { + closed++ + } + } + b.WriteString(w.Render(fmt.Sprintf("%d open · %d false positives\n%d found · %s (v)\n", total-closed, closed, total, m.findingFilterLabel()))) + } if model := m.snapshot.Model; model != "" { b.WriteString(w.Render(model)) } @@ -826,6 +835,14 @@ func (m Model) statusView(width int) string { if m.errorText != "" { left = statusMessage(m.errorText, red, "", width-lipgloss.Width(right)) } + if len(m.snapshot.Vulnerabilities) > 0 { + hint := lipgloss.NewStyle().Foreground(textColor).Render("F2 findings") + if right == "" { + right = hint + } else { + right = hint + " · " + right + } + } return composeStatusRow(left, right, width) } diff --git a/strix/interface/tui/internal/app/vuln_report.go b/strix/interface/tui/internal/app/vuln_report.go index 2f460926..538fd036 100644 --- a/strix/interface/tui/internal/app/vuln_report.go +++ b/strix/interface/tui/internal/app/vuln_report.go @@ -65,6 +65,9 @@ func vulnerabilityMarkdownReport(v map[string]any) string { } } field("ID", render.StringValue(v["id"])) + field("Status", findingStatusLabel(v)) + field("Reviewed", render.StringValue(v["status_changed_at"])) + field("Review reason", triageReasonLabel(render.StringValue(v["reason_code"]))) field("Severity", strings.ToUpper(render.StringValue(v["severity"]))) field("Found", render.StringValue(v["timestamp"])) field("Agent", render.StringValue(v["agent_name"])) diff --git a/strix/interface/tui/internal/app/vulnerabilities.go b/strix/interface/tui/internal/app/vulnerabilities.go index 6a59a5aa..838a7341 100644 --- a/strix/interface/tui/internal/app/vulnerabilities.go +++ b/strix/interface/tui/internal/app/vulnerabilities.go @@ -28,7 +28,7 @@ func (m Model) vulnerabilityRows(width int) []vulnerabilityRow { // Wrapped lines sit under the title rather than under the severity dot. body := max(1, width-2) rows := make([]vulnerabilityRow, 0, len(m.snapshot.Vulnerabilities)) - for i := range m.snapshot.Vulnerabilities { + for _, i := range m.visibleFindingIndices() { for line, text := range strings.Split(wrapBlock(m.vulnerabilityTitle(i), body), "\n") { rows = append(rows, vulnerabilityRow{index: i, text: text, first: line == 0}) } @@ -38,6 +38,9 @@ func (m Model) vulnerabilityRows(width int) []vulnerabilityRow { func (m Model) vulnerabilitiesView(width, height int) string { rows := m.vulnerabilityRows(width) + if len(rows) == 0 { + return wrapBlock("No "+strings.ToLower(m.findingFilterLabel())+" findings.\nv: Open / Closed / All", width) + } start := min(max(0, m.vulnOffset), max(0, len(rows)-1)) end := min(len(rows), start+height) lines := make([]string, 0, max(0, end-start)) @@ -78,6 +81,12 @@ func (m Model) vulnerabilityTitle(index int) string { if title == "" { title = "Unknown Vulnerability" } + if boolField(m.snapshot.Vulnerabilities[index], "review_stale") { + return "[Needs review] " + title + } + if findingStatus(m.snapshot.Vulnerabilities[index]) == "closed" { + return "[False positive] " + title + } return title } @@ -157,7 +166,18 @@ func (m Model) vulnerabilityPageItems() int { } func (m *Model) moveVulnerabilitySelection(delta int) { - m.selectedVuln = max(0, min(len(m.snapshot.Vulnerabilities)-1, m.selectedVuln+delta)) + indices := m.visibleFindingIndices() + if len(indices) == 0 { + return + } + position := 0 + for i, index := range indices { + if index == m.selectedVuln { + position = i + break + } + } + m.selectedVuln = indices[max(0, min(len(indices)-1, position+delta))] } // keepVulnerabilitySelectionInWindow pulls the selection to the nearest finding @@ -192,7 +212,7 @@ func (m Model) modalView() string { switch m.modal { case modalHelp: title := lipgloss.NewStyle().Bold(true).Foreground(green).Width(34).Align(lipgloss.Center).Render("Strix Help") - body := lipgloss.NewStyle().Foreground(textColor).Render("F1 Help\nCtrl+O Open viewer\nCtrl+Q/C Quit\nESC Stop Agent\nEnter Send / expand node\nCtrl+J Newline in message\nTab Switch panels\n↑/↓ Navigate tree\nDrag Select & copy text\nClick Expand/collapse tool") + body := lipgloss.NewStyle().Foreground(textColor).Render("F1 Help\nF2 Findings\nCtrl+O Open viewer\nCtrl+Q/C Quit\nESC Stop Agent\nEnter Send / expand node\nCtrl+J Newline in message\nTab Switch panels\n↑/↓ Navigate tree\nf / r / u Review / reopen / undo (finding)\nv Open / Closed / All (findings)\nDrag Select & copy text\nClick Expand/collapse tool") content := title + "\n\n" + body return lipgloss.NewStyle().Width(38).Border(lipgloss.RoundedBorder()).BorderForeground(green).Background(black).Padding(1, 2).Render(content) case modalQuit: @@ -212,6 +232,8 @@ func (m Model) modalView() string { return "" } return m.vulnerabilityDetail() + case modalTriage: + return m.triageFormView() } return "" } @@ -328,6 +350,14 @@ func vulnerabilityBody(v map[string]any) string { } } field("Agent", render.StringValue(v["agent_name"])) + field("Status", findingStatusLabel(v)) + field("Reviewed", render.StringValue(v["status_changed_at"])) + field("Reason", triageReasonLabel(render.StringValue(v["reason_code"]))) + field("Note", render.StringValue(v["status_note"])) + field("Review unavailable", render.StringValue(v["triage_error"])) + if allowed, present := v["can_triage"]; present && allowed == false && v["triage_error"] == nil { + field("Review", "Read-only finding; decisions cannot be changed.") + } field("Title", render.StringValue(v["title"])) if sev := render.StringValue(v["severity"]); sev != "" { b.WriteString("\n\n" + fieldStyle.Render("Severity: ") + @@ -389,8 +419,9 @@ func (m *Model) resizeVulnerabilityViewport() { width, height := m.vulnerabilityDialogSize() innerWidth := max(1, width-8) // border plus three cells of horizontal padding m.vulnViewport.Width = max(1, innerWidth-2) // right padding and one-cell scrollbar - m.vulnViewport.Height = max(1, height-9) // padding, one-row grid gutter, and two-row footer - m.vulnViewport.SetContent(wrapBlock(vulnerabilityBody(m.snapshot.Vulnerabilities[m.selectedVuln]), m.vulnViewport.Width)) + m.vulnViewport.Height = max(1, height-11) // action row, navigation and mutation outcome + body := vulnerabilityBody(m.snapshot.Vulnerabilities[m.selectedVuln]) + m.vulnViewport.SetContent(wrapBlock(body, m.vulnViewport.Width)) m.vulnViewport.SetYOffset(m.vulnViewport.YOffset) } @@ -430,13 +461,21 @@ func (m Model) vulnerabilityDetail() string { } // Stepping sits on the left behind the position, acting on the right. right := strings.Join(acting, " ") - left := strings.Join(stepping, " ") + left := m.findingFilterLabel() + " (v) " + strings.Join(stepping, " ") if total := len(m.snapshot.Vulnerabilities); total > 1 { left = render.Dim().Render(fmt.Sprintf("%d/%d", m.selectedVuln+1, total)) + " " + left } - room := max(0, inner-lipgloss.Width(right)) - buttonRow := rule + "\n" + - lipgloss.NewStyle().Width(room).Render(truncate(left, room)) + right + buttonRow := rule + "\n" + truncate(left, inner) + "\n" + wrapBlock(right, inner) + outcome := "" + if m.triageErrorID == m.selectedFindingID() { + outcome = m.triageError + } + if m.triagePending != nil && m.triagePending.findingID == m.selectedFindingID() { + outcome = "Saving…" + } + if outcome != "" { + buttonRow += "\n" + truncate(outcome, inner) + } content := m.vulnerabilityScrollView() + "\n" + buttonRow return lipgloss.NewStyle().Width(width-2).Height(height-2).Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#262626")).Background(lipgloss.Color("#0a0a0a")).Padding(2, 3).Render(content) } @@ -459,10 +498,13 @@ func (m *Model) showVulnerability(index int) { // The report's buttons. Prev and Next carry their arrows so a click test cannot // be fooled by the same word appearing in the body of a finding. const ( - reportPrev = "‹ Prev" - reportNext = "Next ›" - reportCopy = "Copy" - reportDone = "Done" + reportPrev = "‹ Prev" + reportNext = "Next ›" + reportCopy = "Copy" + reportDone = "Done" + reportTriage = "False positive (f)" + reportReopen = "Reopen (r)" + reportUndo = "Undo (u)" ) // reportButtons is the row as it stands, left to right. Stepping is offered only @@ -476,6 +518,16 @@ func (m Model) reportButtons() []string { if next { buttons = append(buttons, reportNext) } + if boolField(m.selectedFinding(), "can_triage") { + if findingStatus(m.selectedFinding()) == "closed" { + buttons = append(buttons, reportReopen) + } else { + buttons = append(buttons, reportTriage) + } + } + if m.canUndoTriage() { + buttons = append(buttons, reportUndo) + } return append(buttons, reportCopy, reportDone) } @@ -507,7 +559,11 @@ func (m *Model) stepReportFocus(delta int) { // ends are not wrapped: a report is one of an ordered list, and rolling from the // last to the first hides that you reached the end. func (m Model) vulnerabilityNeighbors() (previous, next bool) { - return m.selectedVuln > 0, m.selectedVuln < len(m.snapshot.Vulnerabilities)-1 + for _, index := range m.visibleFindingIndices() { + previous = previous || index < m.selectedVuln + next = next || index > m.selectedVuln + } + return } // reportButton renders one button of the report row. Copy reports the outcome of diff --git a/strix/interface/tui/internal/app/wire.go b/strix/interface/tui/internal/app/wire.go index 1536110b..e13f80d2 100644 --- a/strix/interface/tui/internal/app/wire.go +++ b/strix/interface/tui/internal/app/wire.go @@ -76,6 +76,9 @@ func (m *Model) handleEnvelope(envelope protocol.Envelope) tea.Cmd { if result.Command != expectedCommand || !m.client.Resolve(envelope.RequestID, result.Command) { return nil } + if result.Command == "vulnerability.triage" { + return m.handleTriageResult(result) + } if !result.OK { if result.Command == "collection.resync" { if collection := m.resyncRequests[envelope.RequestID]; collection != "" { @@ -240,7 +243,10 @@ func (m *Model) handleCollectionBootstrap(payload json.RawMessage) tea.Cmd { } else if chunk.Collection == "events" { m.snapshot.Events = assembly.events } else { + selectedID := m.selectedFindingID() + m.preserveTriageUpdates(assembly.findings) m.snapshot.Vulnerabilities = assembly.findings + m.restoreFindingSelection(selectedID) } m.collectionRevisions[chunk.Collection] = chunk.Revision delete(m.collectionAssemblies, chunk.Collection) @@ -379,6 +385,7 @@ func (m *Model) applyCollectionOperations(name string, operations []protocol.Col return true } + selectedID := m.selectedFindingID() values := append([]map[string]any(nil), m.snapshot.Vulnerabilities...) positions := make(map[string]int, len(values)) for index, finding := range values { @@ -417,13 +424,14 @@ func (m *Model) applyCollectionOperations(name string, operations []protocol.Col } seen[id] = true if index, exists := positions[id]; exists { - values[index] = finding + values[index] = keepNewerTriage(values[index], finding) } else { positions[id] = len(values) values = append(values, finding) } } m.snapshot.Vulnerabilities = values + m.restoreFindingSelection(selectedID) return true } @@ -443,6 +451,9 @@ func (m *Model) refreshAfterCollection(name string) tea.Cmd { return nil } m.selectedVuln = min(m.selectedVuln, max(0, len(m.snapshot.Vulnerabilities)-1)) + if m.modal == modalNone { + m.selectVisibleFinding() + } m.ensureVulnerabilityVisible() m.resizeVulnerabilityViewport() return nil diff --git a/strix/interface/viewer/frontend/src/App.tsx b/strix/interface/viewer/frontend/src/App.tsx index bbd24f4c..87ac3a3f 100644 --- a/strix/interface/viewer/frontend/src/App.tsx +++ b/strix/interface/viewer/frontend/src/App.tsx @@ -26,10 +26,12 @@ import { fetchAll, fetchAuthStatus, fetchCapabilities, - fetchRunSummary, fetchRuns, - fetchTranscript, fetchVulnerabilities, + fetchTriageRevision, + updateFindingTriage, + TriageRequestError, + type TriageUpdate, forgetAuth, parseMcpConnectionStatus, type AuthStatus, @@ -67,6 +69,13 @@ export default function App() { // 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 [issueFilter, setIssueFilter] = useState<"open" | "closed" | "all">("open"); + const activeRunRef = useRef(activeRun); + activeRunRef.current = activeRun; + const dataVersionRef = useRef(0); + const mutationsPendingRef = useRef(0); + const uncertainWritesRef = useRef(new Set()); + const refreshCurrentRef = useRef<() => void>(() => {}); const refreshAuth = useCallback(async () => { try { @@ -95,77 +104,110 @@ export default function App() { }); }, [refreshAuth, refreshRuns]); - // Live polling, scoped to the active run. Re-runs when the active run changes - // so switching to a past run (?run=) reloads its data; a finished run - // does a single full fetch and stops. - const finishedRef = useRef(false); + // Finished runs only poll a small file revision token. Live scans, external + // triage edits and focus changes refresh the same authoritative projection. useEffect(() => { let cancelled = false; let timer: ReturnType | undefined; - finishedRef.current = false; - - const schedule = () => { - timer = setTimeout(tick, POLL_MS); - }; + let finished = false; + let lastStamp = ""; + let forceRefresh = true; + let busy = false; + dataVersionRef.current += 1; const tick = async () => { - if (cancelled) return; + if (cancelled || busy) return; + if (timer) clearTimeout(timer); + busy = true; + const version = dataVersionRef.current; 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(); + if (mutationsPendingRef.current) return; + const stamp = await fetchTriageRevision(activeRun); + if (finished && stamp === lastStamp && !forceRefresh) return; + const full = await fetchAll(activeRun); + if (cancelled || version !== dataVersionRef.current || mutationsPendingRef.current) return; + finished = full.finished; + if (finished && lastStamp && stamp !== lastStamp) void refreshRuns(); + lastStamp = stamp; + forceRefresh = false; + setRun(full); + setError(null); } catch (e) { if (cancelled) return; setError(e instanceof Error ? e.message : "Could not load run data."); - schedule(); + } finally { + busy = false; + if (!cancelled) timer = setTimeout(tick, finished ? 1500 : POLL_MS); } }; - - (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(); - } - })(); - + const refresh = () => { forceRefresh = true; void tick(); }; + refreshCurrentRef.current = refresh; + window.addEventListener("focus", refresh); + void tick(); return () => { cancelled = true; if (timer) clearTimeout(timer); + window.removeEventListener("focus", refresh); }; - }, [activeRun]); + }, [activeRun, refreshRuns]); + const saveTriage = useCallback(async (finding: Vulnerability, update: TriageUpdate) => { + const requestedRun = activeRun; + const requestKey = JSON.stringify([requestedRun, finding.id]); + dataVersionRef.current += 1; + mutationsPendingRef.current += 1; + const applyFindings = (findings: Vulnerability[]) => { + if (activeRunRef.current === requestedRun) { + setRun((previous) => previous ? { ...previous, vulnerabilities: findings } : previous); + } + }; + try { + if (uncertainWritesRef.current.has(requestKey)) { + const latest = await fetchVulnerabilities(finding.scan_id, requestedRun); + applyFindings(latest); + uncertainWritesRef.current.delete(requestKey); + const current = latest.find((item) => item.id === finding.id); + if (!current || current.triage_revision !== finding.triage_revision || current.finding_digest !== finding.finding_digest) { + throw new TriageRequestError("conflict", "The saved finding changed. Review its current state before trying again."); + } + } + const saved = await updateFindingTriage(finding, update, requestedRun); + if (activeRunRef.current === requestedRun) { + setRun((previous) => previous ? { + ...previous, + vulnerabilities: previous.vulnerabilities.map((item) => item.id === saved.id ? saved : item), + } : previous); + } + void refreshRuns(); + return saved; + } catch (error) { + if (!(error instanceof TriageRequestError)) uncertainWritesRef.current.add(requestKey); + // A lost response can follow a successful write. Read the saved state + // before allowing another attempt, without automatically repeating it. + try { + const latest = await fetchVulnerabilities(finding.scan_id, requestedRun); + applyFindings(latest); + uncertainWritesRef.current.delete(requestKey); + const saved = latest.find((item) => item.id === finding.id); + if (!(error instanceof TriageRequestError) && saved && + saved.triage_revision === (finding.triage_revision ?? 0) + 1 && + saved.finding_digest === finding.finding_digest && saved.status === update.status && + (update.status === "open" || (saved.reason_code === (update.reason_code ?? "unspecified") && + (saved.status_note ?? "") === (update.note ?? "").trim()))) return saved; + } catch { /* Leave the current view intact if reconciliation also fails. */ } + if (error instanceof TriageRequestError) throw error; + throw new Error("Could not confirm the save. The latest saved state will refresh before you retry."); + } finally { + mutationsPendingRef.current -= 1; + dataVersionRef.current += 1; + if (activeRunRef.current === requestedRun) refreshCurrentRef.current(); + } + }, [activeRun, refreshRuns]); + + const openFindings = useMemo(() => run?.vulnerabilities.filter((finding) => finding.status !== "closed") ?? [], [run]); const counts = useMemo( - () => (run ? severityCounts(run.vulnerabilities) : null), - [run] + () => (run ? severityCounts(openFindings) : null), + [run, openFindings] ); const selected = run?.vulnerabilities.find((v) => v.id === selectedId) ?? null; const agentCount = run?.transcript.agents.length ?? 0; @@ -228,6 +270,7 @@ export default function App() { setSelectedId(null); setRun(null); setError(null); + setIssueFilter("open"); // Reset the guard so the per-run default applies to the newly selected run. initialViewAppliedRef.current = false; }, []); @@ -272,7 +315,7 @@ export default function App() { if (v === "history") openHistory(); else userSetView(v); }} - issuesCount={run?.vulnerabilities.length ?? 0} + issuesCount={openFindings.length} agentCount={agentCount} mcpConnections={mcpConnections} mcpInUse={mcpInUse} @@ -325,7 +368,7 @@ export default function App() {
- {error && !run && view !== "history" && view !== "email" && ( + {error && view !== "history" && view !== "email" && (
) : ( setSelectedId(id)} /> )} @@ -550,16 +596,23 @@ function Meta({ label }: { label: string }) { function FindingsList({ vulnerabilities, finished, + filter, + onFilter, onSelect, }: { vulnerabilities: Vulnerability[]; finished: boolean; + filter: "open" | "closed" | "all"; + onFilter: (filter: "open" | "closed" | "all") => void; onSelect: (id: string) => void; }) { - const sorted = [...vulnerabilities].sort( + const open = vulnerabilities.filter((finding) => finding.status !== "closed").length; + const closed = vulnerabilities.length - open; + const sorted = vulnerabilities.filter((finding) => filter === "all" || + (filter === "closed" ? finding.status === "closed" : finding.status !== "closed")).sort( (a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity) ); - if (sorted.length === 0) { + if (vulnerabilities.length === 0) { return (
@@ -585,6 +638,22 @@ function FindingsList({ } return (
+
+
+ {(["open", "closed", "all"] as const).map((value) => ( + + ))} +
+

{open} open · {closed} false positives · {vulnerabilities.length} found

+
+ {sorted.length === 0 && ( +
+ {filter === "open" ? `No open findings. ${closed} marked as false positives.` : "No closed findings."} +
+ )} {sorted.map((v) => (
-

Email an encrypted PDF report of this run

+

Email the original scan report as an encrypted PDF

- Encrypted with a key only you can see, email verified with a one-time code before sending. + Excludes subsequent triage. Encrypted with a key only you can see; email verified before sending.

- Export report to PDF + Export original report
@@ -663,6 +733,7 @@ function OverviewTab({ summary, counts, total, + detected, reportMarkdown, raw, finished, @@ -671,6 +742,7 @@ function OverviewTab({ summary: ParsedRunSummary; counts: Record; total: number; + detected: number; reportMarkdown: string | null; raw: Record; finished: boolean; @@ -693,8 +765,9 @@ function OverviewTab({
- {total > 0 && ( + {detected > 0 && (
+

{total} open · {detected - total} false positives · {detected} found

)} @@ -734,12 +807,14 @@ function OverviewTab({ {sections.length > 0 ? (
+ {sections.map((s) => ( ))}
) : reportMarkdown ? (
+
) : ( @@ -752,6 +827,11 @@ function OverviewTab({ ); } +function OriginalReportLabel() { + return

Original scan report

+

Preserved as generated. Subsequent false-positive decisions are excluded; current triage counts appear above.

; +} + function TabButton({ active, onClick, diff --git a/strix/interface/viewer/frontend/src/components/EmailReportView.tsx b/strix/interface/viewer/frontend/src/components/EmailReportView.tsx index a44da4ce..86aaf1c0 100644 --- a/strix/interface/viewer/frontend/src/components/EmailReportView.tsx +++ b/strix/interface/viewer/frontend/src/components/EmailReportView.tsx @@ -192,9 +192,10 @@ export default function EmailReportView({
+ {!verifyOnly &&

This PDF preserves the original detected findings. Subsequent false-positive decisions and local triage notes are excluded.

}
- Export report + Export original report {verified && auth?.email && (

Sending to {auth.email}

diff --git a/strix/interface/viewer/frontend/src/components/PastRunsView.tsx b/strix/interface/viewer/frontend/src/components/PastRunsView.tsx index adfeb4dd..b0250131 100644 --- a/strix/interface/viewer/frontend/src/components/PastRunsView.tsx +++ b/strix/interface/viewer/frontend/src/components/PastRunsView.tsx @@ -22,7 +22,7 @@ const SEV = [ function SeverityChips({ counts }: { counts: RunSeverityCounts }) { const shown = SEV.filter((s) => counts[s.key] > 0); if (shown.length === 0) { - return No findings; + return No open findings; } return (
@@ -164,6 +164,7 @@ export default function PastRunsView({ {date && {date}} {date && run.status && ·} {run.status && {run.status}} + {run.open_count !== undefined && · {run.open_count} open · {run.closed_count ?? 0} false positives · {run.detected_count ?? run.open_count} found}
diff --git a/strix/interface/viewer/frontend/src/components/Sidebar.tsx b/strix/interface/viewer/frontend/src/components/Sidebar.tsx index 19cd1977..60fccb5d 100644 --- a/strix/interface/viewer/frontend/src/components/Sidebar.tsx +++ b/strix/interface/viewer/frontend/src/components/Sidebar.tsx @@ -264,7 +264,7 @@ export default function Sidebar({ {finished && ( } - label="Export report" + label="Export original report" active={view === "email"} onClick={onOpenEmail} /> diff --git a/strix/interface/viewer/frontend/src/components/vulnerability/TriageControls.tsx b/strix/interface/viewer/frontend/src/components/vulnerability/TriageControls.tsx new file mode 100644 index 00000000..87186518 --- /dev/null +++ b/strix/interface/viewer/frontend/src/components/vulnerability/TriageControls.tsx @@ -0,0 +1,142 @@ +import { useEffect, useRef, useState } from "react"; +import type { TriageUpdate } from "@/data/serverSource"; +import { TRIAGE_REASONS, type TriageReason, type Vulnerability } from "@/types/issues"; + +export type SaveTriage = (finding: Vulnerability, update: TriageUpdate) => Promise; + +const buttonClass = "cursor-pointer rounded-lg border border-[#444] px-3 py-1.5 text-sm text-white hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-50"; + +export function TriageControls({ finding, onSave }: { finding: Vulnerability; onSave: SaveTriage }) { + const [reviewed, setReviewed] = useState(null); + const [reason, setReason] = useState("unspecified"); + const [note, setNote] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [undo, setUndo] = useState<{ finding: Vulnerability; update: TriageUpdate } | null>(null); + const [message, setMessage] = useState(null); + const triggerRef = useRef(null); + const reasonRef = useRef(null); + const focusAfterSaveRef = useRef(false); + + useEffect(() => { + if (reviewed) reasonRef.current?.focus(); + }, [reviewed]); + useEffect(() => { + if (focusAfterSaveRef.current && !pending && !reviewed) { + focusAfterSaveRef.current = false; + triggerRef.current?.focus(); + } + }, [pending, reviewed]); + useEffect(() => { + if (undo && (undo.finding.triage_revision !== finding.triage_revision || + undo.finding.finding_digest !== finding.finding_digest)) { + setUndo(null); + setMessage(null); + } + }, [finding.triage_revision, finding.finding_digest, undo]); + useEffect(() => { + if (!undo) return; + const timer = setTimeout(() => setUndo(null), 10000); + return () => clearTimeout(timer); + }, [undo]); + + const closeForm = () => { + setReviewed(null); + setError(null); + triggerRef.current?.focus(); + }; + const save = async (snapshot: Vulnerability, update: TriageUpdate, undoing = false) => { + if (pending) return; + setPending(true); + setError(null); + try { + const saved = await onSave(snapshot, update); + focusAfterSaveRef.current = true; + setReviewed(null); + setMessage(update.status === "closed" ? "Marked as a false positive." : "Finding reopened."); + setUndo(undoing ? null : { + finding: saved, + update: snapshot.status === "closed" + ? { status: "closed", reason_code: snapshot.reason_code, note: snapshot.status_note ?? "" } + : { status: "open" }, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not save this decision. Try again."); + } finally { + setPending(false); + } + }; + const changed = reviewed && ( + reviewed.triage_revision !== finding.triage_revision || reviewed.finding_digest !== finding.finding_digest + ); + + return ( +
+
+ + {!finding.can_triage && Triage is unavailable for this finding or run.} + {message && {message}} + {undo && ( + + )} +
+ {error &&

{error}

} + {reviewed && ( +
{ + event.preventDefault(); + if (!changed) void save(reviewed, { status: "closed", reason_code: reason, note }); + }} + onKeyDown={(event) => { + if (event.key === "Escape" && !pending) { event.preventDefault(); closeForm(); } + }} + > +
+

Mark as false positive

+

This finding is incorrect or does not apply to your target.

+

Applies to this finding in this run. A later scan may report it again.

+
+ +