mirror of
https://github.com/usestrix/strix.git
synced 2026-09-24 00:51:20 +00:00
Merge 327c78c2ca into 976835194d
This commit is contained in:
commit
dc9a462bff
42 changed files with 4403 additions and 668 deletions
|
|
@ -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.
|
||||
|
||||
## Options
|
||||
|
||||
|
|
@ -33,17 +33,29 @@ 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
|
||||
|
||||
At the bottom of a finding, 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. 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.
|
||||
|
||||
## Sharing The Link
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
</Warning>
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ class CustomBuildHook(BuildHookInterface): # type: ignore[type-arg]
|
|||
"""
|
||||
|
||||
def initialize(self, version: str, build_data: dict[str, Any]) -> None:
|
||||
# Editable installs run from the checkout, where the TUI is started
|
||||
# with ``go run``; there is nothing to bundle.
|
||||
# Editable installs compile the checkout's TUI at startup;
|
||||
# there is nothing to bundle.
|
||||
if version == "editable":
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
|
|
|
|||
453
strix/interface/tui/internal/app/triage.go
Normal file
453
strix/interface/tui/internal/app/triage.go
Normal file
|
|
@ -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
|
||||
}
|
||||
231
strix/interface/tui/internal/app/triage_test.go
Normal file
231
strix/interface/tui/internal/app/triage_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import logging
|
|||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
|
@ -26,6 +27,7 @@ from strix.interface.scan_setup import (
|
|||
from strix.interface.tui.backend import TuiBackendServer, TuiController
|
||||
from strix.interface.tui.backend.live_view import TuiLiveView
|
||||
from strix.interface.tui.sidecar import (
|
||||
build_tui_source,
|
||||
check_return_code,
|
||||
child_environment,
|
||||
launch_tui_process,
|
||||
|
|
@ -45,6 +47,7 @@ if TYPE_CHECKING:
|
|||
import argparse
|
||||
import socket
|
||||
import subprocess
|
||||
from typing import TextIO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -410,6 +413,19 @@ class GoTuiRuntime:
|
|||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
@staticmethod
|
||||
async def _source_command(directory: str, env: dict[str, str], output: TextIO) -> list[str]:
|
||||
# Compile before IPC starts: a cold build/toolchain download must not
|
||||
# consume the sidecar's protocol-handshake timeout.
|
||||
print(
|
||||
"\x1b[2mCompiling the TUI from source (cached after the first run)...\x1b[0m",
|
||||
file=output,
|
||||
flush=True,
|
||||
)
|
||||
executable = Path(directory) / tui_executable()
|
||||
await build_tui_source(tui_source_dir(), executable, env)
|
||||
return [str(executable)]
|
||||
|
||||
async def run(self) -> None:
|
||||
# Redirect the process's sys.stdout/sys.stderr while the TUI runs so
|
||||
# logging handlers created during the scan never paint over the Go
|
||||
|
|
@ -424,20 +440,15 @@ class GoTuiRuntime:
|
|||
sync_task: asyncio.Task[None] | None = None
|
||||
prepare_task: asyncio.Task[None] | None = None
|
||||
process: asyncio.subprocess.Process | subprocess.Popen[bytes] | None = None
|
||||
build_directory: tempfile.TemporaryDirectory[str] | None = None
|
||||
try:
|
||||
env = child_environment()
|
||||
env["STRIX_VERSION"] = package_version()
|
||||
command = self.binary_command()
|
||||
cwd = str(tui_source_dir()) if command[:2] == ["go", "run"] else None
|
||||
if cwd is not None:
|
||||
# go run compiles the sidecar when the build cache is cold, so
|
||||
# tell the terminal why nothing is on screen yet.
|
||||
print(
|
||||
"\x1b[2mCompiling the TUI from source (cached after the first run)...\x1b[0m",
|
||||
file=original_stdout,
|
||||
flush=True,
|
||||
)
|
||||
process, backend_socket = await launch_tui_process(command, env, cwd)
|
||||
if command[:2] == ["go", "run"]:
|
||||
build_directory = tempfile.TemporaryDirectory(prefix="strix-tui-")
|
||||
command = await self._source_command(build_directory.name, env, original_stdout)
|
||||
process, backend_socket = await launch_tui_process(command, env, None)
|
||||
await self.server.start(backend_socket)
|
||||
prepare_task = self._start_preparation()
|
||||
sync_task = asyncio.create_task(self.sync_state())
|
||||
|
|
@ -462,6 +473,8 @@ class GoTuiRuntime:
|
|||
sys.stdout = original_stdout
|
||||
sys.stderr = original_stderr
|
||||
output_sink.close()
|
||||
if build_directory is not None:
|
||||
build_directory.cleanup()
|
||||
# Mirror run_tui: surface the captured scan failure once the app has
|
||||
# exited cleanly so the CLI reports it instead of exiting 0.
|
||||
if self.scan_error is not None:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ from importlib.metadata import PackageNotFoundError, version
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from strix.interface.terminal_text import sanitize_terminal_text
|
||||
|
||||
|
||||
_WINDOWS_AUTH_TIMEOUT = 10.0
|
||||
_PROCESS_EXIT_TIMEOUT = 5.0
|
||||
|
|
@ -122,6 +124,50 @@ async def terminate_process(
|
|||
await asyncio.wait_for(asyncio.shield(wait_task), _PROCESS_EXIT_TIMEOUT)
|
||||
|
||||
|
||||
async def build_tui_source(source: Path, output: Path, env: dict[str, str]) -> None:
|
||||
"""Compile before opening IPC so toolchain downloads cannot time out the handshake."""
|
||||
command = ["go", "build", "-o", str(output), "./cmd/strix-tui"]
|
||||
build_env = {**env, "GOTOOLCHAIN": "auto", "CGO_ENABLED": "0"}
|
||||
process: asyncio.subprocess.Process | subprocess.Popen[bytes]
|
||||
communication: asyncio.Task[tuple[bytes | None, bytes | None]]
|
||||
if os.name == "nt":
|
||||
# The Windows selector loop used by Strix does not support async subprocesses.
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
command,
|
||||
cwd=str(source),
|
||||
env=build_env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
communication = asyncio.create_task(asyncio.to_thread(process.communicate))
|
||||
else:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
cwd=str(source),
|
||||
env=build_env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
communication = asyncio.create_task(process.communicate())
|
||||
try:
|
||||
_, stderr = await asyncio.shield(communication)
|
||||
except BaseException:
|
||||
await terminate_process(process)
|
||||
# In particular, wait for the Windows pipe reader before cleaning up the build.
|
||||
with contextlib.suppress(Exception, asyncio.CancelledError):
|
||||
await communication
|
||||
raise
|
||||
if process.returncode != 0:
|
||||
detail = sanitize_terminal_text((stderr or b"").decode("utf-8", errors="replace")).strip()
|
||||
raise RuntimeError(
|
||||
"Could not compile the TUI. Go must be able to select the toolchain "
|
||||
"required by go.mod and download any missing build dependencies."
|
||||
+ (f"\n{detail[-4000:]}" if detail else "")
|
||||
)
|
||||
|
||||
|
||||
async def launch_tui_process(
|
||||
command: list[str],
|
||||
env: dict[str, str],
|
||||
|
|
|
|||
|
|
@ -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<string>());
|
||||
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=<name>) 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<typeof setTimeout> | 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() {
|
|||
</div>
|
||||
|
||||
<div className="max-w-[88rem] mx-auto px-3 sm:px-6 py-8 sm:py-12 space-y-6">
|
||||
{error && !run && view !== "history" && view !== "email" && (
|
||||
{error && view !== "history" && view !== "email" && (
|
||||
<div className="rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5 text-red-400" aria-hidden="true" />
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
|
|
@ -383,7 +426,7 @@ export default function App() {
|
|||
Pentest Overview
|
||||
</TabButton>
|
||||
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
|
||||
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
|
||||
Issues{run.vulnerabilities.length > 0 ? ` (${openFindings.length} open)` : ""}
|
||||
</TabButton>
|
||||
{agentCount > 0 && (
|
||||
<TabButton active={view === "agents"} onClick={() => userSetView("agents")}>
|
||||
|
|
@ -396,7 +439,8 @@ export default function App() {
|
|||
<OverviewTab
|
||||
summary={run.summary}
|
||||
counts={counts}
|
||||
total={run.vulnerabilities.length}
|
||||
total={openFindings.length}
|
||||
detected={run.vulnerabilities.length}
|
||||
reportMarkdown={run.reportMarkdown}
|
||||
raw={run.raw}
|
||||
finished={run.finished}
|
||||
|
|
@ -412,12 +456,14 @@ export default function App() {
|
|||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to all findings
|
||||
</button>
|
||||
<VulnerabilityDetail vulnerability={selected} />
|
||||
<VulnerabilityDetail vulnerability={{ ...selected, can_triage: selected.can_triage && !error }} onTriage={saveTriage} />
|
||||
</div>
|
||||
) : (
|
||||
<FindingsList
|
||||
vulnerabilities={run.vulnerabilities}
|
||||
finished={run.finished}
|
||||
filter={issueFilter}
|
||||
onFilter={setIssueFilter}
|
||||
onSelect={(id) => 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
|
||||
|
|
@ -585,6 +638,22 @@ function FindingsList({
|
|||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex gap-1 rounded-lg border border-[#333] p-1" aria-label="Finding status">
|
||||
{(["open", "closed", "all"] as const).map((value) => (
|
||||
<button type="button" key={value} aria-pressed={filter === value} onClick={() => onFilter(value)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm capitalize ${filter === value ? "bg-white/10 text-white" : "text-[#888] hover:text-white"}`}>
|
||||
{value} ({value === "open" ? open : value === "closed" ? closed : vulnerabilities.length})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-[#888]">{open} open · {closed} false positives · {vulnerabilities.length} found</p>
|
||||
</div>
|
||||
{sorted.length === 0 && (
|
||||
<div className="rounded-xl border border-[#222] p-8 text-center text-sm text-[#aaa]">
|
||||
{filter === "open" ? `No open findings. ${closed} marked as false positives.` : "No closed findings."}
|
||||
</div>
|
||||
)}
|
||||
{sorted.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
|
|
@ -598,6 +667,7 @@ function FindingsList({
|
|||
<span className="block text-xs text-[#666] font-mono truncate">{v.target}</span>
|
||||
)}
|
||||
</span>
|
||||
{(v.status === "closed" || v.review_stale) && <span className="text-xs text-[#aaa]">{v.review_stale ? "Needs review" : "Closed · False positive"}</span>}
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full border capitalize ${SEVERITY_COLORS[v.severity]}`}
|
||||
>
|
||||
|
|
@ -646,13 +716,13 @@ function EmailReportCta({ onOpenEmail }: { onOpenEmail: () => void }) {
|
|||
<Mail className="h-4 w-4 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white">Email an encrypted PDF report of this run</p>
|
||||
<p className="text-sm font-semibold text-white">Email the original scan report as an encrypted PDF</p>
|
||||
<p className="mt-0.5 text-xs text-[#888]">
|
||||
Encrypted with a key only you can see, email verified with a one-time code before sending.
|
||||
Excludes subsequent triage. Encrypted with a key only you can see; email verified before sending.
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90">
|
||||
Export report to PDF
|
||||
Export original report
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -663,6 +733,7 @@ function OverviewTab({
|
|||
summary,
|
||||
counts,
|
||||
total,
|
||||
detected,
|
||||
reportMarkdown,
|
||||
raw,
|
||||
finished,
|
||||
|
|
@ -671,6 +742,7 @@ function OverviewTab({
|
|||
summary: ParsedRunSummary;
|
||||
counts: Record<VulnerabilitySeverity, number>;
|
||||
total: number;
|
||||
detected: number;
|
||||
reportMarkdown: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
finished: boolean;
|
||||
|
|
@ -693,8 +765,9 @@ function OverviewTab({
|
|||
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
|
||||
</div>
|
||||
|
||||
{total > 0 && (
|
||||
{detected > 0 && (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="mb-3 text-sm text-[#aaa]">{total} open · {detected - total} false positives · {detected} found</p>
|
||||
<IssueSeveritySummary findings={{ total, ...counts }} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -734,12 +807,14 @@ function OverviewTab({
|
|||
|
||||
{sections.length > 0 ? (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
|
||||
<OriginalReportLabel />
|
||||
{sections.map((s) => (
|
||||
<ContentSection key={s.title} title={s.title} content={s.content} />
|
||||
))}
|
||||
</div>
|
||||
) : reportMarkdown ? (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<OriginalReportLabel />
|
||||
<ContentSection content={dedupeHeadings(reportMarkdown)} />
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -752,6 +827,11 @@ function OverviewTab({
|
|||
);
|
||||
}
|
||||
|
||||
function OriginalReportLabel() {
|
||||
return <div className="mb-5"><h2 className="text-base font-semibold text-white">Original scan report</h2>
|
||||
<p className="mt-1 text-sm text-[#888]">Preserved as generated. Subsequent false-positive decisions are excluded; current triage counts appear above.</p></div>;
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
|
|
|
|||
|
|
@ -192,9 +192,10 @@ export default function EmailReportView({
|
|||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{verifyOnly ? "Verify your email" : "Export report to PDF"}
|
||||
{verifyOnly ? "Verify your email" : "Export original scan report"}
|
||||
</h1>
|
||||
</div>
|
||||
{!verifyOnly && <p className="text-sm text-[#aaa]">This PDF preserves the original detected findings. Subsequent false-positive decisions and local triage notes are excluded.</p>}
|
||||
|
||||
<div
|
||||
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
|
||||
|
|
@ -239,7 +240,7 @@ export default function EmailReportView({
|
|||
onClick={startFlow}
|
||||
className="w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Export report
|
||||
Export original report
|
||||
</button>
|
||||
{verified && auth?.email && (
|
||||
<p className="text-center text-xs text-[#666]">Sending to {auth.email}</p>
|
||||
|
|
|
|||
|
|
@ -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 <span className="text-xs text-[#555]">No findings</span>;
|
||||
return <span className="text-xs text-[#888]">No open findings</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -164,9 +164,14 @@ export default function PastRunsView({
|
|||
{date && <span>{date}</span>}
|
||||
{date && run.status && <span className="text-[#333]">·</span>}
|
||||
{run.status && <span className="capitalize">{run.status}</span>}
|
||||
{run.open_count !== undefined && <span>· {run.open_count} open · {run.closed_count ?? 0} false positives · {run.detected_count ?? run.open_count} found</span>}
|
||||
</div>
|
||||
</div>
|
||||
<SeverityChips counts={run.severity_counts} />
|
||||
{run.severity_counts ? (
|
||||
<SeverityChips counts={run.severity_counts} />
|
||||
) : (
|
||||
<span className="text-xs text-[#888]">Review unavailable</span>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ export default function Sidebar({
|
|||
{finished && (
|
||||
<NavItem
|
||||
icon={<Mail className="h-4 w-4" />}
|
||||
label="Export report"
|
||||
label="Export original report"
|
||||
active={view === "email"}
|
||||
onClick={onOpenEmail}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
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<Vulnerability>;
|
||||
|
||||
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<Vulnerability | null>(null);
|
||||
const [reason, setReason] = useState<TriageReason>("unspecified");
|
||||
const [note, setNote] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [undo, setUndo] = useState<{ finding: Vulnerability; update: TriageUpdate } | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const reasonRef = useRef<HTMLSelectElement>(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 (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={!finding.can_triage || pending}
|
||||
className="cursor-pointer rounded px-1 py-1.5 text-xs text-[#888] transition-colors hover:text-white focus-visible:outline focus-visible:outline-offset-4 focus-visible:outline-[#666] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
if (finding.status === "closed") void save(finding, { status: "open" });
|
||||
else setReviewed(finding);
|
||||
}}
|
||||
>
|
||||
{pending ? "Saving…" : finding.status === "closed" ? "Reopen" : "Mark as false positive"}
|
||||
</button>
|
||||
{!finding.can_triage && <span className="text-xs text-[#999]">Triage is unavailable for this finding or run.</span>}
|
||||
{message && <span className="text-xs text-[#aaa]" role="status">{message}</span>}
|
||||
{undo && (
|
||||
<button type="button" className="text-xs text-[#aaa] underline hover:text-white disabled:opacity-50" disabled={pending}
|
||||
onClick={() => void save(undo.finding, undo.update, true)}>Undo</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-red-300">{error}</p>}
|
||||
{reviewed && (
|
||||
<form
|
||||
className="max-w-xl space-y-3 rounded-lg border border-[#2a2a2a] bg-[#111] p-4"
|
||||
aria-labelledby="triage-title"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!changed) void save(reviewed, { status: "closed", reason_code: reason, note });
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && !pending) { event.preventDefault(); closeForm(); }
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 id="triage-title" className="text-sm font-medium text-white">Mark as false positive</h2>
|
||||
<p className="mt-1 text-xs text-[#888]">Applies to this finding in this run. You can reopen it anytime.</p>
|
||||
</div>
|
||||
<label className="block text-sm text-[#bbb]">
|
||||
Reason (optional)
|
||||
<select ref={reasonRef} value={reason} disabled={pending}
|
||||
onChange={(event) => setReason(event.target.value as TriageReason)}
|
||||
className="mt-1.5 block w-full rounded-lg border border-[#444] bg-[#161616] px-3 py-2 text-sm text-white">
|
||||
{Object.entries(TRIAGE_REASONS).map(([value, label]) => <option value={value} key={value}>{label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm text-[#bbb]">
|
||||
Note (optional)
|
||||
<textarea value={note} onChange={(event) => setNote(event.target.value)} maxLength={2000}
|
||||
disabled={pending} rows={2} placeholder="Add context for your future review"
|
||||
className="mt-1.5 block w-full resize-y rounded-lg border border-[#444] bg-[#161616] px-3 py-2 text-sm text-white" />
|
||||
</label>
|
||||
{changed && <p role="alert" className="text-sm text-amber-300">This finding changed while the form was open. Cancel and review its current evidence before marking it again.</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={closeForm} disabled={pending} className={buttonClass}>Cancel</button>
|
||||
<button type="submit" disabled={pending || !!changed || !finding.can_triage}
|
||||
className="rounded-lg bg-white px-3 py-1.5 text-sm font-semibold text-black disabled:opacity-50">{pending ? "Saving…" : "Close issue"}</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
import React, { useState } from "react";
|
||||
import { Clock, CheckCircle2, Ban, History, BellOff, Wrench, GitMerge } from "lucide-react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { Vulnerability, VulnerabilityStatus, SEVERITY_COLORS, STATUS_META, isSeverityOverridden } from "@/types/issues";
|
||||
import { Vulnerability, VulnerabilityStatus, SEVERITY_COLORS, STATUS_META, TRIAGE_REASONS, isSeverityOverridden } from "@/types/issues";
|
||||
import { TriageControls, type SaveTriage } from "@/components/vulnerability/TriageControls";
|
||||
import { formatTimeAgo } from "@/lib/utils";
|
||||
import { getSeverityDot } from "@/lib/vulnerability-utils";
|
||||
import { formatStrixId } from "@/lib/display-number";
|
||||
|
|
@ -21,6 +22,7 @@ function bannerTime(dateString: string | null): string {
|
|||
|
||||
const STATUS_BANNER: Record<VulnerabilityStatus, { icon: React.ElementType; label: string; iconColor: string } | null> = {
|
||||
open: null,
|
||||
closed: { icon: Ban, label: "Marked as a false positive", iconColor: "text-[#aaa]" },
|
||||
in_progress: { icon: Clock, label: "Marked as In Progress", iconColor: "text-blue-400" },
|
||||
snoozed: { icon: BellOff, label: "Snoozed", iconColor: "text-purple-400" },
|
||||
fixed: { icon: CheckCircle2, label: "Marked as Fixed", iconColor: "text-emerald-400" },
|
||||
|
|
@ -40,6 +42,7 @@ const WORKFLOW_CTAS: { label: string; slug: string; icon: React.ElementType; req
|
|||
|
||||
interface VulnerabilityDetailProps {
|
||||
vulnerability: Vulnerability;
|
||||
onTriage: SaveTriage;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,8 +50,9 @@ interface VulnerabilityDetailProps {
|
|||
* without page chrome. Shared by the public /share/issues page and the local
|
||||
* /results view so both render findings identically.
|
||||
*/
|
||||
export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDetailProps) {
|
||||
export default function VulnerabilityDetail({ vulnerability, onTriage }: VulnerabilityDetailProps) {
|
||||
const currentMeta = STATUS_META[vulnerability.status];
|
||||
const statusLabel = vulnerability.review_stale ? "Needs review" : currentMeta.label;
|
||||
const hasCodeLocations = vulnerability.code_locations && vulnerability.code_locations.length > 0;
|
||||
const hasFix = hasCodeLocations || vulnerability.remediation_steps;
|
||||
const hasReproduction = !!(vulnerability.evidence || vulnerability.assumptions || vulnerability.poc_description || vulnerability.poc_script_code);
|
||||
|
|
@ -76,7 +80,7 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
|
|||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full border ${currentMeta.color}`}>
|
||||
{currentMeta.label}
|
||||
{statusLabel}
|
||||
</span>
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-semibold rounded-full border ${SEVERITY_COLORS[vulnerability.severity]}`}
|
||||
|
|
@ -120,6 +124,12 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{vulnerability.review_stale && (
|
||||
<p className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-200" role="status">
|
||||
Evidence changed since the previous false-positive decision. This finding is open for review.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Status banner */}
|
||||
{vulnerability.status !== "open" && (() => {
|
||||
const banner = STATUS_BANNER[vulnerability.status];
|
||||
|
|
@ -132,8 +142,11 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
|
|||
<p className="text-sm font-semibold text-white">
|
||||
{banner.label}{bannerTime(vulnerability.status_changed_at)}
|
||||
</p>
|
||||
{vulnerability.reason_code && vulnerability.reason_code !== "unspecified" && (
|
||||
<p className="mt-1 text-sm text-[#aaa]">{TRIAGE_REASONS[vulnerability.reason_code]}</p>
|
||||
)}
|
||||
{vulnerability.status_note && (
|
||||
<p className="text-sm text-[#666] italic mt-1">
|
||||
<p className="text-sm text-[#aaa] whitespace-pre-wrap break-words mt-1">
|
||||
“{vulnerability.status_note}”
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -253,13 +266,16 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
|
|||
statusSlot={
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border ${currentMeta.color}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${currentMeta.dotColor}`} />
|
||||
{currentMeta.label}
|
||||
{statusLabel}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#222] pt-4">
|
||||
<TriageControls finding={vulnerability} onSave={onTriage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Vulnerability } from "@/types/issues";
|
||||
import type { TriageReason, Vulnerability } from "@/types/issues";
|
||||
import {
|
||||
parseRunJson,
|
||||
parseVulnerabilitiesJson,
|
||||
|
|
@ -84,7 +84,11 @@ export interface LoadedRun {
|
|||
|
||||
async function getJson(path: string): Promise<unknown> {
|
||||
const res = await fetch(path, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
if (data?.error === "invalid_triage") throw new Error("The saved triage file could not be read. Findings have not been refreshed; repair the file before continuing.");
|
||||
throw new Error(`Could not refresh run data (${res.status}).`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +117,52 @@ export async function fetchVulnerabilities(
|
|||
return parseVulnerabilitiesJson(JSON.stringify(arr), runId);
|
||||
}
|
||||
|
||||
export async function fetchTriageRevision(runName?: string | null): Promise<string> {
|
||||
const data = await getJson("/api/triage/revision" + runQuery(runName)) as { revision: unknown };
|
||||
return JSON.stringify(data.revision);
|
||||
}
|
||||
|
||||
export interface TriageUpdate {
|
||||
status: "open" | "closed";
|
||||
reason_code?: TriageReason;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class TriageRequestError extends Error {
|
||||
constructor(public code: string, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFindingTriage(
|
||||
finding: Vulnerability,
|
||||
update: TriageUpdate,
|
||||
runName?: string | null,
|
||||
): Promise<Vulnerability> {
|
||||
const { ok, data } = await postJson(
|
||||
`/api/vulnerabilities/${encodeURIComponent(finding.id)}/triage` + runQuery(runName),
|
||||
{
|
||||
...update,
|
||||
expected_revision: finding.triage_revision ?? 0,
|
||||
reviewed_digest: finding.finding_digest ?? "",
|
||||
},
|
||||
);
|
||||
if (!ok || !data.finding || typeof data.finding !== "object") {
|
||||
const code = String(data.error ?? "unavailable");
|
||||
const messages: Record<string, string> = {
|
||||
conflict: "This finding changed. Review the latest evidence and reopen the form before saving.",
|
||||
invalid_triage: "The saved triage file could not be read. Fix it before changing this finding.",
|
||||
read_only: "This run is read-only. Its findings cannot be changed.",
|
||||
unknown_finding: "This finding is no longer available in the run.",
|
||||
unverified: "Verify your email to access this historical run.",
|
||||
forbidden: "Your viewer session has expired. Reopen the viewer using its authorized link.",
|
||||
invalid_request: "The decision could not be saved. Check the form and try again.",
|
||||
};
|
||||
throw new TriageRequestError(code, messages[code] ?? "Could not save this decision. Try again.");
|
||||
}
|
||||
return parseVulnerabilitiesJson(JSON.stringify([data.finding]), finding.scan_id)[0];
|
||||
}
|
||||
|
||||
export async function fetchReportMarkdown(runName?: string | null): Promise<string | null> {
|
||||
const obj = (await getJson("/api/report" + runQuery(runName))) as { markdown?: string };
|
||||
return obj?.markdown ?? null;
|
||||
|
|
@ -130,7 +180,7 @@ export async function fetchTranscript(runName?: string | null): Promise<Transcri
|
|||
export async function fetchAll(runName?: string | null): Promise<LoadedRun> {
|
||||
const { summary, raw, finished } = await fetchRunSummary(runName);
|
||||
const [vulnerabilities, reportMarkdown, transcript] = await Promise.all([
|
||||
fetchVulnerabilities(summary.runId, runName).catch(() => [] as Vulnerability[]),
|
||||
fetchVulnerabilities(summary.runId, runName),
|
||||
fetchReportMarkdown(runName).catch(() => null),
|
||||
fetchTranscript(runName).catch(() => ({ agents: [], events: [] }) as Transcript),
|
||||
]);
|
||||
|
|
@ -160,7 +210,10 @@ export interface RunListEntry {
|
|||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
finished: boolean;
|
||||
severity_counts: RunSeverityCounts;
|
||||
severity_counts: RunSeverityCounts | null;
|
||||
open_count?: number;
|
||||
closed_count?: number;
|
||||
detected_count?: number;
|
||||
}
|
||||
|
||||
export interface RunsPayload {
|
||||
|
|
@ -277,7 +330,7 @@ export async function sendReport(runName?: string | null): Promise<SendReportRes
|
|||
return {
|
||||
ok: true,
|
||||
password: String(data.password ?? ""),
|
||||
filename: String(data.filename ?? "strix-report.pdf"),
|
||||
filename: String(data.filename ?? "strix-original-scan-report.pdf"),
|
||||
};
|
||||
}
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
VulnerabilitySeverity,
|
||||
VulnerabilityStatus,
|
||||
} from "@/types/issues";
|
||||
import { TRIAGE_REASONS } from "@/types/issues";
|
||||
|
||||
/**
|
||||
* Pure, dependency-free parsers that turn a Strix CLI local run
|
||||
|
|
@ -196,7 +197,7 @@ function parseOneVulnerability(
|
|||
? (cweRaw.filter((c) => typeof c === "string" && c) as string[])
|
||||
: null;
|
||||
|
||||
const status: VulnerabilityStatus = "open";
|
||||
const status: VulnerabilityStatus = raw.status === "closed" ? "closed" : "open";
|
||||
|
||||
return {
|
||||
...emptyVulnerabilityDefaults(),
|
||||
|
|
@ -206,6 +207,18 @@ function parseOneVulnerability(
|
|||
description: asStringOrNull(raw.description) ?? "",
|
||||
severity: coerceSeverity(raw.severity),
|
||||
status,
|
||||
triage_status: raw.triage_status === "closed" ? "closed" : "open",
|
||||
resolution_reason: raw.resolution_reason === "false_positive" ? "false_positive" : null,
|
||||
reason_code: typeof raw.reason_code === "string" && Object.hasOwn(TRIAGE_REASONS, raw.reason_code)
|
||||
? raw.reason_code as Vulnerability["reason_code"] : "unspecified",
|
||||
triage_revision: typeof raw.triage_revision === "number" && Number.isSafeInteger(raw.triage_revision)
|
||||
? raw.triage_revision : 0,
|
||||
finding_digest: asStringOrNull(raw.finding_digest) ?? "",
|
||||
review_stale: raw.review_stale === true,
|
||||
can_triage: raw.can_triage === true && typeof raw.id === "string" && !!raw.id,
|
||||
status_note: asStringOrNull(raw.status_note),
|
||||
status_changed_at: asStringOrNull(raw.status_changed_at),
|
||||
status_changed_by: asStringOrNull(raw.status_changed_by),
|
||||
created_at: toIsoTimestamp(raw.timestamp),
|
||||
cve: asStringOrNull(raw.cve),
|
||||
cvss: asNumberOrNull(raw.cvss),
|
||||
|
|
|
|||
|
|
@ -1,19 +1,29 @@
|
|||
export type VulnerabilitySeverity = "critical" | "high" | "medium" | "low";
|
||||
export type VulnerabilityStatus = "open" | "in_progress" | "snoozed" | "fixed" | "ignored";
|
||||
export type VulnerabilityStatus = "open" | "closed" | "in_progress" | "snoozed" | "fixed" | "ignored";
|
||||
export type TriageReason = "unspecified" | "incorrect_assumption" | "existing_protection" | "not_affected" | "expected_behavior" | "other";
|
||||
export const TRIAGE_REASONS: Record<TriageReason, string> = {
|
||||
unspecified: "Select a reason",
|
||||
incorrect_assumption: "Incorrect assumption",
|
||||
existing_protection: "Existing protection prevents the reported exploit",
|
||||
not_affected: "Code or dependency is not affected",
|
||||
expected_behavior: "Expected behavior, not a vulnerability",
|
||||
other: "Other",
|
||||
};
|
||||
export type FixEffort = "trivial" | "low" | "medium" | "high";
|
||||
|
||||
export const ACTIVE_STATUSES: VulnerabilityStatus[] = ["open", "in_progress", "snoozed"];
|
||||
export const RESOLVED_STATUSES: VulnerabilityStatus[] = ["fixed", "ignored"];
|
||||
export const RESOLVED_STATUSES: VulnerabilityStatus[] = ["closed", "fixed", "ignored"];
|
||||
|
||||
// Statuses worth retesting in a "retest all" — everything except ignored
|
||||
// (fixed issues are still re-verified; ignored issues are intentionally skipped).
|
||||
export const RETESTABLE_STATUSES: VulnerabilityStatus[] = ["open", "in_progress", "snoozed", "fixed"];
|
||||
|
||||
export const ALL_STATUSES: VulnerabilityStatus[] = ["open", "in_progress", "snoozed", "fixed", "ignored"];
|
||||
export const ALL_STATUSES: VulnerabilityStatus[] = ["open", "closed", "in_progress", "snoozed", "fixed", "ignored"];
|
||||
|
||||
export interface StatusCounts {
|
||||
all: number;
|
||||
open: number;
|
||||
closed: number;
|
||||
in_progress: number;
|
||||
snoozed: number;
|
||||
fixed: number;
|
||||
|
|
@ -28,6 +38,12 @@ export interface StatusMeta {
|
|||
}
|
||||
|
||||
export const STATUS_META: Record<VulnerabilityStatus, StatusMeta> = {
|
||||
closed: {
|
||||
label: "Closed · False positive",
|
||||
color: "bg-gray-500/10 text-gray-300 border-gray-500/20",
|
||||
dotColor: "bg-gray-400",
|
||||
description: "Marked as a false positive in this run",
|
||||
},
|
||||
open: {
|
||||
label: "Open",
|
||||
color: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
|
|
@ -100,6 +116,13 @@ export interface Vulnerability {
|
|||
potential_risk_saving: number | null;
|
||||
risk_saving_description: string | null;
|
||||
status: VulnerabilityStatus;
|
||||
triage_status?: "open" | "closed";
|
||||
resolution_reason?: "false_positive" | null;
|
||||
reason_code?: TriageReason;
|
||||
triage_revision?: number;
|
||||
finding_digest?: string;
|
||||
review_stale?: boolean;
|
||||
can_triage?: boolean;
|
||||
severity: VulnerabilitySeverity;
|
||||
impact: string | null;
|
||||
endpoint: string | null;
|
||||
|
|
@ -176,6 +199,7 @@ export const SEVERITY_COLORS: Record<VulnerabilitySeverity, string> = {
|
|||
|
||||
export const STATUS_COLORS: Record<VulnerabilityStatus, string> = {
|
||||
open: STATUS_META.open.color,
|
||||
closed: STATUS_META.closed.color,
|
||||
in_progress: STATUS_META.in_progress.color,
|
||||
snoozed: STATUS_META.snoozed.color,
|
||||
fixed: STATUS_META.fixed.color,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ severity grid, per-finding detail with colored severity badges) but is rendered
|
|||
entirely locally with reportlab, so it ships without a browser or heavy system
|
||||
deps and keeps the report on the user's machine.
|
||||
|
||||
The PDF carries FULL finding detail, including proof-of-concept scripts, so it
|
||||
The PDF preserves the original scan findings and excludes subsequent local
|
||||
triage decisions. It carries FULL finding detail, including proof-of-concept scripts, so it
|
||||
is encrypted end to end with AES-256. The password is generated locally with a
|
||||
CSPRNG, shown only to the local browser, and never leaves the machine except in
|
||||
the user's own hands. Strix cannot read the delivered report.
|
||||
|
|
@ -419,11 +420,17 @@ def _cover(
|
|||
Spacer(1, 150),
|
||||
Paragraph("PENETRATION TEST REPORT", styles["badge_label"]),
|
||||
Spacer(1, 20),
|
||||
Paragraph("Security Assessment", styles["cover_title"]),
|
||||
Paragraph("Original scan report", styles["cover_title"]),
|
||||
Paragraph(_esc(target), styles["cover_org"]),
|
||||
Spacer(1, 14),
|
||||
Paragraph(
|
||||
"Includes all detected findings. Subsequent local triage decisions, "
|
||||
"including false-positive closures and notes, are excluded.",
|
||||
styles["body"],
|
||||
),
|
||||
Spacer(1, 28),
|
||||
meta_table,
|
||||
Spacer(1, 90),
|
||||
Spacer(1, 50),
|
||||
confidential,
|
||||
PageBreak(),
|
||||
]
|
||||
|
|
@ -610,7 +617,7 @@ def _overview_flowables(
|
|||
Spacer(1, 16),
|
||||
_severity_grid(styles, counts),
|
||||
Spacer(1, 10),
|
||||
Paragraph(f"<b>{total}</b> total findings across this assessment.", styles["body"]),
|
||||
Paragraph(f"<b>{total}</b> detected findings across this assessment.", styles["body"]),
|
||||
]
|
||||
scan_results = record.get("scan_results")
|
||||
if not isinstance(scan_results, dict):
|
||||
|
|
@ -634,7 +641,7 @@ def _overview_flowables(
|
|||
|
||||
|
||||
def generate_report_pdf(run_dir: Path) -> bytes:
|
||||
"""Render a branded, full-detail PDF report for the run at ``run_dir``."""
|
||||
"""Render the original scan report, without applying the local triage overlay."""
|
||||
record = read_run_summary(run_dir)
|
||||
vulns = [v for v in read_vulnerabilities(run_dir) if isinstance(v, dict)]
|
||||
counts = severity_counts(vulns)
|
||||
|
|
@ -645,7 +652,7 @@ def generate_report_pdf(run_dir: Path) -> bytes:
|
|||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=A4,
|
||||
title="Strix Security Report",
|
||||
title="Strix Original Scan Report",
|
||||
author="Strix",
|
||||
leftMargin=20 * mm,
|
||||
rightMargin=20 * mm,
|
||||
|
|
@ -658,7 +665,7 @@ def generate_report_pdf(run_dir: Path) -> bytes:
|
|||
story.extend(_overview_flowables(styles, record, len(vulns), counts))
|
||||
|
||||
story.append(PageBreak())
|
||||
story.append(_section(styles, "Findings"))
|
||||
story.append(_section(styles, "Detected findings"))
|
||||
story.append(Spacer(1, 16))
|
||||
if vulns:
|
||||
for index, vuln in enumerate(vulns, start=1):
|
||||
|
|
@ -693,7 +700,7 @@ def build_encrypted_report(run_dir: Path) -> tuple[bytes, str, str]:
|
|||
pdf_bytes = generate_report_pdf(run_dir)
|
||||
password = generate_password()
|
||||
encrypted = encrypt_pdf(pdf_bytes, password)
|
||||
filename = f"strix-report-{run_name}.pdf"
|
||||
filename = f"strix-original-scan-report-{run_name}.pdf"
|
||||
return encrypted, password, filename
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ Design notes:
|
|||
- Uses only the standard library (no new runtime dependency). The workload is
|
||||
serving static files plus a handful of JSON reads off disk, so an async stack
|
||||
buys nothing here.
|
||||
- The browser polls the JSON endpoints (~1s) rather than using SSE: a finished
|
||||
run stops polling, and short-lived polls survive sleep/network blips without
|
||||
- The browser polls the JSON endpoints rather than using SSE: finished runs
|
||||
poll a small revision token, and short-lived polls survive sleep/network blips without
|
||||
server-side connection state, which suits a stdlib ThreadingHTTPServer.
|
||||
- All reads happen per-request straight from disk, so the same server serves a
|
||||
live in-progress run and a finished one identically; the SPA distinguishes
|
||||
|
|
@ -23,7 +23,7 @@ import webbrowser
|
|||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import parse_qs, unquote, urlencode, urlsplit
|
||||
|
||||
from strix.core.paths import run_record_path
|
||||
|
|
@ -33,9 +33,14 @@ from strix.interface.viewer.transcript import (
|
|||
primary_target,
|
||||
read_report_markdown,
|
||||
read_run_summary,
|
||||
read_vulnerabilities,
|
||||
severity_counts,
|
||||
)
|
||||
from strix.report.triage import (
|
||||
TriageError,
|
||||
read_triaged_vulnerabilities,
|
||||
triage_finding,
|
||||
triage_stamp,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -66,7 +71,7 @@ def _iter_run_dirs(base_dir: Path) -> list[Path]:
|
|||
def run_list_entry(run_dir: Path) -> dict[str, Any]:
|
||||
"""Compact summary of a single run for the history list."""
|
||||
record = read_run_summary(run_dir)
|
||||
return {
|
||||
entry = {
|
||||
"name": record.get("run_name") or run_dir.name,
|
||||
"target": primary_target(record),
|
||||
"scan_mode": record.get("scan_mode"),
|
||||
|
|
@ -74,7 +79,20 @@ def run_list_entry(run_dir: Path) -> dict[str, Any]:
|
|||
"start_time": record.get("start_time"),
|
||||
"end_time": record.get("end_time"),
|
||||
"finished": bool(record.get("finished")),
|
||||
"severity_counts": severity_counts(read_vulnerabilities(run_dir)),
|
||||
}
|
||||
try:
|
||||
findings = read_triaged_vulnerabilities(run_dir)
|
||||
except TriageError:
|
||||
# Keep the run selectable without claiming its review counts are known.
|
||||
# Its findings endpoint still reports the error and preserves the data.
|
||||
return {**entry, "severity_counts": None}
|
||||
active = [finding for finding in findings if finding.get("status") != "closed"]
|
||||
return {
|
||||
**entry,
|
||||
"severity_counts": severity_counts(active),
|
||||
"open_count": len(active),
|
||||
"closed_count": len(findings) - len(active),
|
||||
"detected_count": len(findings),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -163,6 +181,8 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||
# The browser closed the connection mid-response (e.g. it
|
||||
# navigated away between polls). Not an error.
|
||||
logger.debug("viewer client disconnected during %s", path)
|
||||
except TriageError as exc:
|
||||
self._send_triage_error(exc)
|
||||
except Exception:
|
||||
# A bad request must never kill the worker thread.
|
||||
logger.exception("viewer request failed: %s", path)
|
||||
|
|
@ -185,10 +205,14 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||
self._handle_feedback()
|
||||
elif path == "/api/agents/steer":
|
||||
self._handle_steer()
|
||||
elif path.startswith("/api/vulnerabilities/") and path.endswith("/triage"):
|
||||
self._handle_triage(path)
|
||||
else:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown endpoint"})
|
||||
except BrokenPipeError:
|
||||
logger.debug("viewer client disconnected during POST %s", path)
|
||||
except TriageError as exc:
|
||||
self._send_triage_error(exc)
|
||||
except Exception:
|
||||
# A bad request must never kill the worker thread.
|
||||
logger.exception("viewer request failed: POST %s", path)
|
||||
|
|
@ -278,7 +302,9 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||
if path == "/api/run":
|
||||
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
|
||||
elif path == "/api/vulnerabilities":
|
||||
self._send_json(HTTPStatus.OK, read_vulnerabilities(run_dir))
|
||||
self._send_json(HTTPStatus.OK, read_triaged_vulnerabilities(run_dir))
|
||||
elif path == "/api/triage/revision":
|
||||
self._send_json(HTTPStatus.OK, {"revision": triage_stamp(run_dir)})
|
||||
elif path == "/api/report":
|
||||
self._send_json(HTTPStatus.OK, {"markdown": read_report_markdown(run_dir)})
|
||||
elif path == "/api/transcript":
|
||||
|
|
@ -286,6 +312,114 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||
else:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown endpoint"})
|
||||
|
||||
def _handle_triage(self, path: str) -> None:
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
# The capability cookie is host-scoped. An unrelated local server
|
||||
# must not be able to exercise it using cross-origin browser writes.
|
||||
origin = self.headers.get("Origin")
|
||||
expected_origin = f"http://{self.headers.get('Host', '')}"
|
||||
if origin != expected_origin or self.headers.get("Sec-Fetch-Site") not in (
|
||||
None,
|
||||
"same-origin",
|
||||
):
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden_origin"})
|
||||
return
|
||||
if self.headers.get_content_type() != "application/json":
|
||||
self._send_json(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, {"error": "invalid_request"})
|
||||
return
|
||||
run_dir = self._triage_run()
|
||||
if run_dir is None:
|
||||
return
|
||||
finding_id = unquote(path.removeprefix("/api/vulnerabilities/").removesuffix("/triage"))
|
||||
if not finding_id or "/" in finding_id or "\\" in finding_id:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_request"})
|
||||
return
|
||||
body = self._read_triage_body()
|
||||
if body is None:
|
||||
return
|
||||
result = triage_finding(
|
||||
run_dir,
|
||||
finding_id,
|
||||
status=body["status"],
|
||||
expected_revision=body["expected_revision"],
|
||||
reviewed_digest=body["reviewed_digest"],
|
||||
reason_code=body.get("reason_code", "unspecified"),
|
||||
note=body.get("note", ""),
|
||||
surface="viewer",
|
||||
)
|
||||
self._send_json(HTTPStatus.OK, result)
|
||||
|
||||
def _triage_run(self) -> Path | None:
|
||||
query = parse_qs(urlsplit(self.path).query)
|
||||
run_values = query.get("run", [])
|
||||
run_param = run_values[0] if run_values else None
|
||||
if len(run_values) > 1 or (
|
||||
run_param is not None
|
||||
and (run_param in {".", ".."} or "/" in run_param or "\\" in run_param)
|
||||
):
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown_run"})
|
||||
return None
|
||||
requested = state.base_dir / run_param if run_param else state.run_dir
|
||||
if requested.is_symlink():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "read_only"})
|
||||
return None
|
||||
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
||||
if run_dir is None:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown_run"})
|
||||
return None
|
||||
if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
|
||||
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||
return None
|
||||
return run_dir
|
||||
|
||||
def _read_triage_body(self) -> dict[str, Any] | None:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
length = -1
|
||||
if not 0 < length <= 32768 or self.headers.get("Transfer-Encoding"):
|
||||
self.close_connection = True
|
||||
status = (
|
||||
HTTPStatus.REQUEST_ENTITY_TOO_LARGE
|
||||
if length > 32768
|
||||
else HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
self._send_json(status, {"error": "invalid_request"})
|
||||
return None
|
||||
try:
|
||||
body = json.loads(self.rfile.read(length))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
body = None
|
||||
if not isinstance(body, dict):
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_request"})
|
||||
return None
|
||||
body = cast("dict[str, Any]", body)
|
||||
allowed = {"status", "expected_revision", "reviewed_digest", "reason_code", "note"}
|
||||
if (
|
||||
set(body) - allowed
|
||||
or not {"status", "expected_revision", "reviewed_digest"}.issubset(body)
|
||||
or not isinstance(body.get("status"), str)
|
||||
or type(body.get("expected_revision")) is not int
|
||||
or not isinstance(body.get("reviewed_digest"), str)
|
||||
or not isinstance(body.get("reason_code", "unspecified"), str)
|
||||
or not isinstance(body.get("note", ""), str)
|
||||
):
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_request"})
|
||||
return None
|
||||
return body
|
||||
|
||||
def _send_triage_error(self, exc: TriageError) -> None:
|
||||
status = {
|
||||
"conflict": HTTPStatus.CONFLICT,
|
||||
"invalid_request": HTTPStatus.BAD_REQUEST,
|
||||
"unknown_finding": HTTPStatus.NOT_FOUND,
|
||||
"invalid_triage": HTTPStatus.CONFLICT,
|
||||
"read_only": HTTPStatus.FORBIDDEN,
|
||||
}.get(exc.code, HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
self._send_json(status, {"error": exc.code, "message": str(exc)})
|
||||
|
||||
def _handle_auth_status(self) -> None:
|
||||
# The cached verified email is only disclosed to a caller holding this
|
||||
# process's session capability, so a cookie-less client on an exposed
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
10
strix/interface/viewer/static/assets/index-DNOJaOMX.css
Normal file
10
strix/interface/viewer/static/assets/index-DNOJaOMX.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
512
strix/interface/viewer/static/assets/index-RaZRtkBA.js
Normal file
512
strix/interface/viewer/static/assets/index-RaZRtkBA.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -6,8 +6,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
<script type="module" crossorigin src="./assets/index-B94ANU8d.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DN__rVv3.css">
|
||||
<script type="module" crossorigin src="./assets/index-RaZRtkBA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DNOJaOMX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
339
strix/report/triage.py
Normal file
339
strix/report/triage.py
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
"""Local human finding triage shared by the terminal UI and web viewer.
|
||||
|
||||
Scan evidence is never changed. The overlay is scoped to a run and reviewed
|
||||
evidence digest; updated evidence becomes active until it is reviewed again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from strix.report.triage_store import (
|
||||
TriageError,
|
||||
can_write_triage,
|
||||
locked_store,
|
||||
read_json,
|
||||
triage_stamp,
|
||||
write_store,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REASON_CODES = (
|
||||
"unspecified",
|
||||
"incorrect_assumption",
|
||||
"existing_protection",
|
||||
"not_affected",
|
||||
"expected_behavior",
|
||||
"other",
|
||||
)
|
||||
MAX_NOTE_LENGTH = 2000
|
||||
logger = logging.getLogger(__name__)
|
||||
_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\Z")
|
||||
_DIGEST = re.compile(r"[0-9a-f]{64}\Z")
|
||||
# Evidence, impact and scope determine what was reviewed. Titles, suggested
|
||||
# fixes, timestamps and display formatting do not invalidate a human review.
|
||||
_EVIDENCE_FIELDS = (
|
||||
"description",
|
||||
"impact",
|
||||
"technical_analysis",
|
||||
"evidence",
|
||||
"assumptions",
|
||||
"counterevidence",
|
||||
"confidence",
|
||||
"confidence_rationale",
|
||||
"severity_change_conditions",
|
||||
"poc_description",
|
||||
"poc_script_code",
|
||||
"target",
|
||||
"endpoint",
|
||||
"method",
|
||||
"cwe",
|
||||
"cve",
|
||||
"severity",
|
||||
"cvss",
|
||||
"code_locations",
|
||||
"dependency_metadata",
|
||||
"http_exchange_ids",
|
||||
)
|
||||
|
||||
|
||||
def _normalized(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return value.replace("\r\n", "\n").strip()
|
||||
if isinstance(value, dict):
|
||||
return {key: _normalized(item) for key, item in cast("dict[str, Any]", value).items()}
|
||||
if isinstance(value, list):
|
||||
items = cast("Sequence[Any]", value)
|
||||
return [_normalized(item) for item in items]
|
||||
return value
|
||||
|
||||
|
||||
def finding_digest(finding: dict[str, Any]) -> str:
|
||||
evidence = {key: _normalized(finding.get(key)) for key in _EVIDENCE_FIELDS}
|
||||
# Remediation suggestions inside locations are not evidence changes.
|
||||
locations = evidence.get("code_locations")
|
||||
if isinstance(locations, list):
|
||||
code_locations = cast("Sequence[Any]", locations)
|
||||
evidence["code_locations"] = [
|
||||
{
|
||||
key: value
|
||||
for key, value in cast("dict[str, Any]", location).items()
|
||||
if key not in {"fix_before", "fix_after"}
|
||||
}
|
||||
if isinstance(location, dict)
|
||||
else location
|
||||
for location in code_locations
|
||||
]
|
||||
payload = json.dumps(evidence, sort_keys=True, ensure_ascii=False, default=str)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _run_identity(run_dir: Path) -> dict[str, Any]:
|
||||
record = read_json(run_dir / "run.json", default={})
|
||||
if not isinstance(record, dict):
|
||||
raise TriageError("invalid_triage", "The run record is malformed.")
|
||||
record = cast("dict[str, Any]", record)
|
||||
return {
|
||||
"run_id": record.get("run_id") or record.get("run_name") or run_dir.name,
|
||||
"start_time": record.get("start_time"),
|
||||
}
|
||||
|
||||
|
||||
def _valid_id(value: Any) -> bool:
|
||||
return isinstance(value, str) and _ID.fullmatch(value) is not None
|
||||
|
||||
|
||||
def _validate_decision(decision: Any) -> None:
|
||||
if not isinstance(decision, dict):
|
||||
raise TriageError("invalid_triage", "A saved review is malformed.")
|
||||
decision = cast("dict[str, Any]", decision)
|
||||
status = decision.get("status")
|
||||
if (
|
||||
not isinstance(status, str)
|
||||
or status not in {"open", "closed"}
|
||||
or type(decision.get("revision")) is not int
|
||||
or decision["revision"] < 1
|
||||
or decision.get("reason_code") not in REASON_CODES
|
||||
or not isinstance(decision.get("note"), str)
|
||||
or len(decision["note"]) > MAX_NOTE_LENGTH
|
||||
or not isinstance(decision.get("changed_at"), str)
|
||||
or decision.get("changed_by") != "local_operator"
|
||||
or not isinstance(decision.get("reviewed_digest"), str)
|
||||
or not _DIGEST.fullmatch(decision["reviewed_digest"])
|
||||
or decision.get("resolution_reason") != ("false_positive" if status == "closed" else None)
|
||||
):
|
||||
raise TriageError("invalid_triage", "A saved review has unsupported fields.")
|
||||
|
||||
|
||||
def _read_store(run_dir: Path) -> dict[str, Any]:
|
||||
identity = _run_identity(run_dir)
|
||||
missing = object()
|
||||
document = read_json(run_dir / "triage.json", default=missing)
|
||||
if document is missing:
|
||||
return {"schema_version": 1, "run_identity": identity, "findings": {}}
|
||||
if not isinstance(document, dict):
|
||||
raise TriageError("invalid_triage", "Review data belongs to another run or is unsupported.")
|
||||
document = cast("dict[str, Any]", document)
|
||||
if (
|
||||
type(document.get("schema_version")) is not int
|
||||
or document["schema_version"] != 1
|
||||
or document.get("run_identity") != identity
|
||||
or not isinstance(document.get("findings"), dict)
|
||||
):
|
||||
raise TriageError("invalid_triage", "Review data belongs to another run or is unsupported.")
|
||||
for finding_id, raw_decision in cast("dict[str, Any]", document["findings"]).items():
|
||||
if not _valid_id(finding_id):
|
||||
raise TriageError("invalid_triage", "A saved finding ID is invalid.")
|
||||
_validate_decision(raw_decision)
|
||||
decision = cast("dict[str, Any]", raw_decision)
|
||||
history = decision.get("history")
|
||||
if not isinstance(history, list):
|
||||
raise TriageError("invalid_triage", "Review history is malformed.")
|
||||
entries = cast("Sequence[Any]", history)
|
||||
if len(entries) != decision["revision"]:
|
||||
raise TriageError("invalid_triage", "Review history is malformed.")
|
||||
for index, entry in enumerate(entries, 1):
|
||||
_validate_decision(entry)
|
||||
if entry["revision"] != index:
|
||||
raise TriageError("invalid_triage", "Review history revisions are inconsistent.")
|
||||
if {key: value for key, value in decision.items() if key != "history"} != entries[-1]:
|
||||
raise TriageError("invalid_triage", "Review history does not match the saved decision.")
|
||||
return document
|
||||
|
||||
|
||||
def _read_findings(run_dir: Path) -> list[dict[str, Any]]:
|
||||
reports = read_json(run_dir / "vulnerabilities.json", default=[], limit=128 * 1024 * 1024)
|
||||
if not isinstance(reports, list):
|
||||
raise TriageError("invalid_triage", "The finding list is malformed.")
|
||||
records = cast("Sequence[Any]", reports)
|
||||
if any(not isinstance(item, dict) for item in records):
|
||||
raise TriageError("invalid_triage", "The finding list is malformed.")
|
||||
return cast("list[dict[str, Any]]", records)
|
||||
|
||||
|
||||
def _project(
|
||||
report: dict[str, Any], decision: dict[str, Any] | None, *, writable: bool
|
||||
) -> dict[str, Any]:
|
||||
saved = decision or {}
|
||||
digest = finding_digest(report)
|
||||
stored_status = saved.get("status", "open")
|
||||
stale = stored_status == "closed" and saved.get("reviewed_digest") != digest
|
||||
return {
|
||||
**report,
|
||||
"status": "open" if stale else stored_status,
|
||||
"triage_status": stored_status,
|
||||
"resolution_reason": saved.get("resolution_reason"),
|
||||
"reason_code": saved.get("reason_code", "unspecified"),
|
||||
"status_note": saved.get("note") or None,
|
||||
"status_changed_at": saved.get("changed_at"),
|
||||
"status_changed_by": saved.get("changed_by"),
|
||||
"triage_revision": saved.get("revision", 0),
|
||||
"finding_digest": digest,
|
||||
"review_stale": stale,
|
||||
"can_triage": writable,
|
||||
"triage_history": saved.get("history", []),
|
||||
}
|
||||
|
||||
|
||||
def read_triaged_vulnerabilities(
|
||||
run_dir: Path, reports: list[dict[str, Any]] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Join a validated sidecar with disk or in-memory evidence without mutating it."""
|
||||
document = _read_store(run_dir)
|
||||
raw = _read_findings(run_dir) if reports is None else reports
|
||||
ids = Counter(item.get("id") for item in raw if _valid_id(item.get("id")))
|
||||
writable = can_write_triage(run_dir)
|
||||
return [
|
||||
_project(
|
||||
report,
|
||||
document["findings"].get(report.get("id")) if _valid_id(report.get("id")) else None,
|
||||
writable=writable and _valid_id(report.get("id")) and ids[report["id"]] == 1,
|
||||
)
|
||||
for report in raw
|
||||
]
|
||||
|
||||
|
||||
def _validate_request(
|
||||
finding_id: Any,
|
||||
status: Any,
|
||||
expected_revision: Any,
|
||||
reviewed_digest: Any,
|
||||
reason_code: Any,
|
||||
note: Any,
|
||||
surface: Any,
|
||||
) -> None:
|
||||
if (
|
||||
not _valid_id(finding_id)
|
||||
or not isinstance(status, str)
|
||||
or status not in {"open", "closed"}
|
||||
or type(expected_revision) is not int
|
||||
or expected_revision < 0
|
||||
or not isinstance(reviewed_digest, str)
|
||||
or not _DIGEST.fullmatch(reviewed_digest)
|
||||
or not isinstance(reason_code, str)
|
||||
or reason_code not in REASON_CODES
|
||||
or not isinstance(note, str)
|
||||
or len(note) > MAX_NOTE_LENGTH
|
||||
or not isinstance(surface, str)
|
||||
or surface not in {"viewer", "tui"}
|
||||
):
|
||||
raise TriageError("invalid_request", "Invalid finding review request.")
|
||||
|
||||
|
||||
def triage_finding(
|
||||
run_dir: Path,
|
||||
finding_id: str,
|
||||
*,
|
||||
status: str,
|
||||
expected_revision: int,
|
||||
reviewed_digest: str,
|
||||
reason_code: str = "unspecified",
|
||||
note: str = "",
|
||||
surface: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Commit a human decision, then enqueue only its bounded classification metadata."""
|
||||
_validate_request(
|
||||
finding_id, status, expected_revision, reviewed_digest, reason_code, note, surface
|
||||
)
|
||||
with locked_store(run_dir):
|
||||
if not (run_dir / "run.json").is_file():
|
||||
raise TriageError("unknown_finding", "The run record is missing.")
|
||||
document = _read_store(run_dir)
|
||||
reports = _read_findings(run_dir)
|
||||
matches = [report for report in reports if report.get("id") == finding_id]
|
||||
if len(matches) != 1:
|
||||
raise TriageError("unknown_finding", "The finding is missing or its ID is ambiguous.")
|
||||
report = matches[0]
|
||||
old = document["findings"].get(finding_id)
|
||||
previous = _project(report, old, writable=True)
|
||||
if previous["finding_digest"] != reviewed_digest:
|
||||
raise TriageError("conflict", "Finding evidence changed. Reload and review it again.")
|
||||
reason = reason_code if status == "closed" else "unspecified"
|
||||
saved_note = note.strip() if status == "closed" else ""
|
||||
same = (
|
||||
previous["status"] == status
|
||||
and not previous["review_stale"]
|
||||
and previous["reason_code"] == reason
|
||||
and (previous["status_note"] or "") == saved_note
|
||||
)
|
||||
# A retry with the immediately preceding revision is an idempotent no-op.
|
||||
revision = previous["triage_revision"]
|
||||
if expected_revision != revision:
|
||||
if same and old is not None and expected_revision == revision - 1:
|
||||
return {"changed": False, "finding": previous}
|
||||
raise TriageError(
|
||||
"conflict", "This finding was reviewed elsewhere. Reload before saving."
|
||||
)
|
||||
if same:
|
||||
return {"changed": False, "finding": previous}
|
||||
decision: dict[str, Any] = {
|
||||
"status": status,
|
||||
"resolution_reason": "false_positive" if status == "closed" else None,
|
||||
"reason_code": reason,
|
||||
"note": saved_note,
|
||||
"changed_at": datetime.now(UTC).isoformat(),
|
||||
"changed_by": "local_operator",
|
||||
"revision": revision + 1,
|
||||
"reviewed_digest": reviewed_digest,
|
||||
}
|
||||
history: list[dict[str, Any]] = [*(old["history"] if old else []), decision.copy()]
|
||||
document["findings"][finding_id] = {**decision, "history": history}
|
||||
write_store(run_dir, document)
|
||||
result = _project(report, document["findings"][finding_id], writable=True)
|
||||
# Notes-only edits remain entirely local. Never allow analytics failure to
|
||||
# turn a committed local review into an apparent save failure.
|
||||
if (
|
||||
previous["status"] != status
|
||||
or previous["reason_code"] != reason
|
||||
or previous["review_stale"]
|
||||
):
|
||||
try:
|
||||
from strix.telemetry.triage import record_triage # noqa: PLC0415
|
||||
|
||||
record_triage(previous, result, run_dir=run_dir, surface=surface)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Saved review metric was skipped")
|
||||
return {"changed": True, "finding": result}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_NOTE_LENGTH",
|
||||
"REASON_CODES",
|
||||
"TriageError",
|
||||
"can_write_triage",
|
||||
"finding_digest",
|
||||
"read_triaged_vulnerabilities",
|
||||
"triage_finding",
|
||||
"triage_stamp",
|
||||
]
|
||||
192
strix/report/triage_store.py
Normal file
192
strix/report/triage_store.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""Bounded, atomic storage for human review decisions, separate from scan output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
|
||||
MAX_STORE_BYTES = 16 * 1024 * 1024
|
||||
LOCK_TIMEOUT = 3.0
|
||||
_locks: dict[Path, threading.Lock] = {}
|
||||
_locks_guard = threading.Lock()
|
||||
|
||||
|
||||
class TriageError(Exception):
|
||||
"""A user-actionable review failure with a stable interface error code."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def regular_file(path: Path) -> bool:
|
||||
try:
|
||||
return stat.S_ISREG(path.lstat().st_mode)
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def can_write_triage(run_dir: Path) -> bool:
|
||||
"""Read-only capability check. Never creates a sidecar or lockfile."""
|
||||
try:
|
||||
if run_dir.is_symlink() or not run_dir.is_dir() or not (run_dir / "run.json").is_file():
|
||||
return False
|
||||
if not run_dir.stat().st_mode & 0o222 or not os.access(run_dir, os.W_OK):
|
||||
return False
|
||||
for name in ("triage.json", "triage.lock", "run.json", "vulnerabilities.json"):
|
||||
path = run_dir / name
|
||||
if not regular_file(path):
|
||||
return False
|
||||
if (
|
||||
name in {"triage.json", "triage.lock"}
|
||||
and path.exists()
|
||||
and (not path.stat().st_mode & 0o222 or not os.access(path, os.W_OK))
|
||||
):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def read_json(path: Path, *, default: Any, limit: int = MAX_STORE_BYTES) -> Any:
|
||||
"""Read a regular file only, rejecting links/devices and malformed contents."""
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
||||
try:
|
||||
if not regular_file(path):
|
||||
raise TriageError("invalid_triage", "Run data must be a regular file, not a link.")
|
||||
descriptor = os.open(path, flags)
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
|
||||
raise TriageError("invalid_triage", "Run data is not a supported regular file.")
|
||||
content = stream.read(limit + 1)
|
||||
if len(content) > limit:
|
||||
raise TriageError("invalid_triage", "Run data exceeds the supported size.")
|
||||
return json.loads(content)
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
except (ValueError, UnicodeError, RecursionError) as exc:
|
||||
raise TriageError(
|
||||
"invalid_triage", "Run data is malformed; no decisions were changed."
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise TriageError("unavailable", "Could not read the run's review data.") from exc
|
||||
|
||||
|
||||
def triage_stamp(run_dir: Path) -> tuple[int, int]:
|
||||
"""Opaque token for review/evidence changes, including resumed finished runs."""
|
||||
stamps: list[tuple[int, int, int]] = []
|
||||
for name in ("triage.json", "vulnerabilities.json", "run.json"):
|
||||
try:
|
||||
info = (run_dir / name).lstat()
|
||||
stamps.append((info.st_mtime_ns, info.st_size, info.st_ino))
|
||||
except FileNotFoundError:
|
||||
stamps.append((0, 0, 0))
|
||||
except OSError:
|
||||
stamps.append((-1, -1, -1))
|
||||
digest = hashlib.sha256(repr(stamps).encode("ascii")).digest()
|
||||
# Keep each number exactly representable in browser JavaScript.
|
||||
return int.from_bytes(digest[:6]), int.from_bytes(digest[6:12])
|
||||
|
||||
|
||||
def _file_lock(descriptor: int, *, unlock: bool = False) -> None:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt # noqa: PLC0415
|
||||
|
||||
# The CRT permits locking beyond EOF, so even an empty file has byte zero
|
||||
# available as a stable lock region. Always unlock that same region.
|
||||
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||
msvcrt.locking(descriptor, msvcrt.LK_UNLCK if unlock else msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl # noqa: PLC0415
|
||||
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN if unlock else fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def locked_store(run_dir: Path) -> Generator[None]:
|
||||
"""Serialize threads and processes using a stable inode, never the replaced JSON."""
|
||||
if not can_write_triage(run_dir):
|
||||
raise TriageError("read_only", "This run is read-only or has unsafe review file paths.")
|
||||
with _locks_guard:
|
||||
thread_lock = _locks.setdefault(run_dir.resolve(), threading.Lock())
|
||||
if not thread_lock.acquire(timeout=LOCK_TIMEOUT):
|
||||
raise TriageError("unavailable", "Another review is being saved. Try again.")
|
||||
descriptor: int | None = None
|
||||
acquired = False
|
||||
try:
|
||||
path = run_dir / "triage.lock"
|
||||
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
||||
raise TriageError("read_only", "The review lock must be a regular file.")
|
||||
deadline = time.monotonic() + LOCK_TIMEOUT
|
||||
while True:
|
||||
try:
|
||||
_file_lock(descriptor)
|
||||
acquired = True
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TriageError(
|
||||
"unavailable", "Could not lock review data. Try again."
|
||||
) from None
|
||||
time.sleep(0.025)
|
||||
yield
|
||||
except PermissionError as exc:
|
||||
raise TriageError("read_only", "This run's review data is read-only.") from exc
|
||||
except OSError as exc:
|
||||
raise TriageError(
|
||||
"unavailable", "Could not save the review. Reload before retrying."
|
||||
) from exc
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
if acquired:
|
||||
with contextlib.suppress(OSError):
|
||||
_file_lock(descriptor, unlock=True)
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(descriptor)
|
||||
thread_lock.release()
|
||||
|
||||
|
||||
def write_store(run_dir: Path, document: dict[str, Any]) -> None:
|
||||
"""Commit complete JSON durably; callers must hold locked_store."""
|
||||
payload = json.dumps(document, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
if len(payload) > MAX_STORE_BYTES:
|
||||
raise TriageError("invalid_triage", "Review history exceeds the supported size.")
|
||||
path = run_dir / "triage.json"
|
||||
if not regular_file(path):
|
||||
raise TriageError("invalid_triage", "Review data must be a regular file, not a link.")
|
||||
temporary: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(dir=run_dir, prefix=".triage-", delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(path)
|
||||
if os.name != "nt":
|
||||
descriptor = os.open(run_dir, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
|
@ -17,7 +17,8 @@ We collect only very **basic** usage data including:
|
|||
**Scan Context:** Scan mode (quick/standard/deep), scan type (whitebox/blackbox)\
|
||||
**Model Usage:** Which LLM model is being used and whether it runs via an API key or a model subscription (not prompts or responses)\
|
||||
**Feature Usage:** Which built-in skills were used during a scan (reported once, at scan end)\
|
||||
**Aggregate Metrics:** Vulnerability counts by severity and weakness category (CWE)
|
||||
**Aggregate Metrics:** Vulnerability counts by severity and weakness category (CWE)\
|
||||
**Finding Triage:** Anonymous metadata about false-positive decisions and reopenings when telemetry is enabled; excludes notes and finding content.
|
||||
|
||||
### What We **Never** Collect
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,11 @@ def finding(severity: str, cwe: str | None = None, is_cve: bool = False) -> None
|
|||
)
|
||||
|
||||
|
||||
def finding_triage_changed(properties: dict[str, Any]) -> None:
|
||||
"""Send the allowlisted metadata built by the local review metrics module."""
|
||||
_send("finding_triage_changed", properties)
|
||||
|
||||
|
||||
def end(report_state: "ReportState", exit_reason: str = "completed") -> None:
|
||||
if report_state.posthog_scan_ended_sent:
|
||||
return
|
||||
|
|
|
|||
113
strix/telemetry/triage.py
Normal file
113
strix/telemetry/triage.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Best-effort classification metrics; never includes finding content or notes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.report.triage_store import read_json
|
||||
from strix.telemetry import posthog
|
||||
from strix.telemetry._common import base_props
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=128)
|
||||
_worker: threading.Thread | None = None
|
||||
_guard = threading.Lock()
|
||||
_REASONS = frozenset(
|
||||
{
|
||||
"unspecified",
|
||||
"incorrect_assumption",
|
||||
"existing_protection",
|
||||
"not_affected",
|
||||
"expected_behavior",
|
||||
"other",
|
||||
}
|
||||
)
|
||||
_CWE = re.compile(r"cwe-[1-9][0-9]{0,5}\Z")
|
||||
|
||||
|
||||
def _category(value: Any, allowed: set[str] | frozenset[str]) -> str:
|
||||
normalized = value.lower().strip() if isinstance(value, str) else ""
|
||||
return normalized if normalized in allowed else "unknown"
|
||||
|
||||
|
||||
def _cwe(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return "unknown"
|
||||
normalized = value.lower().strip()
|
||||
return normalized if _CWE.fullmatch(normalized) else "unknown"
|
||||
|
||||
|
||||
def _deliver() -> None:
|
||||
global _worker # noqa: PLW0603
|
||||
while True:
|
||||
# Share the producer's lock while retiring: an event arriving as the
|
||||
# queue empties must either find this worker or start its replacement.
|
||||
with _guard:
|
||||
try:
|
||||
properties = _queue.get_nowait()
|
||||
except queue.Empty:
|
||||
_worker = None
|
||||
return
|
||||
try:
|
||||
# The final sender also checks the current setting. No disk backlog
|
||||
# survives this process, and disabled events are never enqueued.
|
||||
if load_settings().telemetry.enabled:
|
||||
posthog.finding_triage_changed(properties)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Review metric delivery failed")
|
||||
finally:
|
||||
_queue.task_done()
|
||||
|
||||
|
||||
def _enqueue(properties: dict[str, Any]) -> None:
|
||||
global _worker # noqa: PLW0603
|
||||
with _guard:
|
||||
_queue.put_nowait(properties)
|
||||
if _worker is None or not _worker.is_alive():
|
||||
_worker = threading.Thread(target=_deliver, name="strix-triage-metrics", daemon=True)
|
||||
_worker.start()
|
||||
|
||||
|
||||
def record_triage(
|
||||
previous: dict[str, Any], current: dict[str, Any], *, run_dir: Path, surface: str
|
||||
) -> None:
|
||||
"""Build an allowlisted event after commit; errors never reach the user's action."""
|
||||
try:
|
||||
if not load_settings().telemetry.enabled:
|
||||
return
|
||||
record = read_json(run_dir / "run.json", default={})
|
||||
mode = cast("dict[str, Any]", record).get("scan_mode") if isinstance(record, dict) else None
|
||||
previous_resolution = previous.get("resolution_reason")
|
||||
resolution = current.get("resolution_reason")
|
||||
properties = {
|
||||
**base_props(),
|
||||
"schema_version": 1,
|
||||
"surface": _category(surface, {"viewer", "tui"}),
|
||||
"previous_status": _category(previous.get("status"), {"open", "closed"}),
|
||||
"new_status": _category(current.get("status"), {"open", "closed"}),
|
||||
"previous_resolution_reason": (
|
||||
"false_positive" if previous_resolution == "false_positive" else None
|
||||
),
|
||||
"resolution_reason": "false_positive" if resolution == "false_positive" else None,
|
||||
"reason_code": _category(current.get("reason_code"), _REASONS),
|
||||
"severity": _category(
|
||||
current.get("severity"), {"critical", "high", "medium", "low", "info"}
|
||||
),
|
||||
"cwe": _cwe(current.get("cwe")),
|
||||
"is_cve": bool(current.get("cve")),
|
||||
"scan_mode": _category(mode, {"quick", "standard", "deep"}),
|
||||
}
|
||||
if load_settings().telemetry.enabled:
|
||||
_enqueue(properties)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Review metric skipped")
|
||||
|
|
@ -22,6 +22,7 @@ from strix.interface.viewer.report_pdf import (
|
|||
generate_password,
|
||||
generate_report_pdf,
|
||||
)
|
||||
from strix.report.triage import read_triaged_vulnerabilities, triage_finding
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -46,6 +47,7 @@ def _make_run(base: Path, name: str = "sample") -> Path:
|
|||
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
|
||||
vulns = [
|
||||
{
|
||||
"id": "vuln-0001",
|
||||
"title": "SQL Injection",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.8,
|
||||
|
|
@ -60,7 +62,7 @@ def _make_run(base: Path, name: str = "sample") -> Path:
|
|||
"endpoint": "/login",
|
||||
"method": "POST",
|
||||
},
|
||||
{"title": "Informational note", "severity": "info"},
|
||||
{"id": "vuln-0002", "title": "Informational note", "severity": "info"},
|
||||
]
|
||||
(run_dir / "vulnerabilities.json").write_text(json.dumps(vulns), encoding="utf-8")
|
||||
return run_dir
|
||||
|
|
@ -77,6 +79,50 @@ def test_generate_report_pdf_has_pdf_header(tmp_path: Path) -> None:
|
|||
assert len(pdf) > 1000
|
||||
|
||||
|
||||
def test_generate_report_pdf_labels_original_scan_and_detected_counts(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path)
|
||||
pdf = generate_report_pdf(run_dir)
|
||||
reader = PdfReader(BytesIO(pdf))
|
||||
cover = reader.pages[0].extract_text()
|
||||
text = " ".join(_pdf_text(pdf).split())
|
||||
|
||||
assert "Original scan report" in cover
|
||||
assert "Subsequent local triage decisions" in cover
|
||||
assert "false-positive closures and notes, are excluded." in " ".join(cover.split())
|
||||
assert "2 detected findings across this assessment." in text
|
||||
assert "Detected findings" in text
|
||||
assert reader.metadata is not None
|
||||
assert reader.metadata.title == "Strix Original Scan Report"
|
||||
|
||||
|
||||
def test_original_report_preserves_closed_findings_and_excludes_triage_notes(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr("strix.telemetry.triage.record_triage", lambda *_args, **_kwargs: None)
|
||||
run_dir = _make_run(tmp_path)
|
||||
original = _pdf_text(generate_report_pdf(run_dir))
|
||||
for finding in read_triaged_vulnerabilities(run_dir):
|
||||
triage_finding(
|
||||
run_dir,
|
||||
finding["id"],
|
||||
status="closed",
|
||||
expected_revision=finding["triage_revision"],
|
||||
reviewed_digest=finding["finding_digest"],
|
||||
reason_code="incorrect_assumption",
|
||||
note="Private local review context must stay out of the original report.",
|
||||
surface="viewer",
|
||||
)
|
||||
|
||||
assert all(finding["status"] == "closed" for finding in read_triaged_vulnerabilities(run_dir))
|
||||
exported = _pdf_text(generate_report_pdf(run_dir))
|
||||
assert exported == original
|
||||
assert "2 detected findings" in exported
|
||||
assert "SQL Injection" in exported
|
||||
assert "HTTP 500 with SQL error." in exported
|
||||
assert "Informational note" in exported
|
||||
assert "Private local review context" not in exported
|
||||
|
||||
|
||||
def test_generate_password_is_long_and_random() -> None:
|
||||
first = generate_password()
|
||||
second = generate_password()
|
||||
|
|
@ -108,7 +154,7 @@ def test_build_encrypted_report(tmp_path: Path) -> None:
|
|||
run_dir = _make_run(tmp_path, name="run-42")
|
||||
pdf_bytes, password, filename = build_encrypted_report(run_dir)
|
||||
|
||||
assert filename == "strix-report-run-42.pdf"
|
||||
assert filename == "strix-original-scan-report-run-42.pdf"
|
||||
assert len(password) >= 20
|
||||
reader = PdfReader(BytesIO(pdf_bytes))
|
||||
assert reader.is_encrypted
|
||||
|
|
|
|||
325
tests/test_triage.py
Normal file
325
tests/test_triage.py
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"""Persistence, concurrency and evidence invariants for local human review."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import multiprocessing
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.report import triage, triage_store
|
||||
from strix.report.triage import TriageError, read_triaged_vulnerabilities, triage_finding
|
||||
from strix.report.triage_store import triage_stamp
|
||||
from strix.telemetry import triage as metrics
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
monkeypatch.setenv("STRIX_TELEMETRY", "0")
|
||||
path = tmp_path / "run"
|
||||
path.mkdir()
|
||||
(path / "run.json").write_text(json.dumps({"run_id": "run", "start_time": "2026-09-16"}))
|
||||
reports = [
|
||||
{
|
||||
"id": f"vuln-{i:04d}",
|
||||
"title": f"Finding {i}",
|
||||
"evidence": f"Evidence {i}",
|
||||
"severity": "high",
|
||||
"timestamp": "2026-09-16 12:00:00 UTC",
|
||||
}
|
||||
for i in (1, 2)
|
||||
]
|
||||
(path / "vulnerabilities.json").write_text(json.dumps(reports))
|
||||
monkeypatch.setattr(metrics, "record_triage", Mock())
|
||||
return path
|
||||
|
||||
|
||||
def close(run_dir: Path, finding_id: str = "vuln-0001", **kwargs: Any) -> dict[str, Any]:
|
||||
finding = next(
|
||||
item for item in read_triaged_vulnerabilities(run_dir) if item["id"] == finding_id
|
||||
)
|
||||
values = {
|
||||
"status": "closed",
|
||||
"expected_revision": finding["triage_revision"],
|
||||
"reviewed_digest": finding["finding_digest"],
|
||||
"reason_code": "incorrect_assumption",
|
||||
"surface": "tui",
|
||||
**kwargs,
|
||||
}
|
||||
return triage_finding(run_dir, finding_id, **values)
|
||||
|
||||
|
||||
def test_legacy_read_is_open_and_does_not_write(run_dir: Path) -> None:
|
||||
before = {p.name: p.read_bytes() for p in run_dir.iterdir()}
|
||||
findings = read_triaged_vulnerabilities(run_dir)
|
||||
assert all(item["status"] == "open" and item["can_triage"] for item in findings)
|
||||
assert all(item["triage_revision"] == 0 for item in findings)
|
||||
assert before == {p.name: p.read_bytes() for p in run_dir.iterdir()}
|
||||
|
||||
|
||||
def test_close_reopen_preserves_evidence_and_history(run_dir: Path) -> None:
|
||||
evidence = (run_dir / "vulnerabilities.json").read_bytes()
|
||||
result = close(run_dir, note="My private explanation")
|
||||
assert result["changed"] is True
|
||||
finding = read_triaged_vulnerabilities(run_dir)[0]
|
||||
assert finding["status"] == "closed"
|
||||
assert finding["status_note"] == "My private explanation"
|
||||
reopened = close(run_dir, status="open")["finding"]
|
||||
assert reopened["status"] == "open"
|
||||
assert reopened["status_note"] is None
|
||||
assert reopened["resolution_reason"] is None
|
||||
assert [event["status"] for event in reopened["triage_history"]] == ["closed", "open"]
|
||||
assert reopened["triage_history"][0]["note"] == "My private explanation"
|
||||
assert (run_dir / "vulnerabilities.json").read_bytes() == evidence
|
||||
assert metrics.record_triage.call_count == 2 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_repeated_request_is_idempotent_and_note_edits_stay_local(run_dir: Path) -> None:
|
||||
first = read_triaged_vulnerabilities(run_dir)[0]
|
||||
closed = close(run_dir)
|
||||
assert close(run_dir)["changed"] is False
|
||||
assert close(run_dir, expected_revision=0)["changed"] is False
|
||||
assert closed["finding"]["triage_revision"] == 1
|
||||
updated = close(run_dir, note="Local note only")
|
||||
assert updated["finding"]["triage_revision"] == 2
|
||||
assert metrics.record_triage.call_count == 1 # type: ignore[attr-defined]
|
||||
with pytest.raises(TriageError, match="reviewed elsewhere"):
|
||||
close(run_dir, status="open", expected_revision=first["triage_revision"])
|
||||
|
||||
|
||||
def test_evidence_changes_require_new_review(run_dir: Path) -> None:
|
||||
old = read_triaged_vulnerabilities(run_dir)[0]
|
||||
close(run_dir)
|
||||
raw = json.loads((run_dir / "vulnerabilities.json").read_text())
|
||||
raw[0]["evidence"] = "New evidence changes the claim"
|
||||
(run_dir / "vulnerabilities.json").write_text(json.dumps(raw))
|
||||
finding = read_triaged_vulnerabilities(run_dir)[0]
|
||||
assert finding["status"] == "open" and finding["review_stale"]
|
||||
assert finding["triage_status"] == "closed"
|
||||
assert len(finding["triage_history"]) == 1
|
||||
with pytest.raises(TriageError, match="evidence changed"):
|
||||
close(run_dir, reviewed_digest=old["finding_digest"])
|
||||
result = close(run_dir)["finding"]
|
||||
assert result["triage_revision"] == 2
|
||||
assert result["status"] == "closed" and not result["review_stale"]
|
||||
|
||||
|
||||
def test_presentation_and_fix_changes_do_not_invalidate_review(run_dir: Path) -> None:
|
||||
close(run_dir)
|
||||
raw = json.loads((run_dir / "vulnerabilities.json").read_text())
|
||||
raw[0].update(title="Clearer title", remediation_steps="A better fix", timestamp="later")
|
||||
(run_dir / "vulnerabilities.json").write_text(json.dumps(raw))
|
||||
assert read_triaged_vulnerabilities(run_dir)[0]["status"] == "closed"
|
||||
|
||||
|
||||
def test_projection_does_not_modify_in_memory_agent_reports(run_dir: Path) -> None:
|
||||
close(run_dir)
|
||||
raw = json.loads((run_dir / "vulnerabilities.json").read_text())
|
||||
before = json.dumps(raw)
|
||||
assert read_triaged_vulnerabilities(run_dir, raw)[0]["status"] == "closed"
|
||||
assert json.dumps(raw) == before
|
||||
assert len(raw) == 2 and "status" not in raw[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", ["{", "[]", '{"schema_version": 99}', "null"])
|
||||
def test_invalid_sidecar_is_never_overwritten(run_dir: Path, payload: str) -> None:
|
||||
finding = read_triaged_vulnerabilities(run_dir)[0]
|
||||
(run_dir / "triage.json").write_text(payload)
|
||||
with pytest.raises(TriageError):
|
||||
triage_finding(
|
||||
run_dir,
|
||||
"vuln-0001",
|
||||
status="closed",
|
||||
expected_revision=0,
|
||||
reviewed_digest=finding["finding_digest"],
|
||||
surface="tui",
|
||||
)
|
||||
assert (run_dir / "triage.json").read_text() == payload
|
||||
metrics.record_triage.assert_not_called() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_reused_run_identity_cannot_inherit_decision(run_dir: Path) -> None:
|
||||
close(run_dir)
|
||||
(run_dir / "run.json").write_text(json.dumps({"run_id": "run", "start_time": "new scan"}))
|
||||
with pytest.raises(TriageError, match="another run"):
|
||||
read_triaged_vulnerabilities(run_dir)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("status", []),
|
||||
("reason_code", {}),
|
||||
("expected_revision", True),
|
||||
("expected_revision", -1),
|
||||
("reviewed_digest", "arbitrary"),
|
||||
("note", "a" * 2001),
|
||||
("note", []),
|
||||
("surface", "untrusted"),
|
||||
],
|
||||
)
|
||||
def test_bad_requests_are_rejected(run_dir: Path, field: str, value: Any) -> None:
|
||||
with pytest.raises(TriageError) as error:
|
||||
close(run_dir, **{field: value})
|
||||
assert error.value.code == "invalid_request"
|
||||
assert not (run_dir / "triage.json").exists()
|
||||
|
||||
|
||||
def test_duplicate_and_synthetic_ids_are_not_writable(run_dir: Path) -> None:
|
||||
raw = json.loads((run_dir / "vulnerabilities.json").read_text())
|
||||
raw[1]["id"] = raw[0]["id"]
|
||||
raw.append({"title": "No ID"})
|
||||
(run_dir / "vulnerabilities.json").write_text(json.dumps(raw))
|
||||
assert not any(item["can_triage"] for item in read_triaged_vulnerabilities(run_dir))
|
||||
with pytest.raises(TriageError) as error:
|
||||
close(run_dir)
|
||||
assert error.value.code == "unknown_finding"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["triage.json", "triage.lock"])
|
||||
def test_symlink_cannot_redirect_writes(run_dir: Path, name: str) -> None:
|
||||
finding = read_triaged_vulnerabilities(run_dir)[0]
|
||||
outside = run_dir.parent / "outside"
|
||||
outside.write_text("unchanged")
|
||||
(run_dir / name).symlink_to(outside)
|
||||
with pytest.raises(TriageError):
|
||||
triage_finding(
|
||||
run_dir,
|
||||
"vuln-0001",
|
||||
status="closed",
|
||||
expected_revision=0,
|
||||
reviewed_digest=finding["finding_digest"],
|
||||
surface="tui",
|
||||
)
|
||||
assert outside.read_text() == "unchanged"
|
||||
|
||||
|
||||
def test_read_only_directory_still_displays_findings(run_dir: Path) -> None:
|
||||
run_dir.chmod(0o555)
|
||||
try:
|
||||
assert not any(item["can_triage"] for item in read_triaged_vulnerabilities(run_dir))
|
||||
with pytest.raises(TriageError) as error:
|
||||
close(run_dir)
|
||||
assert error.value.code == "read_only"
|
||||
finally:
|
||||
run_dir.chmod(0o755)
|
||||
|
||||
|
||||
def test_failed_save_has_no_success_event(run_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(triage, "write_store", Mock(side_effect=OSError("disk unavailable")))
|
||||
with pytest.raises(TriageError):
|
||||
close(run_dir)
|
||||
assert not (run_dir / "triage.json").exists()
|
||||
metrics.record_triage.assert_not_called() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_telemetry_exception_cannot_fail_committed_review(
|
||||
run_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(metrics, "record_triage", Mock(side_effect=RuntimeError("offline")))
|
||||
assert close(run_dir)["changed"]
|
||||
assert read_triaged_vulnerabilities(run_dir)[0]["status"] == "closed"
|
||||
|
||||
|
||||
def test_change_token_tracks_evidence_and_sidecar(run_dir: Path) -> None:
|
||||
initial = triage_stamp(run_dir)
|
||||
close(run_dir)
|
||||
reviewed = triage_stamp(run_dir)
|
||||
assert reviewed != initial
|
||||
reports = json.loads((run_dir / "vulnerabilities.json").read_text())
|
||||
reports[0]["evidence"] = "Changed"
|
||||
(run_dir / "vulnerabilities.json").write_text(json.dumps(reports))
|
||||
assert triage_stamp(run_dir) != reviewed
|
||||
assert all(0 <= value < 2**53 for value in triage_stamp(run_dir))
|
||||
|
||||
|
||||
def _process_close(path: str, finding_id: str) -> None:
|
||||
# Spawned processes inherit STRIX_TELEMETRY=0 from the parent fixture.
|
||||
run = Path(path)
|
||||
finding = next(item for item in read_triaged_vulnerabilities(run) if item["id"] == finding_id)
|
||||
triage_finding(
|
||||
run,
|
||||
finding_id,
|
||||
status="closed",
|
||||
expected_revision=0,
|
||||
reviewed_digest=finding["finding_digest"],
|
||||
surface="tui",
|
||||
)
|
||||
|
||||
|
||||
def test_separate_processes_preserve_both_decisions(run_dir: Path) -> None:
|
||||
context = multiprocessing.get_context("spawn")
|
||||
processes = [
|
||||
context.Process(target=_process_close, args=(str(run_dir), f"vuln-{i:04d}")) for i in (1, 2)
|
||||
]
|
||||
for process in processes:
|
||||
process.start()
|
||||
for process in processes:
|
||||
process.join(timeout=20)
|
||||
if process.is_alive():
|
||||
process.terminate()
|
||||
pytest.fail("Review process did not finish")
|
||||
assert process.exitcode == 0
|
||||
findings = read_triaged_vulnerabilities(run_dir)
|
||||
assert all(item["status"] == "closed" for item in findings)
|
||||
assert all(item["triage_revision"] == 1 for item in findings)
|
||||
|
||||
|
||||
def test_atomic_replace_failure_preserves_prior_decision(
|
||||
run_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
close(run_dir)
|
||||
saved = (run_dir / "triage.json").read_bytes()
|
||||
monkeypatch.setattr(Path, "replace", Mock(side_effect=OSError("replace failed")))
|
||||
with pytest.raises(TriageError):
|
||||
close(run_dir, status="open")
|
||||
assert (run_dir / "triage.json").read_bytes() == saved
|
||||
assert not list(run_dir.glob(".triage-*"))
|
||||
assert metrics.record_triage.call_count == 1 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_lock_failure_never_saves_unlocked(run_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(triage_store, "LOCK_TIMEOUT", 0)
|
||||
monkeypatch.setattr(triage_store, "_file_lock", Mock(side_effect=OSError("lock unavailable")))
|
||||
with pytest.raises(TriageError, match="lock review data"):
|
||||
close(run_dir)
|
||||
assert not (run_dir / "triage.json").exists()
|
||||
metrics.record_triage.assert_not_called() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("counterevidence", "New contrary evidence"),
|
||||
("http_exchange_ids", ["new-archived-exchange"]),
|
||||
("confidence_rationale", "Previously assumed protection disproved"),
|
||||
],
|
||||
)
|
||||
def test_additional_evidence_fields_invalidate_review(
|
||||
run_dir: Path, field: str, value: Any
|
||||
) -> None:
|
||||
close(run_dir)
|
||||
reports = json.loads((run_dir / "vulnerabilities.json").read_text())
|
||||
reports[0][field] = value
|
||||
(run_dir / "vulnerabilities.json").write_text(json.dumps(reports))
|
||||
assert read_triaged_vulnerabilities(run_dir)[0]["review_stale"]
|
||||
|
||||
|
||||
def test_resume_and_scanner_artifact_rewrite_preserve_review(run_dir: Path) -> None:
|
||||
from strix.report.state import ReportState # noqa: PLC0415
|
||||
from strix.report.writer import write_vulnerabilities # noqa: PLC0415
|
||||
|
||||
close(run_dir)
|
||||
state = ReportState(run_name="run")
|
||||
state._run_dir = run_dir
|
||||
state.hydrate_from_run_dir()
|
||||
assert len(state.vulnerability_reports) == 2
|
||||
assert "status" not in state.vulnerability_reports[0]
|
||||
write_vulnerabilities(run_dir, state.vulnerability_reports, set())
|
||||
assert read_triaged_vulnerabilities(run_dir)[0]["status"] == "closed"
|
||||
assert state.add_vulnerability_report(title="Third finding", severity="low") == "vuln-0003"
|
||||
assert len(state.vulnerability_reports) == 3
|
||||
assert read_triaged_vulnerabilities(run_dir)[0]["status"] == "closed"
|
||||
40
tests/test_triage_store.py
Normal file
40
tests/test_triage_store.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Native operating-system locking invariants for the local review sidecar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.report.triage_store import _file_lock
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("contents", [b"", b"existing lock file"])
|
||||
def test_file_lock_excludes_other_handles_and_releases(tmp_path: Path, contents: bytes) -> None:
|
||||
"""Exercise fcntl on POSIX and the real msvcrt implementation on Windows."""
|
||||
path = tmp_path / "triage.lock"
|
||||
path.write_bytes(contents)
|
||||
first = os.open(path, os.O_RDWR)
|
||||
try:
|
||||
second = os.open(path, os.O_RDWR)
|
||||
try:
|
||||
_file_lock(first)
|
||||
try:
|
||||
with pytest.raises(OSError):
|
||||
_file_lock(second)
|
||||
# Locking/unlocking must agree on byte zero, regardless of position.
|
||||
os.lseek(first, 100, os.SEEK_SET)
|
||||
finally:
|
||||
_file_lock(first, unlock=True)
|
||||
_file_lock(second)
|
||||
_file_lock(second, unlock=True)
|
||||
finally:
|
||||
os.close(second)
|
||||
finally:
|
||||
os.close(first)
|
||||
assert path.read_bytes() == contents
|
||||
393
tests/test_triage_telemetry.py
Normal file
393
tests/test_triage_telemetry.py
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
"""Triage analytics respect consent and keep finding content off the network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from strix.config import loader
|
||||
from strix.telemetry import triage
|
||||
from strix.telemetry._common import SESSION_ID
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PRIVATE = "private finding content https://private.invalid customer@example.invalid"
|
||||
|
||||
|
||||
def _wait_for_delivery() -> None:
|
||||
worker = triage._worker
|
||||
if worker is not None:
|
||||
worker.join(timeout=3)
|
||||
assert not worker.is_alive(), "Telemetry worker did not finish"
|
||||
assert triage._queue.unfinished_tasks == 0
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_telemetry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
for name in list(os.environ):
|
||||
if name.upper() == "STRIX_TELEMETRY":
|
||||
monkeypatch.delenv(name)
|
||||
monkeypatch.setattr(loader, "_override", tmp_path / "config.json")
|
||||
monkeypatch.setattr(loader, "_cached", None)
|
||||
monkeypatch.setattr(triage, "_queue", queue.Queue(maxsize=128))
|
||||
monkeypatch.setattr(triage, "_worker", None)
|
||||
monkeypatch.setattr(triage, "_guard", threading.Lock())
|
||||
yield
|
||||
_wait_for_delivery()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sent(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
||||
payloads: list[dict[str, Any]] = []
|
||||
|
||||
def capture(_url: str, *, json: dict[str, Any], timeout: Any) -> requests.Response:
|
||||
assert timeout is not None
|
||||
payloads.append(json)
|
||||
response = requests.Response()
|
||||
response.status_code = 200
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(requests, "post", capture)
|
||||
return payloads
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_dir(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "synthetic-run"
|
||||
path.mkdir()
|
||||
(path / "run.json").write_text(
|
||||
json.dumps({"scan_mode": "standard", "run_name": PRIVATE, "target": PRIVATE}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _record(run_dir: Path, **fields: Any) -> None:
|
||||
current = {
|
||||
"status": "closed",
|
||||
"resolution_reason": "false_positive",
|
||||
"reason_code": "incorrect_assumption",
|
||||
"severity": "high",
|
||||
"cwe": "CWE-79",
|
||||
"cve": "CVE-2026-12345",
|
||||
**fields,
|
||||
}
|
||||
triage.record_triage({"status": "open"}, current, run_dir=run_dir, surface="viewer")
|
||||
|
||||
|
||||
def test_payload_contains_only_classification_metadata(
|
||||
run_dir: Path, sent: list[dict[str, Any]]
|
||||
) -> None:
|
||||
_record(
|
||||
run_dir,
|
||||
**dict.fromkeys(
|
||||
(
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"target",
|
||||
"endpoint",
|
||||
"email",
|
||||
"evidence",
|
||||
"poc_script_code",
|
||||
"status_note",
|
||||
"note",
|
||||
"code_locations",
|
||||
"finding_digest",
|
||||
"reviewed_digest",
|
||||
"status_changed_by",
|
||||
"status_changed_at",
|
||||
"triage_history",
|
||||
"model",
|
||||
"agent_id",
|
||||
"agent_name",
|
||||
),
|
||||
PRIVATE,
|
||||
),
|
||||
)
|
||||
_wait_for_delivery()
|
||||
|
||||
assert len(sent) == 1
|
||||
payload = sent[0]
|
||||
assert payload["event"] == "finding_triage_changed"
|
||||
assert payload["distinct_id"] == SESSION_ID
|
||||
properties = payload["properties"]
|
||||
assert set(properties) == {
|
||||
"os",
|
||||
"arch",
|
||||
"python",
|
||||
"strix_version",
|
||||
"$lib",
|
||||
"$lib_version",
|
||||
"$process_person_profile",
|
||||
"schema_version",
|
||||
"surface",
|
||||
"previous_status",
|
||||
"new_status",
|
||||
"previous_resolution_reason",
|
||||
"resolution_reason",
|
||||
"reason_code",
|
||||
"severity",
|
||||
"cwe",
|
||||
"is_cve",
|
||||
"scan_mode",
|
||||
}
|
||||
assert properties["schema_version"] == 1
|
||||
assert properties["surface"] == "viewer"
|
||||
assert properties["previous_status"] == "open"
|
||||
assert properties["new_status"] == "closed"
|
||||
assert properties["previous_resolution_reason"] is None
|
||||
assert properties["resolution_reason"] == "false_positive"
|
||||
assert properties["reason_code"] == "incorrect_assumption"
|
||||
assert properties["severity"] == "high"
|
||||
assert properties["cwe"] == "cwe-79"
|
||||
assert properties["is_cve"] is True
|
||||
assert properties["scan_mode"] == "standard"
|
||||
assert properties["$process_person_profile"] is False
|
||||
assert PRIVATE not in json.dumps(payload)
|
||||
assert "CVE-2026-12345" not in json.dumps(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("surface", ["viewer", "tui"])
|
||||
def test_reopen_preserves_transition_direction(
|
||||
run_dir: Path, sent: list[dict[str, Any]], surface: str
|
||||
) -> None:
|
||||
triage.record_triage(
|
||||
{"status": "closed", "resolution_reason": "false_positive", "status_note": PRIVATE},
|
||||
{"status": "open", "reason_code": "unspecified", "severity": " HIGH "},
|
||||
run_dir=run_dir,
|
||||
surface=surface,
|
||||
)
|
||||
_wait_for_delivery()
|
||||
properties = sent[0]["properties"]
|
||||
assert properties["surface"] == surface
|
||||
assert properties["previous_status"] == "closed"
|
||||
assert properties["new_status"] == "open"
|
||||
assert properties["previous_resolution_reason"] == "false_positive"
|
||||
assert properties["resolution_reason"] is None
|
||||
assert properties["severity"] == "high"
|
||||
assert properties["is_cve"] is False
|
||||
assert PRIVATE not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_value", [PRIVATE, {"private": PRIVATE}, [PRIVATE], 42, None])
|
||||
def test_untrusted_categories_cannot_be_forwarded(
|
||||
run_dir: Path, sent: list[dict[str, Any]], bad_value: Any
|
||||
) -> None:
|
||||
(run_dir / "run.json").write_text(json.dumps({"scan_mode": bad_value}), encoding="utf-8")
|
||||
triage.record_triage(
|
||||
{"status": bad_value, "resolution_reason": bad_value},
|
||||
dict.fromkeys(
|
||||
("status", "resolution_reason", "reason_code", "severity", "cwe", "cve"), bad_value
|
||||
),
|
||||
run_dir=run_dir,
|
||||
surface=bad_value,
|
||||
)
|
||||
_wait_for_delivery()
|
||||
assert len(sent) == 1
|
||||
properties = sent[0]["properties"]
|
||||
for field in (
|
||||
"surface",
|
||||
"previous_status",
|
||||
"new_status",
|
||||
"reason_code",
|
||||
"severity",
|
||||
"cwe",
|
||||
"scan_mode",
|
||||
):
|
||||
assert properties[field] == "unknown"
|
||||
assert properties["previous_resolution_reason"] is None
|
||||
assert properties["resolution_reason"] is None
|
||||
assert isinstance(properties["is_cve"], bool)
|
||||
assert PRIVATE not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cwe", "expected"),
|
||||
[
|
||||
(" CWE-79 ", "cwe-79"),
|
||||
("cwe-1000", "cwe-1000"),
|
||||
("CWE-79 " + PRIVATE, "unknown"),
|
||||
("CWE-79\n" + PRIVATE, "unknown"),
|
||||
("cwe-0", "unknown"),
|
||||
("cwe-00079", "unknown"),
|
||||
("cwe-1234567", "unknown"),
|
||||
("79", "unknown"),
|
||||
],
|
||||
)
|
||||
def test_cwe_is_a_bounded_identifier(
|
||||
run_dir: Path, sent: list[dict[str, Any]], cwe: str, expected: str
|
||||
) -> None:
|
||||
_record(run_dir, cwe=cwe)
|
||||
_wait_for_delivery()
|
||||
assert sent[0]["properties"]["cwe"] == expected
|
||||
assert PRIVATE not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["environment", "saved_config"])
|
||||
@pytest.mark.parametrize("disabled", ["0", "false", "no", "off"])
|
||||
def test_opt_out_does_not_send_or_replay_disabled_actions(
|
||||
run_dir: Path,
|
||||
sent: list[dict[str, Any]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
source: str,
|
||||
disabled: str,
|
||||
) -> None:
|
||||
config = run_dir / "config.json"
|
||||
if source == "environment":
|
||||
config.write_text(json.dumps({"env": {"STRIX_TELEMETRY": "1"}}), encoding="utf-8")
|
||||
monkeypatch.setenv("STRIX_TELEMETRY", disabled)
|
||||
else:
|
||||
config.write_text(json.dumps({"env": {"STRIX_TELEMETRY": disabled}}), encoding="utf-8")
|
||||
loader.apply_config_override(config)
|
||||
|
||||
_record(run_dir, reason_code="other")
|
||||
assert loader.load_settings().telemetry.enabled is False
|
||||
assert sent == []
|
||||
assert triage._queue.empty()
|
||||
assert triage._worker is None
|
||||
|
||||
monkeypatch.delenv("STRIX_TELEMETRY", raising=False)
|
||||
config.write_text(json.dumps({"env": {"STRIX_TELEMETRY": "1"}}), encoding="utf-8")
|
||||
loader.apply_config_override(config)
|
||||
_record(run_dir, reason_code="expected_behavior")
|
||||
_wait_for_delivery()
|
||||
assert len(sent) == 1
|
||||
assert sent[0]["properties"]["reason_code"] == "expected_behavior"
|
||||
|
||||
|
||||
def test_environment_enable_overrides_saved_opt_out(
|
||||
run_dir: Path, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = run_dir / "config.json"
|
||||
config.write_text(json.dumps({"env": {"STRIX_TELEMETRY": "0"}}), encoding="utf-8")
|
||||
monkeypatch.setenv("STRIX_TELEMETRY", "1")
|
||||
loader.apply_config_override(config)
|
||||
_record(run_dir)
|
||||
_wait_for_delivery()
|
||||
assert len(sent) == 1
|
||||
|
||||
|
||||
def test_consent_is_rechecked_after_loading_scan_metadata(
|
||||
run_dir: Path, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
settings = loader.load_settings()
|
||||
|
||||
def disable_during_read(*_args: Any, **_kwargs: Any) -> dict[str, str]:
|
||||
settings.telemetry.enabled = False
|
||||
return {"scan_mode": "standard"}
|
||||
|
||||
monkeypatch.setattr(triage, "read_json", disable_during_read)
|
||||
_record(run_dir)
|
||||
assert sent == []
|
||||
assert triage._queue.empty()
|
||||
assert triage._worker is None
|
||||
|
||||
|
||||
def test_network_delivery_never_blocks_the_caller_and_queue_is_bounded(
|
||||
run_dir: Path, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
returned = threading.Event()
|
||||
capture = requests.post
|
||||
monkeypatch.setattr(triage, "_queue", queue.Queue(maxsize=2))
|
||||
|
||||
def blocked_post(*args: Any, **kwargs: Any) -> requests.Response:
|
||||
started.set()
|
||||
release.wait(timeout=5)
|
||||
return capture(*args, **kwargs)
|
||||
|
||||
def close_action() -> None:
|
||||
_record(run_dir)
|
||||
returned.set()
|
||||
|
||||
monkeypatch.setattr(requests, "post", blocked_post)
|
||||
caller = threading.Thread(target=close_action, daemon=True)
|
||||
caller.start()
|
||||
try:
|
||||
assert started.wait(timeout=2)
|
||||
# This asserts completion order while the request is still blocked,
|
||||
# rather than imposing a performance threshold on a successful send.
|
||||
assert returned.wait(timeout=2)
|
||||
assert not release.is_set()
|
||||
_record(run_dir)
|
||||
_record(run_dir)
|
||||
_record(run_dir) # The full queue drops this event without blocking.
|
||||
assert triage._queue.qsize() == 2
|
||||
assert triage._worker is not None
|
||||
assert triage._worker.daemon
|
||||
finally:
|
||||
release.set()
|
||||
caller.join(timeout=3)
|
||||
_wait_for_delivery()
|
||||
assert len(sent) == 3
|
||||
|
||||
|
||||
def test_pending_event_is_dropped_if_disabled_before_worker_delivery(
|
||||
run_dir: Path, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
capture = requests.post
|
||||
|
||||
def blocked_post(*args: Any, **kwargs: Any) -> requests.Response:
|
||||
started.set()
|
||||
release.wait(timeout=5)
|
||||
return capture(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(requests, "post", blocked_post)
|
||||
_record(run_dir)
|
||||
try:
|
||||
assert started.wait(timeout=2)
|
||||
_record(run_dir)
|
||||
assert triage._queue.qsize() == 1
|
||||
loader.load_settings().telemetry.enabled = False
|
||||
finally:
|
||||
release.set()
|
||||
_wait_for_delivery()
|
||||
assert len(sent) == 1 # Only the request already in progress was sent.
|
||||
loader.load_settings().telemetry.enabled = True
|
||||
_record(run_dir)
|
||||
_wait_for_delivery()
|
||||
assert len(sent) == 2 # The disabled queued event was not replayed.
|
||||
|
||||
|
||||
def test_delivery_failure_does_not_break_later_delivery(
|
||||
run_dir: Path, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
capture = requests.post
|
||||
failures: list[bool] = []
|
||||
|
||||
def fail_once(*args: Any, **kwargs: Any) -> requests.Response:
|
||||
if not failures:
|
||||
failures.append(True)
|
||||
raise requests.Timeout(PRIVATE)
|
||||
return capture(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(requests, "post", fail_once)
|
||||
_record(run_dir)
|
||||
_wait_for_delivery()
|
||||
_record(run_dir)
|
||||
_wait_for_delivery()
|
||||
assert len(sent) == 1
|
||||
assert PRIVATE not in json.dumps(sent)
|
||||
|
||||
|
||||
def test_malformed_scan_metadata_never_fails_the_local_action(
|
||||
run_dir: Path, sent: list[dict[str, Any]]
|
||||
) -> None:
|
||||
(run_dir / "run.json").write_text("{" + PRIVATE, encoding="utf-8")
|
||||
_record(run_dir)
|
||||
assert sent == []
|
||||
assert triage._queue.empty()
|
||||
298
tests/test_tui_source_build.py
Normal file
298
tests/test_tui_source_build.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.interface.tui import runtime as go_tui
|
||||
from strix.interface.tui import sidecar
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _runtime() -> go_tui.GoTuiRuntime:
|
||||
return go_tui.GoTuiRuntime(
|
||||
argparse.Namespace(
|
||||
needs_setup=True,
|
||||
targets_info=[],
|
||||
instruction=None,
|
||||
scan_mode="quick",
|
||||
max_budget_usd=None,
|
||||
max_turns=10,
|
||||
scope_mode="auto",
|
||||
diff_base=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_build_overrides_toolchain_without_mutating_environment(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
env = {"GOTOOLCHAIN": "local", "CGO_ENABLED": "1", "TERM": "xterm-256color"}
|
||||
original_env = env.copy()
|
||||
process = SimpleNamespace(returncode=0, communicate=AsyncMock(return_value=(None, b"")))
|
||||
create = AsyncMock(return_value=process)
|
||||
monkeypatch.setattr(sidecar, "os", SimpleNamespace(name="posix"))
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", create)
|
||||
output = tmp_path / "strix-tui"
|
||||
|
||||
await sidecar.build_tui_source(tmp_path, output, env)
|
||||
|
||||
create.assert_awaited_once_with(
|
||||
"go",
|
||||
"build",
|
||||
"-o",
|
||||
str(output),
|
||||
"./cmd/strix-tui",
|
||||
cwd=str(tmp_path),
|
||||
env={**original_env, "GOTOOLCHAIN": "auto", "CGO_ENABLED": "0"},
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
process.communicate.assert_awaited_once()
|
||||
assert env == original_env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_build_error_preserves_diagnostic_and_sanitizes_output(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
diagnostic = b"go: requires go >= 1.24.0\n\x1b[31mcompiler failed\x1b[0m"
|
||||
process = SimpleNamespace(returncode=1, communicate=AsyncMock(return_value=(None, diagnostic)))
|
||||
monkeypatch.setattr(sidecar, "os", SimpleNamespace(name="posix"))
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", AsyncMock(return_value=process))
|
||||
|
||||
with pytest.raises(RuntimeError, match=r"requires go >= 1\.24\.0") as caught:
|
||||
await sidecar.build_tui_source(tmp_path, tmp_path / "strix-tui", {})
|
||||
|
||||
message = str(caught.value)
|
||||
assert "compiler failed" in message
|
||||
assert "\x1b" not in message
|
||||
assert "handshake" not in message.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_build_bounds_compiler_diagnostic(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
process = SimpleNamespace(
|
||||
returncode=1,
|
||||
communicate=AsyncMock(return_value=(None, b"compiler detail " + b"x" * 10_000)),
|
||||
)
|
||||
monkeypatch.setattr(sidecar, "os", SimpleNamespace(name="posix"))
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", AsyncMock(return_value=process))
|
||||
|
||||
with pytest.raises(RuntimeError) as caught:
|
||||
await sidecar.build_tui_source(tmp_path, tmp_path / "strix-tui", {})
|
||||
|
||||
assert len(str(caught.value)) < 4500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_source_build_terminates_compiler(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
process = SimpleNamespace(
|
||||
returncode=None, communicate=AsyncMock(side_effect=asyncio.CancelledError)
|
||||
)
|
||||
terminate = AsyncMock()
|
||||
monkeypatch.setattr(sidecar, "os", SimpleNamespace(name="posix"))
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", AsyncMock(return_value=process))
|
||||
monkeypatch.setattr(sidecar, "terminate_process", terminate)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await sidecar.build_tui_source(tmp_path, tmp_path / "strix-tui", {})
|
||||
|
||||
terminate.assert_awaited_once_with(process)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("returncode", [0, 1])
|
||||
async def test_windows_source_build_uses_thread_without_async_subprocess_support(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, returncode: int
|
||||
) -> None:
|
||||
env = {"GOTOOLCHAIN": "local", "CGO_ENABLED": "1", "TERM": "xterm-256color"}
|
||||
original_env = env.copy()
|
||||
event_loop_thread = threading.get_ident()
|
||||
|
||||
def communicate() -> tuple[None, bytes]:
|
||||
assert threading.get_ident() != event_loop_thread
|
||||
return None, b"\x1b[31mWindows compiler failed\x1b[0m"
|
||||
|
||||
process = SimpleNamespace(returncode=returncode, communicate=Mock(side_effect=communicate))
|
||||
popen = Mock(return_value=process)
|
||||
async_subprocess = AsyncMock(side_effect=NotImplementedError("selector loop"))
|
||||
monkeypatch.setattr(sidecar, "os", SimpleNamespace(name="nt"))
|
||||
monkeypatch.setattr(sidecar.subprocess, "Popen", popen)
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", async_subprocess)
|
||||
output = tmp_path / "strix-tui.exe"
|
||||
|
||||
if returncode:
|
||||
with pytest.raises(RuntimeError, match="Windows compiler failed") as caught:
|
||||
await sidecar.build_tui_source(tmp_path, output, env)
|
||||
assert "\x1b" not in str(caught.value)
|
||||
else:
|
||||
await sidecar.build_tui_source(tmp_path, output, env)
|
||||
|
||||
popen.assert_called_once_with(
|
||||
["go", "build", "-o", str(output), "./cmd/strix-tui"],
|
||||
cwd=str(tmp_path),
|
||||
env={**original_env, "GOTOOLCHAIN": "auto", "CGO_ENABLED": "0"},
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
async_subprocess.assert_not_called()
|
||||
process.communicate.assert_called_once()
|
||||
assert env == original_env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_source_build_cancellation_waits_for_process_and_pipe_reader(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-c", "import time; time.sleep(60)"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
communicating = threading.Event()
|
||||
communication_finished = threading.Event()
|
||||
original_communicate = process.communicate
|
||||
|
||||
def communicate() -> tuple[bytes, bytes]:
|
||||
communicating.set()
|
||||
try:
|
||||
return original_communicate()
|
||||
finally:
|
||||
communication_finished.set()
|
||||
|
||||
monkeypatch.setattr(process, "communicate", communicate)
|
||||
monkeypatch.setattr(sidecar, "os", SimpleNamespace(name="nt"))
|
||||
monkeypatch.setattr(sidecar.subprocess, "Popen", Mock(return_value=process))
|
||||
task = asyncio.create_task(sidecar.build_tui_source(tmp_path, tmp_path / "strix-tui.exe", {}))
|
||||
try:
|
||||
assert await asyncio.to_thread(communicating.wait, 2)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
assert process.returncode is not None
|
||||
assert communication_finished.is_set()
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=2)
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_finishes_source_build_before_launch_and_handshake(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
runtime = _runtime()
|
||||
backend, child = socket.socketpair()
|
||||
build_started = asyncio.Event()
|
||||
finish_build = asyncio.Event()
|
||||
calls: list[str] = []
|
||||
outputs: list[Path] = []
|
||||
process = SimpleNamespace(returncode=0)
|
||||
|
||||
async def build(_source: Path, output: Path, env: dict[str, str]) -> None:
|
||||
assert env["GOTOOLCHAIN"] == "local"
|
||||
outputs.append(output)
|
||||
calls.append("build")
|
||||
build_started.set()
|
||||
await finish_build.wait()
|
||||
output.write_bytes(b"compiled test executable")
|
||||
calls.append("built")
|
||||
|
||||
async def launch(
|
||||
command: list[str], env: dict[str, str], cwd: str | None
|
||||
) -> tuple[SimpleNamespace, socket.socket]:
|
||||
assert command == [str(outputs[0])]
|
||||
assert outputs[0].is_file()
|
||||
assert cwd is None
|
||||
assert env["GOTOOLCHAIN"] == "local"
|
||||
calls.append("launch")
|
||||
return process, backend
|
||||
|
||||
async def ready(connection: socket.socket) -> None:
|
||||
assert connection is backend
|
||||
calls.append("ready")
|
||||
runtime.server.activated = True
|
||||
|
||||
def prepare() -> None:
|
||||
calls.append("prepare")
|
||||
|
||||
monkeypatch.setattr(runtime, "binary_command", lambda: ["go", "run", "./cmd/strix-tui"])
|
||||
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(go_tui, "child_environment", lambda: {"GOTOOLCHAIN": "local"})
|
||||
monkeypatch.setattr(go_tui, "build_tui_source", build)
|
||||
monkeypatch.setattr(go_tui, "launch_tui_process", launch)
|
||||
monkeypatch.setattr(go_tui, "wait_process", AsyncMock(return_value=0))
|
||||
monkeypatch.setattr(runtime.server, "start", ready)
|
||||
monkeypatch.setattr(runtime, "_start_preparation", prepare)
|
||||
task = asyncio.create_task(runtime.run())
|
||||
try:
|
||||
await asyncio.wait_for(build_started.wait(), timeout=2)
|
||||
assert calls == ["build"]
|
||||
finish_build.set()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
finally:
|
||||
finish_build.set()
|
||||
child.close()
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert calls == ["build", "built", "launch", "ready", "prepare"]
|
||||
assert not outputs[0].parent.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_source_build_failure_does_not_launch_or_prepare_scan(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
runtime = _runtime()
|
||||
outputs: list[Path] = []
|
||||
|
||||
async def fail_build(_source: Path, output: Path, _env: dict[str, str]) -> None:
|
||||
outputs.append(output)
|
||||
output.write_bytes(b"partial executable")
|
||||
raise RuntimeError("Go compiler could not build the TUI: requires go >= 1.24.0")
|
||||
|
||||
launch = AsyncMock()
|
||||
handshake = AsyncMock()
|
||||
prepare = Mock()
|
||||
monkeypatch.setattr(runtime, "binary_command", lambda: ["go", "run", "./cmd/strix-tui"])
|
||||
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(go_tui, "build_tui_source", fail_build)
|
||||
monkeypatch.setattr(go_tui, "launch_tui_process", launch)
|
||||
monkeypatch.setattr(runtime.server, "start", handshake)
|
||||
monkeypatch.setattr(runtime, "_start_preparation", prepare)
|
||||
original_stdout, original_stderr = sys.stdout, sys.stderr
|
||||
|
||||
with pytest.raises(go_tui.GoTuiPreActivationError, match=r"requires go >= 1\.24\.0"):
|
||||
await runtime.run()
|
||||
|
||||
launch.assert_not_called()
|
||||
handshake.assert_not_called()
|
||||
prepare.assert_not_called()
|
||||
assert not outputs[0].parent.exists()
|
||||
assert sys.stdout is original_stdout
|
||||
assert sys.stderr is original_stderr
|
||||
168
tests/test_tui_triage.py
Normal file
168
tests/test_tui_triage.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.config import loader
|
||||
from strix.interface.tui.backend.controller import TuiController
|
||||
from strix.interface.tui.backend.protocol import PROTOCOL_VERSION
|
||||
from strix.interface.tui.backend.server import TuiBackendServer
|
||||
from strix.report.triage import TriageError, read_triaged_vulnerabilities, triage_finding
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from strix.report.state import ReportState
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def triage_controller(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TuiController:
|
||||
monkeypatch.setenv("STRIX_TELEMETRY", "0")
|
||||
monkeypatch.setattr(loader, "_cached", None)
|
||||
monkeypatch.setattr(loader, "_override", tmp_path / "config.json")
|
||||
reports = [{"id": "vuln-0001", "title": "Example", "severity": "high", "evidence": "proof"}]
|
||||
(tmp_path / "vulnerabilities.json").write_text(json.dumps(reports))
|
||||
(tmp_path / "run.json").write_text(json.dumps({"run_id": "test-run"}))
|
||||
args = argparse.Namespace(
|
||||
needs_setup=False,
|
||||
targets_info=[],
|
||||
instruction=None,
|
||||
scan_mode="standard",
|
||||
max_budget_usd=None,
|
||||
max_turns=50,
|
||||
scope_mode="auto",
|
||||
diff_base=None,
|
||||
)
|
||||
state = SimpleNamespace(vulnerability_reports=reports, get_run_dir=lambda: tmp_path)
|
||||
return TuiController(args, report_state=cast("ReportState", state))
|
||||
|
||||
|
||||
def _request(finding: dict[str, Any], status: str = "closed") -> dict[str, Any]:
|
||||
return {
|
||||
"finding_id": finding["id"],
|
||||
"status": status,
|
||||
"expected_revision": finding["triage_revision"],
|
||||
"reviewed_digest": finding["finding_digest"],
|
||||
"reason_code": "incorrect_assumption",
|
||||
"note": "Local explanation",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_close_reopen_updates_shared_overlay_without_mutating_evidence(
|
||||
triage_controller: TuiController,
|
||||
) -> None:
|
||||
controller = triage_controller
|
||||
finding = controller.collection("vulnerabilities")[0]
|
||||
state = controller.report_state
|
||||
assert state is not None
|
||||
raw = json.loads((state.get_run_dir() / "vulnerabilities.json").read_text())
|
||||
result = await controller.handle("vulnerability.triage", _request(finding))
|
||||
assert result["changed"] is True
|
||||
assert result["finding"]["status"] == "closed"
|
||||
assert "evidence" not in result["finding"]
|
||||
assert (
|
||||
read_triaged_vulnerabilities(state.get_run_dir())[0]["status_note"] == "Local explanation"
|
||||
)
|
||||
assert controller.collection("vulnerabilities")[0]["status"] == "closed"
|
||||
assert state.vulnerability_reports == raw
|
||||
assert json.loads((state.get_run_dir() / "vulnerabilities.json").read_text()) == raw
|
||||
reopened = await controller.handle("vulnerability.triage", _request(result["finding"], "open"))
|
||||
assert reopened["finding"]["status"] == "open"
|
||||
assert reopened["finding"]["resolution_reason"] is None
|
||||
assert len(read_triaged_vulnerabilities(state.get_run_dir())[0]["triage_history"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_stale_review_is_a_structured_conflict(
|
||||
triage_controller: TuiController,
|
||||
) -> None:
|
||||
controller = triage_controller
|
||||
finding = controller.collection("vulnerabilities")[0]
|
||||
assert controller.report_state is not None
|
||||
path = controller.report_state.get_run_dir() / "vulnerabilities.json"
|
||||
reports = json.loads(path.read_text())
|
||||
reports[0]["evidence"] = "New evidence"
|
||||
path.write_text(json.dumps(reports))
|
||||
server = TuiBackendServer(controller)
|
||||
response, _ = await server._handle_message(
|
||||
json.dumps(
|
||||
{
|
||||
"version": PROTOCOL_VERSION,
|
||||
"type": "vulnerability.triage",
|
||||
"request_id": "review",
|
||||
"payload": _request(finding),
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
assert response is not None
|
||||
assert response["payload"]["ok"] is False
|
||||
assert response["payload"]["error"]["code"] == "conflict"
|
||||
assert not (path.parent / "triage.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_retries_do_not_duplicate_reviews(triage_controller: TuiController) -> None:
|
||||
controller = triage_controller
|
||||
request = _request(controller.collection("vulnerabilities")[0])
|
||||
await controller.handle("vulnerability.triage", request)
|
||||
repeated = await controller.handle("vulnerability.triage", request)
|
||||
assert repeated["changed"] is False
|
||||
assert repeated["finding"]["triage_revision"] == 1
|
||||
|
||||
|
||||
def test_corrupt_sidecar_leaves_evidence_visible_without_write_capability(
|
||||
triage_controller: TuiController,
|
||||
) -> None:
|
||||
assert triage_controller.report_state is not None
|
||||
(triage_controller.report_state.get_run_dir() / "triage.json").write_text("broken")
|
||||
finding = triage_controller.collection("vulnerabilities")[0]
|
||||
assert finding["evidence"] == "proof"
|
||||
assert finding["can_triage"] is False
|
||||
assert finding["triage_error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_decision_notifies_idle_terminal(triage_controller: TuiController) -> None:
|
||||
controller = triage_controller
|
||||
server = TuiBackendServer(controller)
|
||||
changed = asyncio.Event()
|
||||
controller.set_change_callback(changed.set)
|
||||
server.activated = True
|
||||
# Observe the real watcher callback without opening another socket.
|
||||
server.notify_changed = changed.set # type: ignore[method-assign]
|
||||
task = asyncio.create_task(server._watch_triage())
|
||||
try:
|
||||
await asyncio.wait_for(changed.wait(), 2)
|
||||
changed.clear()
|
||||
finding = controller.collection("vulnerabilities")[0]
|
||||
assert controller.report_state is not None
|
||||
triage_finding(
|
||||
controller.report_state.get_run_dir(),
|
||||
finding["id"],
|
||||
status="closed",
|
||||
expected_revision=0,
|
||||
reviewed_digest=finding["finding_digest"],
|
||||
surface="viewer",
|
||||
)
|
||||
await asyncio.wait_for(changed.wait(), 2)
|
||||
assert controller.collection("vulnerabilities")[0]["status"] == "closed"
|
||||
finally:
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_missing_finding_cannot_be_created(triage_controller: TuiController) -> None:
|
||||
request = _request(triage_controller.collection("vulnerabilities")[0])
|
||||
request["finding_id"] = "vulnerability-0"
|
||||
with pytest.raises(TriageError) as failure:
|
||||
await triage_controller.handle("vulnerability.triage", request)
|
||||
assert failure.value.code == "unknown_finding"
|
||||
336
tests/test_viewer_triage.py
Normal file
336
tests/test_viewer_triage.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
"""The viewer's authenticated triage boundary and shared on-disk projection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.interface.viewer.server import serve
|
||||
from strix.interface.viewer.transcript import read_vulnerabilities
|
||||
from strix.report.triage import read_triaged_vulnerabilities, triage_finding
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class ViewerClient:
|
||||
run_dir: Path
|
||||
url: str
|
||||
cookie: str
|
||||
|
||||
def request(
|
||||
self,
|
||||
path: str,
|
||||
body: Any = None,
|
||||
*,
|
||||
authorized: bool = True,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> tuple[int, Any]:
|
||||
request_headers = {"Content-Type": "application/json", "Origin": self.url}
|
||||
if authorized:
|
||||
request_headers["Cookie"] = self.cookie
|
||||
request_headers.update(headers or {})
|
||||
parts = urlsplit(self.url)
|
||||
connection = http.client.HTTPConnection(str(parts.hostname), parts.port, timeout=5)
|
||||
raw = body if isinstance(body, bytes) else json.dumps(body)
|
||||
connection.request(
|
||||
"GET" if body is None else "POST",
|
||||
path,
|
||||
body=None if body is None else raw,
|
||||
headers=request_headers,
|
||||
)
|
||||
response = connection.getresponse()
|
||||
status = response.status
|
||||
data = json.loads(response.read())
|
||||
connection.close()
|
||||
return status, data
|
||||
|
||||
def finding(self) -> dict[str, Any]:
|
||||
status, findings = self.request("/api/vulnerabilities")
|
||||
assert status == 200
|
||||
return dict(findings[0])
|
||||
|
||||
def change(self, finding: dict[str, Any], **changes: Any) -> tuple[int, Any]:
|
||||
return self.request(
|
||||
f"/api/vulnerabilities/{finding['id']}/triage",
|
||||
{
|
||||
"status": "closed",
|
||||
"expected_revision": finding["triage_revision"],
|
||||
"reviewed_digest": finding["finding_digest"],
|
||||
**changes,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def viewer(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[ViewerClient]:
|
||||
run_dir = tmp_path / "runs" / "example"
|
||||
run_dir.mkdir(parents=True)
|
||||
(run_dir / "run.json").write_text(
|
||||
json.dumps({"run_name": "example", "status": "completed", "end_time": "2026-09-16"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(run_dir / "vulnerabilities.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"id": "vuln-0001",
|
||||
"title": "Example finding",
|
||||
"severity": "high",
|
||||
"evidence": "original evidence",
|
||||
}
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assets = tmp_path / "assets"
|
||||
assets.mkdir()
|
||||
(assets / "index.html").write_text("<!doctype html><title>Test</title>", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
|
||||
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: False)
|
||||
monkeypatch.setattr("strix.telemetry.triage.record_triage", lambda *_args, **_kwargs: None)
|
||||
server, url, token = serve(run_dir, open_browser=False)
|
||||
parts = urlsplit(url)
|
||||
connection = http.client.HTTPConnection(str(parts.hostname), parts.port, timeout=5)
|
||||
connection.request("GET", f"/?token={token}")
|
||||
response = connection.getresponse()
|
||||
cookie = str(response.getheader("Set-Cookie")).split(";", 1)[0]
|
||||
response.read()
|
||||
connection.close()
|
||||
try:
|
||||
yield ViewerClient(run_dir, url, cookie)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_close_and_reopen_preserve_raw_finding_and_refresh_projection(viewer: ViewerClient) -> None:
|
||||
original = read_vulnerabilities(viewer.run_dir)
|
||||
finding = viewer.finding()
|
||||
_, before = viewer.request("/api/triage/revision")
|
||||
status, result = viewer.change(
|
||||
finding, reason_code="incorrect_assumption", note="<script>local note</script>"
|
||||
)
|
||||
assert status == 200
|
||||
assert result["changed"] is True
|
||||
saved = viewer.finding()
|
||||
assert saved["status"] == "closed"
|
||||
assert saved["resolution_reason"] == "false_positive"
|
||||
assert saved["status_note"] == "<script>local note</script>"
|
||||
assert saved["evidence"] == "original evidence"
|
||||
assert read_triaged_vulnerabilities(viewer.run_dir)[0] == saved
|
||||
assert read_vulnerabilities(viewer.run_dir) == original
|
||||
_, after = viewer.request("/api/triage/revision")
|
||||
assert before != after
|
||||
|
||||
status, result = viewer.change(saved, status="open")
|
||||
assert status == 200
|
||||
assert result["finding"]["status"] == "open"
|
||||
assert result["finding"]["resolution_reason"] is None
|
||||
assert result["finding"]["status_note"] is None
|
||||
assert read_vulnerabilities(viewer.run_dir) == original
|
||||
|
||||
|
||||
def test_external_change_and_new_evidence_are_visible_to_finished_viewer(
|
||||
viewer: ViewerClient,
|
||||
) -> None:
|
||||
finding = viewer.finding()
|
||||
_, before = viewer.request("/api/triage/revision")
|
||||
triage_finding(
|
||||
viewer.run_dir,
|
||||
finding["id"],
|
||||
status="closed",
|
||||
expected_revision=0,
|
||||
reviewed_digest=finding["finding_digest"],
|
||||
surface="tui",
|
||||
)
|
||||
assert viewer.finding()["status"] == "closed"
|
||||
_, after = viewer.request("/api/triage/revision")
|
||||
assert before != after
|
||||
raw = read_vulnerabilities(viewer.run_dir)
|
||||
raw[0]["evidence"] = "materially different evidence"
|
||||
(viewer.run_dir / "vulnerabilities.json").write_text(json.dumps(raw), encoding="utf-8")
|
||||
changed = viewer.finding()
|
||||
assert changed["review_stale"] is True
|
||||
assert changed["status"] == "open"
|
||||
assert viewer.change(finding)[0] == 409
|
||||
_, evidence_revision = viewer.request("/api/triage/revision")
|
||||
assert evidence_revision != after
|
||||
assert viewer.change(changed)[0] == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("headers", "authorized", "status"),
|
||||
[
|
||||
({}, False, 403),
|
||||
({"Origin": "http://untrusted.example"}, True, 403),
|
||||
({"Origin": "null"}, True, 403),
|
||||
({"Origin": ""}, True, 403),
|
||||
({"Sec-Fetch-Site": "cross-site"}, True, 403),
|
||||
({"Content-Type": "text/plain"}, True, 415),
|
||||
],
|
||||
)
|
||||
def test_mutations_require_authorized_same_origin_json(
|
||||
viewer: ViewerClient, headers: dict[str, str], authorized: bool, status: int
|
||||
) -> None:
|
||||
actual, _ = viewer.request(
|
||||
"/api/vulnerabilities/vuln-0001/triage",
|
||||
{"status": "closed"},
|
||||
headers=headers,
|
||||
authorized=authorized,
|
||||
)
|
||||
assert actual == status
|
||||
assert not (viewer.run_dir / "triage.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
b"{",
|
||||
[],
|
||||
{"status": "closed"},
|
||||
{"expected_revision": True},
|
||||
{"reason_code": []},
|
||||
{"note": "x" * 2001},
|
||||
{"surface": "tui"},
|
||||
{"status": "fixed"},
|
||||
],
|
||||
)
|
||||
def test_bad_payloads_cannot_create_a_decision(viewer: ViewerClient, body: Any) -> None:
|
||||
finding = viewer.finding()
|
||||
if isinstance(body, dict):
|
||||
body = {
|
||||
"status": "closed",
|
||||
"expected_revision": 0,
|
||||
"reviewed_digest": finding["finding_digest"],
|
||||
**body,
|
||||
}
|
||||
if body == {
|
||||
"status": "closed",
|
||||
"expected_revision": 0,
|
||||
"reviewed_digest": finding["finding_digest"],
|
||||
}:
|
||||
body.pop("reviewed_digest")
|
||||
status, _ = viewer.request("/api/vulnerabilities/vuln-0001/triage", body)
|
||||
assert status == 400
|
||||
assert not (viewer.run_dir / "triage.json").exists()
|
||||
|
||||
|
||||
def test_oversized_payload_rejected_before_parsing(viewer: ViewerClient) -> None:
|
||||
status, _ = viewer.request("/api/vulnerabilities/vuln-0001/triage", b"x" * 32769)
|
||||
assert status == 413
|
||||
|
||||
|
||||
def test_mutation_run_selection_and_history_gate(
|
||||
viewer: ViewerClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
other = viewer.run_dir.parent / "other"
|
||||
other.mkdir()
|
||||
for filename in ("run.json", "vulnerabilities.json"):
|
||||
(other / filename).write_bytes((viewer.run_dir / filename).read_bytes())
|
||||
finding = viewer.finding()
|
||||
body = {
|
||||
"status": "closed",
|
||||
"expected_revision": 0,
|
||||
"reviewed_digest": finding["finding_digest"],
|
||||
}
|
||||
route = "/api/vulnerabilities/vuln-0001/triage"
|
||||
assert viewer.request(route + "?run=other", body)[0] == 401
|
||||
assert viewer.request(route + "?run=../other", body)[0] == 404
|
||||
assert viewer.request(route + "?run=other&run=example", body)[0] == 404
|
||||
(viewer.run_dir.parent / "alias").symlink_to(other, target_is_directory=True)
|
||||
assert viewer.request(route + "?run=alias", body)[0] == 403
|
||||
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: True)
|
||||
assert viewer.request(route + "?run=other", body)[0] == 200
|
||||
assert viewer.finding()["status"] == "open"
|
||||
|
||||
|
||||
def test_corrupt_sidecar_is_a_visible_error_not_empty_findings(viewer: ViewerClient) -> None:
|
||||
(viewer.run_dir / "triage.json").write_text("{", encoding="utf-8")
|
||||
status, error = viewer.request("/api/vulnerabilities")
|
||||
assert status == 409
|
||||
assert error["error"] == "invalid_triage"
|
||||
assert read_vulnerabilities(viewer.run_dir)[0]["evidence"] == "original evidence"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["malformed", "null", "schema", "identity", "findings"])
|
||||
def test_invalid_historical_run_does_not_break_history(
|
||||
viewer: ViewerClient, monkeypatch: pytest.MonkeyPatch, damage: str
|
||||
) -> None:
|
||||
other = viewer.run_dir.parent / "damaged"
|
||||
other.mkdir()
|
||||
(other / "run.json").write_text(
|
||||
json.dumps({"run_name": "damaged", "status": "completed", "end_time": "2026-09-15"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(other / "vulnerabilities.json").write_bytes(
|
||||
(viewer.run_dir / "vulnerabilities.json").read_bytes()
|
||||
)
|
||||
finding = read_triaged_vulnerabilities(other)[0]
|
||||
triage_finding(
|
||||
other,
|
||||
finding["id"],
|
||||
status="closed",
|
||||
expected_revision=0,
|
||||
reviewed_digest=finding["finding_digest"],
|
||||
surface="tui",
|
||||
)
|
||||
sidecar = other / "triage.json"
|
||||
document = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
if damage == "malformed":
|
||||
sidecar.write_text("{", encoding="utf-8")
|
||||
elif damage == "null":
|
||||
sidecar.write_text("null", encoding="utf-8")
|
||||
elif damage == "findings":
|
||||
(other / "vulnerabilities.json").write_text("[null]", encoding="utf-8")
|
||||
else:
|
||||
if damage == "schema":
|
||||
document["schema_version"] = 2
|
||||
else:
|
||||
document["run_identity"]["run_id"] = "another-run"
|
||||
sidecar.write_text(json.dumps(document), encoding="utf-8")
|
||||
saved = sidecar.read_bytes()
|
||||
evidence = (other / "vulnerabilities.json").read_bytes()
|
||||
|
||||
# The locked history must not try to read the damaged run's findings.
|
||||
assert viewer.request("/api/runs") == (200, {"locked": True, "count": 2, "runs": []})
|
||||
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: True)
|
||||
status, payload = viewer.request("/api/runs")
|
||||
assert status == 200
|
||||
assert payload["count"] == 2
|
||||
runs = {run["name"]: run for run in payload["runs"]}
|
||||
assert runs["example"]["open_count"] == 1
|
||||
assert runs["example"]["severity_counts"]["high"] == 1
|
||||
assert runs["damaged"]["status"] == "completed"
|
||||
assert runs["damaged"]["severity_counts"] is None
|
||||
assert all(
|
||||
key not in runs["damaged"] for key in ("open_count", "closed_count", "detected_count")
|
||||
)
|
||||
assert viewer.finding()["status"] == "open"
|
||||
assert viewer.request("/api/vulnerabilities?run=damaged")[0] == 409
|
||||
body = {
|
||||
"status": "open",
|
||||
"expected_revision": 1,
|
||||
"reviewed_digest": finding["finding_digest"],
|
||||
}
|
||||
assert viewer.request("/api/vulnerabilities/vuln-0001/triage?run=damaged", body)[0] == 409
|
||||
assert sidecar.read_bytes() == saved
|
||||
assert (other / "vulnerabilities.json").read_bytes() == evidence
|
||||
|
||||
|
||||
def test_read_only_run_has_no_write_capability(viewer: ViewerClient) -> None:
|
||||
viewer.run_dir.chmod(0o500)
|
||||
try:
|
||||
finding = viewer.finding()
|
||||
assert finding["can_triage"] is False
|
||||
assert viewer.change(finding)[0] == 403
|
||||
finally:
|
||||
viewer.run_dir.chmod(0o700)
|
||||
Loading…
Add table
Reference in a new issue