mirror of
https://github.com/usestrix/strix.git
synced 2026-08-21 18:52:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22750077da | ||
|
|
0607abf9e5 | ||
|
|
9dae76667b |
@@ -68,10 +68,10 @@ Framework-specific testing patterns.
|
|||||||
|
|
||||||
Third-party service and platform security.
|
Third-party service and platform security.
|
||||||
|
|
||||||
| Skill | Coverage |
|
| Skill | Coverage |
|
||||||
| -------------------- | ---------------------------------- |
|
| ---------- | ------------------------------------------------------ |
|
||||||
| `supabase` | Supabase RLS bypasses, auth issues |
|
| `supabase` | Supabase RLS bypasses, auth issues |
|
||||||
| `firebase_firestore` | Firestore rules, Firebase auth |
|
| `firebase` | Firebase Firestore, Storage rules, Auth, and Functions |
|
||||||
|
|
||||||
### Protocols
|
### Protocols
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "strix-agent"
|
name = "strix-agent"
|
||||||
version = "1.5.0"
|
version = "1.5.1"
|
||||||
description = "Open-source AI Hackers for your apps"
|
description = "Open-source AI Hackers for your apps"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -490,8 +490,10 @@ Default user: pentester (sudo available)
|
|||||||
<available_skills>
|
<available_skills>
|
||||||
On-demand specialist skills. Spawn a specialist via `create_agent(skills=[...])`, or pull guidance inline for yourself via `load_skill(skills=[...])`. Anything wrapped in `<specialized_knowledge>` above is already loaded for you.
|
On-demand specialist skills. Spawn a specialist via `create_agent(skills=[...])`, or pull guidance inline for yourself via `load_skill(skills=[...])`. Anything wrapped in `<specialized_knowledge>` above is already loaded for you.
|
||||||
|
|
||||||
{% for category, names in available_skills | dictsort -%}
|
{% for category, skills in available_skills | dictsort -%}
|
||||||
- {{ category }}: {{ names | join(', ') }}
|
{% for skill in skills -%}
|
||||||
|
- {{ category }}/{{ skill.name }}{% if skill.description %}: {{ skill.description }}{% endif %}
|
||||||
|
{% endfor -%}
|
||||||
{% endfor -%}
|
{% endfor -%}
|
||||||
</available_skills>
|
</available_skills>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
"github.com/usestrix/strix/tui/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
func findingsModel(t *testing.T, titles ...string) Model {
|
||||||
|
t.Helper()
|
||||||
|
m := New(nil)
|
||||||
|
m.width, m.height = 130, 30
|
||||||
|
m.showSplash = false
|
||||||
|
m.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"}))
|
||||||
|
items := make([]json.RawMessage, 0, len(titles))
|
||||||
|
for i, title := range titles {
|
||||||
|
items = append(items, rawJSON(t, map[string]any{
|
||||||
|
"id": string(rune('a' + i)), "title": title, "severity": "high",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
m.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_bootstrap",
|
||||||
|
Payload: rawJSON(t, protocol.CollectionBootstrap{
|
||||||
|
Collection: "vulnerabilities", Revision: 1, Cursor: 0,
|
||||||
|
NextCursor: len(items), Done: true, Items: items,
|
||||||
|
})})
|
||||||
|
m.resizeViewport()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list scrolls by row, not by finding. Stepping a whole entry at a time is
|
||||||
|
// what made a list of wrapped titles feel paginated.
|
||||||
|
func TestFindingsScrollByRow(t *testing.T) {
|
||||||
|
long := "A deliberately long finding title that wraps across several rows in the sidebar"
|
||||||
|
m := findingsModel(t, long, long, long)
|
||||||
|
|
||||||
|
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
|
||||||
|
if len(rows) <= 3 {
|
||||||
|
t.Fatalf("titles did not wrap, so this proves nothing: %d rows", len(rows))
|
||||||
|
}
|
||||||
|
total, offset := m.vulnerabilityScrollRows()
|
||||||
|
if total != len(rows) || offset != 0 {
|
||||||
|
t.Fatalf("scroll metrics are not in rows: total=%d offset=%d rows=%d", total, offset, len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// One step of the offset moves one row, and the first visible line follows it.
|
||||||
|
first := strings.Split(ansi.Strip(m.vulnerabilitiesView(40, 4)), "\n")[0]
|
||||||
|
m.vulnOffset = 1
|
||||||
|
second := strings.Split(ansi.Strip(m.vulnerabilitiesView(40, 4)), "\n")[0]
|
||||||
|
if first == second {
|
||||||
|
t.Fatalf("advancing one row did not move the list: %q", first)
|
||||||
|
}
|
||||||
|
// That row still belongs to the first finding, which an item-stepping list
|
||||||
|
// would have skipped past entirely.
|
||||||
|
if got := m.vulnerabilityIndexAtRow(0); got != 0 {
|
||||||
|
t.Fatalf("one row in, the top line belongs to finding %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selecting a finding scrolls the least it can, and never past its own start.
|
||||||
|
func TestSelectingAFindingBringsItIntoView(t *testing.T) {
|
||||||
|
long := "A deliberately long finding title that wraps across several rows in the sidebar"
|
||||||
|
m := findingsModel(t, long, long, long, long)
|
||||||
|
|
||||||
|
m.selectedVuln = 3
|
||||||
|
m.ensureVulnerabilityVisible()
|
||||||
|
|
||||||
|
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
|
||||||
|
height := m.vulnerabilityPageSize()
|
||||||
|
end := min(len(rows), m.vulnOffset+height)
|
||||||
|
found := false
|
||||||
|
for _, row := range rows[m.vulnOffset:end] {
|
||||||
|
if row.index == 3 {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("the selected finding is not on screen: offset=%d height=%d", m.vulnOffset, height)
|
||||||
|
}
|
||||||
|
if m.vulnOffset > len(rows)-height && len(rows) > height {
|
||||||
|
t.Fatalf("scrolled past the end: offset=%d rows=%d height=%d", m.vulnOffset, len(rows), height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportModel(t *testing.T, count int) Model {
|
||||||
|
t.Helper()
|
||||||
|
titles := make([]string, 0, count)
|
||||||
|
for i := range count {
|
||||||
|
titles = append(titles, fmt.Sprintf("Finding number %d", i+1))
|
||||||
|
}
|
||||||
|
m := findingsModel(t, titles...)
|
||||||
|
m.openModal(modalVulnerability)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// The open report can be stepped through the list without closing it.
|
||||||
|
func TestReportStepsBetweenFindings(t *testing.T) {
|
||||||
|
m := reportModel(t, 3)
|
||||||
|
|
||||||
|
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyRight})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.selectedVuln != 1 {
|
||||||
|
t.Fatalf("right moved to %d, want 1", m.selectedVuln)
|
||||||
|
}
|
||||||
|
if m.modal != modalVulnerability {
|
||||||
|
t.Fatal("stepping closed the report")
|
||||||
|
}
|
||||||
|
updated, _ = m.updateModal(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.selectedVuln != 0 {
|
||||||
|
t.Fatalf("left moved to %d, want 0", m.selectedVuln)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ends do not wrap: rolling from the last report to the first would hide
|
||||||
|
// that you had reached the end.
|
||||||
|
func TestReportStepsStopAtTheEnds(t *testing.T) {
|
||||||
|
m := reportModel(t, 3)
|
||||||
|
|
||||||
|
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.selectedVuln != 0 {
|
||||||
|
t.Fatalf("left from the first report moved to %d, want 0", m.selectedVuln)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.selectedVuln = 2
|
||||||
|
updated, _ = m.updateModal(tea.KeyMsg{Type: tea.KeyRight})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.selectedVuln != 2 {
|
||||||
|
t.Fatalf("right from the last report moved to %d, want 2", m.selectedVuln)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each direction is offered only when there is a report that way, and a lone
|
||||||
|
// finding is offered neither.
|
||||||
|
func TestReportNavigationHintsFollowAvailability(t *testing.T) {
|
||||||
|
m := reportModel(t, 3)
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
index int
|
||||||
|
wantPrev, wantNext bool
|
||||||
|
position string
|
||||||
|
}{
|
||||||
|
{index: 0, wantNext: true, position: "1/3"},
|
||||||
|
{index: 1, wantPrev: true, wantNext: true, position: "2/3"},
|
||||||
|
{index: 2, wantPrev: true, position: "3/3"},
|
||||||
|
} {
|
||||||
|
m.selectedVuln = testCase.index
|
||||||
|
view := ansi.Strip(m.modalView())
|
||||||
|
if !strings.Contains(view, testCase.position) {
|
||||||
|
t.Fatalf("report %d does not show %q", testCase.index, testCase.position)
|
||||||
|
}
|
||||||
|
if got := strings.Contains(view, reportPrev); got != testCase.wantPrev {
|
||||||
|
t.Fatalf("report %d prev hint = %v, want %v", testCase.index, got, testCase.wantPrev)
|
||||||
|
}
|
||||||
|
if got := strings.Contains(view, reportNext); got != testCase.wantNext {
|
||||||
|
t.Fatalf("report %d next hint = %v, want %v", testCase.index, got, testCase.wantNext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lone := reportModel(t, 1)
|
||||||
|
view := ansi.Strip(lone.modalView())
|
||||||
|
if strings.Contains(view, reportPrev) || strings.Contains(view, reportNext) || strings.Contains(view, "1/1") {
|
||||||
|
t.Fatalf("a lone finding offered navigation:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new report opens at its top, and the copy state does not carry over.
|
||||||
|
func TestSteppingResetsTheReportView(t *testing.T) {
|
||||||
|
m := reportModel(t, 3)
|
||||||
|
m.vulnerabilityCopied = true
|
||||||
|
m.vulnViewport.SetYOffset(3)
|
||||||
|
|
||||||
|
m.showVulnerability(1)
|
||||||
|
|
||||||
|
if m.vulnViewport.YOffset != 0 {
|
||||||
|
t.Fatalf("the next report opened scrolled to %d", m.vulnViewport.YOffset)
|
||||||
|
}
|
||||||
|
if m.vulnerabilityCopied {
|
||||||
|
t.Fatal("the copy state carried over to another report")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prev and Next are buttons, not just key hints: they can be clicked.
|
||||||
|
func TestReportStepButtonsAreClickable(t *testing.T) {
|
||||||
|
m := reportModel(t, 3)
|
||||||
|
m.selectedVuln = 1
|
||||||
|
|
||||||
|
click := func(label string) Model {
|
||||||
|
t.Helper()
|
||||||
|
view := m.modalView()
|
||||||
|
left, top, _, _ := m.centeredViewBounds(view)
|
||||||
|
for row, line := range strings.Split(view, "\n") {
|
||||||
|
plain := ansi.Strip(line)
|
||||||
|
index := strings.Index(plain, label)
|
||||||
|
if index < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updated, _ := m.updateModalMouse(tea.MouseMsg{
|
||||||
|
X: left + ansi.StringWidth(plain[:index]) + 1, Y: top + row,
|
||||||
|
Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
|
||||||
|
})
|
||||||
|
return updated.(Model)
|
||||||
|
}
|
||||||
|
t.Fatalf("%q was not rendered", label)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := click(reportNext).selectedVuln; got != 2 {
|
||||||
|
t.Fatalf("clicking Next selected %d, want 2", got)
|
||||||
|
}
|
||||||
|
if got := click(reportPrev).selectedVuln; got != 0 {
|
||||||
|
t.Fatalf("clicking Prev selected %d, want 0", got)
|
||||||
|
}
|
||||||
|
if got := click(reportNext).modal; got != modalVulnerability {
|
||||||
|
t.Fatalf("clicking Next closed the report: modal=%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab walks the whole row, so the step buttons are reachable from the keyboard
|
||||||
|
// as well, and Enter presses whichever one is focused.
|
||||||
|
func TestTabReachesTheStepButtons(t *testing.T) {
|
||||||
|
m := reportModel(t, 3)
|
||||||
|
m.selectedVuln = 1
|
||||||
|
|
||||||
|
if got := m.focusedReportButton(); got != reportDone {
|
||||||
|
t.Fatalf("the report opened focused on %q, want %q", got, reportDone)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for range len(m.reportButtons()) {
|
||||||
|
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyTab})
|
||||||
|
m = updated.(Model)
|
||||||
|
seen[m.focusedReportButton()] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{reportPrev, reportNext, reportCopy, reportDone} {
|
||||||
|
if !seen[want] {
|
||||||
|
t.Fatalf("tab never reached %q: %v", want, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter on a focused step button steps.
|
||||||
|
m.reportFocus = reportNext
|
||||||
|
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
if got := updated.(Model).selectedVuln; got != 2 {
|
||||||
|
t.Fatalf("enter on Next selected %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stepping to an end drops that button from the row; focus must not be stranded
|
||||||
|
// on it.
|
||||||
|
func TestFocusFallsBackWhenAStepButtonDisappears(t *testing.T) {
|
||||||
|
m := reportModel(t, 2)
|
||||||
|
m.selectedVuln = 0
|
||||||
|
m.reportFocus = reportNext
|
||||||
|
|
||||||
|
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(Model)
|
||||||
|
|
||||||
|
if m.selectedVuln != 1 {
|
||||||
|
t.Fatalf("enter on Next selected %d, want 1", m.selectedVuln)
|
||||||
|
}
|
||||||
|
// Next is gone at the last report, so the focus cannot still be on it.
|
||||||
|
if got := m.focusedReportButton(); got == reportNext {
|
||||||
|
t.Fatalf("focus stayed on a button that is no longer shown: %q", got)
|
||||||
|
}
|
||||||
|
if got := m.focusedReportButton(); got != reportDone {
|
||||||
|
t.Fatalf("focus fell back to %q, want %q", got, reportDone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list must be laid out at one width. Rendering at one and hit-testing at
|
||||||
|
// another gives two different row counts for the same title, and then a click
|
||||||
|
// resolves to the wrong finding and the scrollbar reports the wrong length.
|
||||||
|
func TestFindingsUseOneWidthForRenderAndInteraction(t *testing.T) {
|
||||||
|
// This title wraps to one row at 21 columns and two at 20, which is exactly
|
||||||
|
// the pair of widths the two paths used to disagree on.
|
||||||
|
m := findingsModel(t, "ffffff dddd a a a a", "eeeee eeeee a a a a", "header dddd a a a a")
|
||||||
|
|
||||||
|
width := m.vulnerabilityListWidth()
|
||||||
|
rows := m.vulnerabilityRows(width)
|
||||||
|
rendered := strings.Split(ansi.Strip(m.vulnerabilitiesView(width, len(rows))), "\n")
|
||||||
|
|
||||||
|
if len(rendered) != len(rows) {
|
||||||
|
t.Fatalf("rendered %d rows, interaction counts %d", len(rendered), len(rows))
|
||||||
|
}
|
||||||
|
for row := range rendered {
|
||||||
|
if got := m.vulnerabilityIndexAtRow(row); got != rows[row].index {
|
||||||
|
t.Fatalf("row %d shows finding %d but a click resolves to %d",
|
||||||
|
row, rows[row].index, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total, _ := m.vulnerabilityScrollRows(); total != len(rendered) {
|
||||||
|
t.Fatalf("the scrollbar reports %d rows, %d are rendered", total, len(rendered))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,7 @@ type Model struct {
|
|||||||
agentOffset int
|
agentOffset int
|
||||||
vulnOffset int
|
vulnOffset int
|
||||||
modalChoice int
|
modalChoice int
|
||||||
|
reportFocus string
|
||||||
ready bool
|
ready bool
|
||||||
quitting bool
|
quitting bool
|
||||||
showSplash bool
|
showSplash bool
|
||||||
@@ -157,12 +158,16 @@ const (
|
|||||||
treeCursorBg = lipgloss.Color("#0178d4")
|
treeCursorBg = lipgloss.Color("#0178d4")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Scrollbar thumbs. Each panel keeps its own, and the track stays blank so a
|
// Scrollbar thumbs. The track stays blank so a scrollable panel does not gain a
|
||||||
// scrollable panel does not gain a visible rule down its edge.
|
// visible rule down its edge, and the thumb brightens while it is dragged, which
|
||||||
|
// is the feedback Textual gave through scrollbar-color-active.
|
||||||
|
//
|
||||||
|
// One resting color for every panel, rather than the three the stylesheet named.
|
||||||
|
// The chat pane's was #1a1a1a on black, which is invisible - the bar could not be
|
||||||
|
// found, let alone grabbed (#1005).
|
||||||
const (
|
const (
|
||||||
thumbTrace = lipgloss.Color("#1a1a1a")
|
thumbResting = lipgloss.Color("#3f3f46")
|
||||||
thumbAgents = lipgloss.Color("#404040")
|
thumbActive = lipgloss.Color("#9ca3af")
|
||||||
thumbFindings = lipgloss.Color("#333333")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Composer placeholders. The launch screen falls back to the short prompt when
|
// Composer placeholders. The launch screen falls back to the short prompt when
|
||||||
|
|||||||
@@ -527,7 +527,8 @@ func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model := newModel()
|
model := newModel()
|
||||||
updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyLeft})
|
// Tab moves between the buttons; the arrows step between reports.
|
||||||
|
updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyTab})
|
||||||
model = updated.(Model)
|
model = updated.(Model)
|
||||||
updated, cmd := model.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, cmd := model.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
model = updated.(Model)
|
model = updated.(Model)
|
||||||
@@ -560,8 +561,8 @@ func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) {
|
|||||||
X: copyX, Y: copyY, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
|
X: copyX, Y: copyY, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
|
||||||
})
|
})
|
||||||
model = updated.(Model)
|
model = updated.(Model)
|
||||||
if cmd == nil || model.modalChoice != 0 {
|
if cmd == nil || model.reportFocus != reportCopy {
|
||||||
t.Fatalf("mouse Copy was not activated: choice=%d cmd=%v", model.modalChoice, cmd)
|
t.Fatalf("mouse Copy was not activated: focus=%q cmd=%v", model.reportFocus, cmd)
|
||||||
}
|
}
|
||||||
cmd()
|
cmd()
|
||||||
if len(copied) != 2 {
|
if len(copied) != 2 {
|
||||||
@@ -820,8 +821,8 @@ func TestRunningViewerShowsCompleteWrappedURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
|
func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
|
||||||
top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbAgents)), "\n")
|
top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbResting)), "\n")
|
||||||
bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbAgents)), "\n")
|
bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbResting)), "\n")
|
||||||
|
|
||||||
// The track is blank, so only the thumb is drawn.
|
// The track is blank, so only the thumb is drawn.
|
||||||
if top[0] != "█" || top[5] != " " {
|
if top[0] != "█" || top[5] != " " {
|
||||||
@@ -830,10 +831,10 @@ func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
|
|||||||
if bottom[0] != " " || bottom[5] != "█" {
|
if bottom[0] != " " || bottom[5] != "█" {
|
||||||
t.Fatalf("bottom scrollbar is incorrect: %#v", bottom)
|
t.Fatalf("bottom scrollbar is incorrect: %#v", bottom)
|
||||||
}
|
}
|
||||||
if full := verticalScrollbar(4, 4, 4, 0, thumbAgents); full != "" {
|
if full := verticalScrollbar(4, 4, 4, 0, thumbResting); full != "" {
|
||||||
t.Fatalf("non-overflowing scrollbar should be hidden: %q", full)
|
t.Fatalf("non-overflowing scrollbar should be hidden: %q", full)
|
||||||
}
|
}
|
||||||
withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbAgents))
|
withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbResting))
|
||||||
if strings.ContainsAny(withoutBar, "█") {
|
if strings.ContainsAny(withoutBar, "█") {
|
||||||
t.Fatalf("non-overflowing panel rendered a scrollbar: %q", withoutBar)
|
t.Fatalf("non-overflowing panel rendered a scrollbar: %q", withoutBar)
|
||||||
}
|
}
|
||||||
@@ -841,7 +842,7 @@ func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
|
|||||||
|
|
||||||
// The bar takes exactly one column, so a scrolling panel keeps the rest.
|
// The bar takes exactly one column, so a scrolling panel keeps the rest.
|
||||||
func TestVerticalScrollbarOccupiesOneColumn(t *testing.T) {
|
func TestVerticalScrollbarOccupiesOneColumn(t *testing.T) {
|
||||||
rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbTrace), "\n")
|
rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbResting), "\n")
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if width := ansi.StringWidth(row); width != 12 {
|
if width := ansi.StringWidth(row); width != 12 {
|
||||||
t.Fatalf("scrolling panel row width = %d, want 12", width)
|
t.Fatalf("scrolling panel row width = %d, want 12", width)
|
||||||
|
|||||||
@@ -219,7 +219,8 @@ func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
|
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
|
||||||
m.focus = focusVulnerabilities
|
m.focus = focusVulnerabilities
|
||||||
m.input.Blur()
|
m.input.Blur()
|
||||||
m.vulnOffset = min(max(0, len(m.snapshot.Vulnerabilities)-1), m.vulnOffset+3)
|
totalRows, _ := m.vulnerabilityScrollRows()
|
||||||
|
m.vulnOffset = min(max(0, totalRows-m.vulnerabilityPageSize()), m.vulnOffset+3)
|
||||||
m.keepVulnerabilitySelectionInWindow()
|
m.keepVulnerabilitySelectionInWindow()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -318,24 +319,7 @@ func (m *Model) updateMainScrollbarMouse(
|
|||||||
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
|
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
target := m.scrollbarAt(msg, showSidebar, chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight)
|
||||||
target := scrollbarNone
|
|
||||||
switch {
|
|
||||||
case msg.X == chatWidth-2 && msg.Y >= 1 && msg.Y < chatHeight-1 &&
|
|
||||||
m.viewport.TotalLineCount() > m.viewport.VisibleLineCount():
|
|
||||||
target = scrollbarTrace
|
|
||||||
case showSidebar && msg.X == m.width-3 && msg.Y >= viewerHeight+2 &&
|
|
||||||
msg.Y < viewerHeight+agentHeight-2 &&
|
|
||||||
len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize():
|
|
||||||
target = scrollbarAgents
|
|
||||||
case showSidebar && vulnHeight > 0 && msg.X == m.width-3 &&
|
|
||||||
msg.Y >= viewerHeight+agentHeight+1 &&
|
|
||||||
msg.Y < viewerHeight+agentHeight+vulnHeight-1:
|
|
||||||
totalRows, _ := m.vulnerabilityScrollRows()
|
|
||||||
if totalRows > m.vulnerabilityPageSize() {
|
|
||||||
target = scrollbarFindings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if target == scrollbarNone {
|
if target == scrollbarNone {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -344,6 +328,40 @@ func (m *Model) updateMainScrollbarMouse(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scrollbarGrab is how far either side of the bar still counts as grabbing it. A
|
||||||
|
// one column target is unreasonable to hit with a mouse, and nothing else lives
|
||||||
|
// in the column beside it.
|
||||||
|
const scrollbarGrab = 1
|
||||||
|
|
||||||
|
func nearColumn(x, column int) bool {
|
||||||
|
return x >= column-scrollbarGrab && x <= column+scrollbarGrab
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrollbarAt reports which scrollbar, if any, the pointer is over.
|
||||||
|
func (m Model) scrollbarAt(
|
||||||
|
msg tea.MouseMsg,
|
||||||
|
showSidebar bool,
|
||||||
|
chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight int,
|
||||||
|
) scrollbarTarget {
|
||||||
|
switch {
|
||||||
|
case nearColumn(msg.X, chatWidth-2) && msg.Y >= 1 && msg.Y < chatHeight-1 &&
|
||||||
|
m.viewport.TotalLineCount() > m.viewport.VisibleLineCount():
|
||||||
|
return scrollbarTrace
|
||||||
|
case showSidebar && nearColumn(msg.X, m.width-3) && msg.Y >= viewerHeight+2 &&
|
||||||
|
msg.Y < viewerHeight+agentHeight-2 &&
|
||||||
|
len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize():
|
||||||
|
return scrollbarAgents
|
||||||
|
case showSidebar && vulnHeight > 0 && nearColumn(msg.X, m.width-3) &&
|
||||||
|
msg.Y >= viewerHeight+agentHeight+1 &&
|
||||||
|
msg.Y < viewerHeight+agentHeight+vulnHeight-1:
|
||||||
|
totalRows, _ := m.vulnerabilityScrollRows()
|
||||||
|
if totalRows > m.vulnerabilityPageSize() {
|
||||||
|
return scrollbarFindings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scrollbarNone
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) scrollFromMouse(
|
func (m *Model) scrollFromMouse(
|
||||||
target scrollbarTarget,
|
target scrollbarTarget,
|
||||||
y, chatHeight, viewerHeight, agentHeight int,
|
y, chatHeight, viewerHeight, agentHeight int,
|
||||||
@@ -367,10 +385,10 @@ func (m *Model) scrollFromMouse(
|
|||||||
case scrollbarFindings:
|
case scrollbarFindings:
|
||||||
height := m.vulnerabilityPageSize()
|
height := m.vulnerabilityPageSize()
|
||||||
totalRows, _ := m.vulnerabilityScrollRows()
|
totalRows, _ := m.vulnerabilityScrollRows()
|
||||||
rowOffset := scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height)
|
|
||||||
m.focus = focusVulnerabilities
|
m.focus = focusVulnerabilities
|
||||||
m.input.Blur()
|
m.input.Blur()
|
||||||
m.vulnOffset = m.vulnerabilityOffsetAtRow(rowOffset)
|
// The offset is a row, so dragging moves the list continuously.
|
||||||
|
m.vulnOffset = scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height)
|
||||||
m.keepVulnerabilitySelectionInWindow()
|
m.keepVulnerabilitySelectionInWindow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,6 +423,22 @@ func (m Model) updateSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pressReportButton performs a button of the report row, however it was reached.
|
||||||
|
func (m Model) pressReportButton(button string) (tea.Model, tea.Cmd) {
|
||||||
|
switch button {
|
||||||
|
case reportPrev:
|
||||||
|
m.showVulnerability(m.selectedVuln - 1)
|
||||||
|
case reportNext:
|
||||||
|
m.showVulnerability(m.selectedVuln + 1)
|
||||||
|
case reportCopy:
|
||||||
|
m.reportFocus = reportCopy
|
||||||
|
return m, m.startVulnerabilityCopy()
|
||||||
|
default:
|
||||||
|
m.closeModal()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||||
if m.modal == modalVulnerability {
|
if m.modal == modalVulnerability {
|
||||||
view := m.modalView()
|
view := m.modalView()
|
||||||
@@ -441,13 +475,22 @@ func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
}
|
}
|
||||||
case modalVulnerability:
|
case modalVulnerability:
|
||||||
|
for _, button := range m.reportButtons() {
|
||||||
|
if button == reportCopy || button == reportDone {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.centeredLabelHit(view, button, msg.X, msg.Y) {
|
||||||
|
m.reportFocus = button
|
||||||
|
return m.pressReportButton(button)
|
||||||
|
}
|
||||||
|
}
|
||||||
if m.centeredLabelHit(view, "Copy", msg.X, msg.Y) {
|
if m.centeredLabelHit(view, "Copy", msg.X, msg.Y) {
|
||||||
m.modalChoice = 0
|
m.reportFocus = reportCopy
|
||||||
cmd := m.startVulnerabilityCopy()
|
cmd := m.startVulnerabilityCopy()
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
if m.centeredLabelHit(view, "Done", msg.X, msg.Y) {
|
if m.centeredLabelHit(view, "Done", msg.X, msg.Y) {
|
||||||
m.modalChoice = 1
|
m.reportFocus = reportDone
|
||||||
m.closeModal()
|
m.closeModal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,16 +559,19 @@ func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
switch key.String() {
|
switch key.String() {
|
||||||
case "esc":
|
case "esc":
|
||||||
m.closeModal()
|
m.closeModal()
|
||||||
case "left", "right", "tab", "shift+tab":
|
// The arrows step between reports directly; tab walks the button row.
|
||||||
m.modalChoice = 1 - m.modalChoice
|
case "left":
|
||||||
|
m.showVulnerability(m.selectedVuln - 1)
|
||||||
|
case "right":
|
||||||
|
m.showVulnerability(m.selectedVuln + 1)
|
||||||
|
case "tab":
|
||||||
|
m.stepReportFocus(1)
|
||||||
|
case "shift+tab":
|
||||||
|
m.stepReportFocus(-1)
|
||||||
case "enter":
|
case "enter":
|
||||||
if m.modalChoice == 0 {
|
return m.pressReportButton(m.focusedReportButton())
|
||||||
cmd := m.startVulnerabilityCopy()
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
m.closeModal()
|
|
||||||
case "c":
|
case "c":
|
||||||
m.modalChoice = 0
|
m.reportFocus = reportCopy
|
||||||
cmd := m.startVulnerabilityCopy()
|
cmd := m.startVulnerabilityCopy()
|
||||||
return m, cmd
|
return m, cmd
|
||||||
case "up":
|
case "up":
|
||||||
@@ -584,6 +630,7 @@ func (m *Model) openModal(mode modalMode) {
|
|||||||
m.modalChoice = 1
|
m.modalChoice = 1
|
||||||
}
|
}
|
||||||
if mode == modalVulnerability {
|
if mode == modalVulnerability {
|
||||||
|
m.reportFocus = reportDone
|
||||||
m.modalChoice = 1
|
m.modalChoice = 1
|
||||||
m.vulnerabilityCopied = false
|
m.vulnerabilityCopied = false
|
||||||
m.vulnerabilityCopyError = ""
|
m.vulnerabilityCopyError = ""
|
||||||
|
|||||||
@@ -164,6 +164,15 @@ func wrapBlock(value string, width int) string {
|
|||||||
return strings.Join(out, "\n")
|
return strings.Join(out, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scrollbarThumb brightens the bar being dragged so the grab reads as taking
|
||||||
|
// hold of it.
|
||||||
|
func (m Model) scrollbarThumb(target scrollbarTarget) lipgloss.Color {
|
||||||
|
if m.draggingScrollbar == target {
|
||||||
|
return thumbActive
|
||||||
|
}
|
||||||
|
return thumbResting
|
||||||
|
}
|
||||||
|
|
||||||
func verticalScrollbar(height, total, visible, offset int, thumb lipgloss.Color) string {
|
func verticalScrollbar(height, total, visible, offset int, thumb lipgloss.Color) string {
|
||||||
if height <= 0 || total <= visible {
|
if height <= 0 || total <= visible {
|
||||||
return ""
|
return ""
|
||||||
@@ -424,7 +433,7 @@ func (m Model) renderChatPane(width, height int, border lipgloss.Color) string {
|
|||||||
m.viewport.TotalLineCount(),
|
m.viewport.TotalLineCount(),
|
||||||
m.viewport.VisibleLineCount(),
|
m.viewport.VisibleLineCount(),
|
||||||
m.viewport.YOffset,
|
m.viewport.YOffset,
|
||||||
thumbTrace,
|
m.scrollbarThumb(scrollbarTrace),
|
||||||
)
|
)
|
||||||
out := lipgloss.NewStyle().Width(width).Height(height).
|
out := lipgloss.NewStyle().Width(width).Height(height).
|
||||||
Border(lipgloss.RoundedBorder()).BorderForeground(border).Render(trace)
|
Border(lipgloss.RoundedBorder()).BorderForeground(border).Render(trace)
|
||||||
@@ -489,7 +498,7 @@ func (m Model) sidebarView(width, height int) string {
|
|||||||
len(agentEntries),
|
len(agentEntries),
|
||||||
agentRows,
|
agentRows,
|
||||||
m.agentOffset,
|
m.agentOffset,
|
||||||
thumbAgents,
|
m.scrollbarThumb(scrollbarAgents),
|
||||||
)
|
)
|
||||||
parts := []string{
|
parts := []string{
|
||||||
lipgloss.NewStyle().Width(width-2).Height(m.viewerHeight()-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(m.viewerView(width - 4)),
|
lipgloss.NewStyle().Width(width-2).Height(m.viewerHeight()-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(m.viewerView(width - 4)),
|
||||||
@@ -503,13 +512,13 @@ func (m Model) sidebarView(width, height int) string {
|
|||||||
vulnRows := max(1, vulnHeight-2)
|
vulnRows := max(1, vulnHeight-2)
|
||||||
totalRows, offsetRows := m.vulnerabilityScrollRows()
|
totalRows, offsetRows := m.vulnerabilityScrollRows()
|
||||||
findings := withVerticalScrollbar(
|
findings := withVerticalScrollbar(
|
||||||
m.vulnerabilitiesView(max(1, width-5), vulnRows),
|
m.vulnerabilitiesView(m.vulnerabilityListWidth(), vulnRows),
|
||||||
width-4,
|
width-4,
|
||||||
vulnRows,
|
vulnRows,
|
||||||
totalRows,
|
totalRows,
|
||||||
vulnRows,
|
vulnRows,
|
||||||
offsetRows,
|
offsetRows,
|
||||||
thumbFindings,
|
m.scrollbarThumb(scrollbarFindings),
|
||||||
)
|
)
|
||||||
parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(vulnRows).Border(lipgloss.RoundedBorder()).BorderForeground(vulnBorder).Padding(0, 1).Render(findings))
|
parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(vulnRows).Border(lipgloss.RoundedBorder()).BorderForeground(vulnBorder).Padding(0, 1).Render(findings))
|
||||||
}
|
}
|
||||||
@@ -524,12 +533,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, agentHeight int) {
|
|||||||
statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.viewerContentWidth()).Render(m.statsView()))
|
statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.viewerContentWidth()).Render(m.statsView()))
|
||||||
statsHeight = min(15, statsRows+2)
|
statsHeight = min(15, statsRows+2)
|
||||||
if len(m.snapshot.Vulnerabilities) > 0 {
|
if len(m.snapshot.Vulnerabilities) > 0 {
|
||||||
rows := 0
|
vulnHeight = min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+2)
|
||||||
width := m.vulnerabilityListWidth()
|
|
||||||
for i := range m.snapshot.Vulnerabilities {
|
|
||||||
rows += len(m.vulnerabilityTitleLines(i, width))
|
|
||||||
}
|
|
||||||
vulnHeight = min(12, rows+2)
|
|
||||||
}
|
}
|
||||||
agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight)
|
agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -13,115 +14,123 @@ var panelSeverityColors = map[string]lipgloss.Color{
|
|||||||
"critical": render.SevCrit, "high": render.SevHigh, "medium": render.SevMed, "low": green, "info": blue,
|
"critical": render.SevCrit, "high": render.SevHigh, "medium": render.SevMed, "low": green, "info": blue,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) vulnerabilitiesView(width, height int) string {
|
// vulnerabilityRow is one rendered line of the findings list. The list scrolls by
|
||||||
var lines []string
|
// row rather than by finding, so a long title does not make the panel jump a
|
||||||
start := min(max(0, m.vulnOffset), max(0, len(m.snapshot.Vulnerabilities)-1))
|
// whole entry at a time.
|
||||||
for i := start; i < len(m.snapshot.Vulnerabilities) && len(lines) < height; i++ {
|
type vulnerabilityRow struct {
|
||||||
vuln := m.snapshot.Vulnerabilities[i]
|
index int // the finding this line belongs to
|
||||||
severity := strings.ToLower(render.StringValue(vuln["severity"]))
|
text string // one wrapped line of its title
|
||||||
color, ok := panelSeverityColors[severity]
|
first bool // the line that carries the number and the severity dot
|
||||||
if !ok {
|
}
|
||||||
color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6")
|
|
||||||
|
// vulnerabilityRows lays every finding out as the lines it will occupy.
|
||||||
|
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 line, text := range strings.Split(wrapBlock(m.vulnerabilityTitle(i), body), "\n") {
|
||||||
|
rows = append(rows, vulnerabilityRow{index: i, text: text, first: line == 0})
|
||||||
}
|
}
|
||||||
marker := lipgloss.NewStyle().Foreground(color).Render("● ")
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) vulnerabilitiesView(width, height int) string {
|
||||||
|
rows := m.vulnerabilityRows(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))
|
||||||
|
for _, row := range rows[start:end] {
|
||||||
style := lipgloss.NewStyle().Foreground(textColor)
|
style := lipgloss.NewStyle().Foreground(textColor)
|
||||||
if i == m.selectedVuln {
|
if row.index == m.selectedVuln {
|
||||||
style = style.Bold(true).Foreground(white)
|
style = style.Bold(true).Foreground(white)
|
||||||
}
|
}
|
||||||
for row, titleLine := range m.vulnerabilityTitleLines(i, width) {
|
prefix := " "
|
||||||
if len(lines) >= height {
|
if row.first {
|
||||||
break
|
severity := strings.ToLower(render.StringValue(m.snapshot.Vulnerabilities[row.index]["severity"]))
|
||||||
|
color, ok := panelSeverityColors[severity]
|
||||||
|
if !ok {
|
||||||
|
color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6")
|
||||||
}
|
}
|
||||||
prefix := " "
|
prefix = lipgloss.NewStyle().Foreground(color).Render("● ")
|
||||||
if row == 0 {
|
|
||||||
prefix = marker
|
|
||||||
}
|
|
||||||
lines = append(lines, prefix+style.Render(titleLine))
|
|
||||||
}
|
}
|
||||||
|
lines = append(lines, prefix+style.Render(row.text))
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vulnerabilityListWidth is the one width the findings list is laid out at, for
|
||||||
|
// rendering and for every interaction alike. Wrapping a title at two widths a
|
||||||
|
// column apart gives two different row counts, and then a click resolves to the
|
||||||
|
// wrong finding and the scrollbar reports the wrong length.
|
||||||
|
//
|
||||||
|
// The panel is sidebarWidth-2 wide with a column of padding either side, and the
|
||||||
|
// scrollbar takes one more. That last column is reserved whether or not the bar
|
||||||
|
// is showing, so the layout does not shift as the list grows past the panel.
|
||||||
func (m Model) vulnerabilityListWidth() int {
|
func (m Model) vulnerabilityListWidth() int {
|
||||||
_, sidebarWidth, _, _ := m.layout()
|
_, sidebarWidth, _, _ := m.layout()
|
||||||
return max(1, sidebarWidth-6)
|
return max(1, sidebarWidth-5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) vulnerabilityTitleLines(index, width int) []string {
|
func (m Model) vulnerabilityTitle(index int) string {
|
||||||
title := render.StringValue(m.snapshot.Vulnerabilities[index]["title"])
|
title := render.StringValue(m.snapshot.Vulnerabilities[index]["title"])
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "Unknown Vulnerability"
|
title = "Unknown Vulnerability"
|
||||||
}
|
}
|
||||||
return strings.Split(wrapBlock(title, max(1, width-2)), "\n")
|
return title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vulnerabilityScrollRows reports the list length and position in rows, which is
|
||||||
|
// what the scrollbar needs to move continuously.
|
||||||
func (m Model) vulnerabilityScrollRows() (total, offset int) {
|
func (m Model) vulnerabilityScrollRows() (total, offset int) {
|
||||||
width := m.vulnerabilityListWidth()
|
return len(m.vulnerabilityRows(m.vulnerabilityListWidth())), m.vulnOffset
|
||||||
for i := range m.snapshot.Vulnerabilities {
|
|
||||||
rows := len(m.vulnerabilityTitleLines(i, width))
|
|
||||||
total += rows
|
|
||||||
if i < m.vulnOffset {
|
|
||||||
offset += rows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total, offset
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) vulnerabilityOffsetAtRow(targetRow int) int {
|
|
||||||
width := m.vulnerabilityListWidth()
|
|
||||||
row := 0
|
|
||||||
for i := range m.snapshot.Vulnerabilities {
|
|
||||||
row += len(m.vulnerabilityTitleLines(i, width))
|
|
||||||
if targetRow < row {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return max(0, len(m.snapshot.Vulnerabilities)-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) vulnerabilityVisibleEnd(start int) int {
|
|
||||||
height := m.vulnerabilityPageSize()
|
|
||||||
width := m.vulnerabilityListWidth()
|
|
||||||
rows := 0
|
|
||||||
end := min(max(0, start), len(m.snapshot.Vulnerabilities))
|
|
||||||
for end < len(m.snapshot.Vulnerabilities) {
|
|
||||||
itemRows := len(m.vulnerabilityTitleLines(end, width))
|
|
||||||
if rows > 0 && rows+itemRows > height {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
rows += itemRows
|
|
||||||
end++
|
|
||||||
if rows >= height {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return end
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vulnerabilityIndexAtRow maps a click on a visible row back to its finding.
|
||||||
func (m Model) vulnerabilityIndexAtRow(row int) int {
|
func (m Model) vulnerabilityIndexAtRow(row int) int {
|
||||||
width := m.vulnerabilityListWidth()
|
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
|
||||||
currentRow := 0
|
target := m.vulnOffset + row
|
||||||
for i := m.vulnOffset; i < m.vulnerabilityVisibleEnd(m.vulnOffset); i++ {
|
if target < 0 || target >= len(rows) {
|
||||||
currentRow += len(m.vulnerabilityTitleLines(i, width))
|
return -1
|
||||||
if row < currentRow {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return -1
|
return rows[target].index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureVulnerabilityVisible scrolls the least it can to bring the selected
|
||||||
|
// finding into view, keeping the whole entry visible where it fits.
|
||||||
func (m *Model) ensureVulnerabilityVisible() {
|
func (m *Model) ensureVulnerabilityVisible() {
|
||||||
if len(m.snapshot.Vulnerabilities) == 0 {
|
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
|
||||||
|
if len(rows) == 0 {
|
||||||
m.vulnOffset = 0
|
m.vulnOffset = 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if m.selectedVuln < m.vulnOffset {
|
height := m.vulnerabilityPageSize()
|
||||||
m.vulnOffset = m.selectedVuln
|
firstRow, lastRow := -1, -1
|
||||||
|
for row, entry := range rows {
|
||||||
|
if entry.index != m.selectedVuln {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if firstRow < 0 {
|
||||||
|
firstRow = row
|
||||||
|
}
|
||||||
|
lastRow = row
|
||||||
}
|
}
|
||||||
for m.selectedVuln >= m.vulnerabilityVisibleEnd(m.vulnOffset) && m.vulnOffset < m.selectedVuln {
|
if firstRow < 0 {
|
||||||
m.vulnOffset++
|
m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
m.vulnOffset = min(m.vulnOffset, len(m.snapshot.Vulnerabilities)-1)
|
if firstRow < m.vulnOffset {
|
||||||
|
m.vulnOffset = firstRow
|
||||||
|
} else if lastRow >= m.vulnOffset+height {
|
||||||
|
// Prefer showing the whole entry, but never scroll its start out of view.
|
||||||
|
m.vulnOffset = min(firstRow, lastRow-height+1)
|
||||||
|
}
|
||||||
|
m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampVulnerabilityOffset(offset, total, height int) int {
|
||||||
|
return min(max(0, offset), max(0, total-height))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) vulnerabilityPageSize() int {
|
func (m Model) vulnerabilityPageSize() int {
|
||||||
@@ -129,23 +138,52 @@ func (m Model) vulnerabilityPageSize() int {
|
|||||||
return max(1, vulnHeight-2)
|
return max(1, vulnHeight-2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vulnerabilityPageItems is how many findings a page step should move by: the
|
||||||
|
// number of distinct entries currently on screen.
|
||||||
func (m Model) vulnerabilityPageItems() int {
|
func (m Model) vulnerabilityPageItems() int {
|
||||||
return max(1, m.vulnerabilityVisibleEnd(m.vulnOffset)-m.vulnOffset)
|
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
|
||||||
|
height := m.vulnerabilityPageSize()
|
||||||
|
start := min(max(0, m.vulnOffset), max(0, len(rows)))
|
||||||
|
end := min(len(rows), start+height)
|
||||||
|
seen := 0
|
||||||
|
previous := -1
|
||||||
|
for _, row := range rows[start:end] {
|
||||||
|
if row.index != previous {
|
||||||
|
seen++
|
||||||
|
previous = row.index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max(1, seen)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) moveVulnerabilitySelection(delta int) {
|
func (m *Model) moveVulnerabilitySelection(delta int) {
|
||||||
m.selectedVuln = max(0, min(len(m.snapshot.Vulnerabilities)-1, m.selectedVuln+delta))
|
m.selectedVuln = max(0, min(len(m.snapshot.Vulnerabilities)-1, m.selectedVuln+delta))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keepVulnerabilitySelectionInWindow pulls the selection to the nearest finding
|
||||||
|
// still on screen after the list has been scrolled directly.
|
||||||
func (m *Model) keepVulnerabilitySelectionInWindow() {
|
func (m *Model) keepVulnerabilitySelectionInWindow() {
|
||||||
if len(m.snapshot.Vulnerabilities) == 0 {
|
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
|
||||||
|
if len(rows) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if m.selectedVuln < m.vulnOffset {
|
height := m.vulnerabilityPageSize()
|
||||||
m.selectedVuln = m.vulnOffset
|
start := min(max(0, m.vulnOffset), max(0, len(rows)-1))
|
||||||
} else if end := m.vulnerabilityVisibleEnd(m.vulnOffset); m.selectedVuln >= end {
|
end := min(len(rows), start+height)
|
||||||
m.selectedVuln = max(m.vulnOffset, end-1)
|
visible := rows[start:end]
|
||||||
|
if len(visible) == 0 {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
for _, row := range visible {
|
||||||
|
if row.index == m.selectedVuln {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.selectedVuln < visible[0].index {
|
||||||
|
m.selectedVuln = visible[0].index
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.selectedVuln = visible[len(visible)-1].index
|
||||||
}
|
}
|
||||||
|
|
||||||
// statsView ports build_tui_stats_text + the version line appended in
|
// statsView ports build_tui_stats_text + the version line appended in
|
||||||
@@ -373,25 +411,116 @@ func (m Model) vulnerabilityDetail() string {
|
|||||||
inner := max(1, width-8)
|
inner := max(1, width-8)
|
||||||
// Button row: right-aligned Copy / Done above a top rule (#vuln_detail_buttons).
|
// Button row: right-aligned Copy / Done above a top rule (#vuln_detail_buttons).
|
||||||
rule := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a1a")).Render(strings.Repeat("─", max(1, inner)))
|
rule := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a1a")).Render(strings.Repeat("─", max(1, inner)))
|
||||||
copyLabel := "Copy"
|
focused := m.focusedReportButton()
|
||||||
if m.vulnerabilityCopied {
|
var stepping, acting []string
|
||||||
copyLabel = "Copied!"
|
for _, button := range m.reportButtons() {
|
||||||
} else if m.vulnerabilityCopyError != "" {
|
rendered := m.reportButton(button, button == focused)
|
||||||
copyLabel = "Copy failed"
|
if button == reportPrev || button == reportNext {
|
||||||
|
stepping = append(stepping, rendered)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acting = append(acting, rendered)
|
||||||
}
|
}
|
||||||
copyButton := lipgloss.NewStyle().Foreground(lipgloss.Color("#525252"))
|
// Stepping sits on the left behind the position, acting on the right.
|
||||||
doneButton := lipgloss.NewStyle().Foreground(mid)
|
right := strings.Join(acting, " ")
|
||||||
if m.modalChoice == 0 {
|
left := strings.Join(stepping, " ")
|
||||||
copyButton = copyButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1)
|
if total := len(m.snapshot.Vulnerabilities); total > 1 {
|
||||||
} else {
|
left = render.Dim().Render(fmt.Sprintf("%d/%d", m.selectedVuln+1, total)) + " " + left
|
||||||
doneButton = doneButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1)
|
|
||||||
}
|
}
|
||||||
buttons := copyButton.Render(copyLabel) + " " + doneButton.Render("Done")
|
room := max(0, inner-lipgloss.Width(right))
|
||||||
buttonRow := rule + "\n" + lipgloss.NewStyle().Width(inner).Align(lipgloss.Right).Render(buttons)
|
buttonRow := rule + "\n" +
|
||||||
|
lipgloss.NewStyle().Width(room).Render(truncate(left, room)) + right
|
||||||
content := m.vulnerabilityScrollView() + "\n" + buttonRow
|
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)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// showVulnerability moves the open report to another finding, keeping the list
|
||||||
|
// behind it in step and starting the new report at its top.
|
||||||
|
func (m *Model) showVulnerability(index int) {
|
||||||
|
if index < 0 || index >= len(m.snapshot.Vulnerabilities) || index == m.selectedVuln {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.selectedVuln = index
|
||||||
|
m.ensureVulnerabilityVisible()
|
||||||
|
// The copy state belongs to the report that was on screen, not this one.
|
||||||
|
m.vulnerabilityCopied = false
|
||||||
|
m.vulnerabilityCopyError = ""
|
||||||
|
m.resizeVulnerabilityViewport()
|
||||||
|
m.vulnViewport.GotoTop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reportButtons is the row as it stands, left to right. Stepping is offered only
|
||||||
|
// in the directions that have a report.
|
||||||
|
func (m Model) reportButtons() []string {
|
||||||
|
previous, next := m.vulnerabilityNeighbors()
|
||||||
|
buttons := make([]string, 0, 4)
|
||||||
|
if previous {
|
||||||
|
buttons = append(buttons, reportPrev)
|
||||||
|
}
|
||||||
|
if next {
|
||||||
|
buttons = append(buttons, reportNext)
|
||||||
|
}
|
||||||
|
return append(buttons, reportCopy, reportDone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusedReportButton is the button Enter would press. It falls back to Done when
|
||||||
|
// the focused one has gone, which happens when stepping to either end drops a
|
||||||
|
// direction from the row.
|
||||||
|
func (m Model) focusedReportButton() string {
|
||||||
|
for _, button := range m.reportButtons() {
|
||||||
|
if button == m.reportFocus {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reportDone
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepReportFocus moves along the row, wrapping at its ends.
|
||||||
|
func (m *Model) stepReportFocus(delta int) {
|
||||||
|
buttons := m.reportButtons()
|
||||||
|
current := 0
|
||||||
|
for i, button := range buttons {
|
||||||
|
if button == m.focusedReportButton() {
|
||||||
|
current = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.reportFocus = buttons[clampCycle(current+delta, len(buttons))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// vulnerabilityNeighbors reports which way the open report can be stepped. The
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportButton renders one button of the report row. Copy reports the outcome of
|
||||||
|
// the last attempt in its own label.
|
||||||
|
func (m Model) reportButton(label string, focused bool) string {
|
||||||
|
if label == reportCopy {
|
||||||
|
switch {
|
||||||
|
case m.vulnerabilityCopied:
|
||||||
|
label = "Copied!"
|
||||||
|
case m.vulnerabilityCopyError != "":
|
||||||
|
label = "Copy failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if focused {
|
||||||
|
return lipgloss.NewStyle().Background(lipgloss.Color("#363636")).
|
||||||
|
Foreground(brightWhite).Bold(true).Padding(0, 1).Render(label)
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#525252")).Render(label)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) startVulnerabilityCopy() tea.Cmd {
|
func (m *Model) startVulnerabilityCopy() tea.Cmd {
|
||||||
m.vulnerabilityCopied = false
|
m.vulnerabilityCopied = false
|
||||||
m.vulnerabilityCopyError = ""
|
m.vulnerabilityCopyError = ""
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import threading
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeGuard
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from strix.telemetry import posthog, scarf
|
from strix.telemetry import posthog, scarf
|
||||||
from strix.utils.resource_paths import get_strix_resource_path
|
from strix.utils.resource_paths import get_strix_resource_path
|
||||||
@@ -11,12 +14,17 @@ from strix.utils.resource_paths import get_strix_resource_path
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
|
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n", re.DOTALL)
|
||||||
|
|
||||||
_INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"})
|
_INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"})
|
||||||
_ROOT_SKILL_CATEGORY = "root"
|
_ROOT_SKILL_CATEGORY = "root"
|
||||||
|
|
||||||
_EXTRA_SKILL_DIRS: list[Path] = []
|
_EXTRA_SKILL_DIRS: list[Path] = []
|
||||||
|
_SKILL_METADATA_CACHE: dict[tuple[Path, int, int], dict[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_frontmatter_mapping(value: object) -> TypeGuard[dict[object, object]]:
|
||||||
|
return isinstance(value, dict)
|
||||||
|
|
||||||
|
|
||||||
def register_skill_dir(path: str | Path) -> None:
|
def register_skill_dir(path: str | Path) -> None:
|
||||||
@@ -109,13 +117,18 @@ def _get_ambiguous_skill_names() -> set[str]:
|
|||||||
return {name for name, count in counts.items() if count > 1}
|
return {name for name, count in counts.items() if count > 1}
|
||||||
|
|
||||||
|
|
||||||
def _qualified_skill_files(skill_name: str) -> list[Path]:
|
def _qualified_skill_file_for_name(skill_name: str) -> Path | None:
|
||||||
category, _, name = skill_name.partition("/")
|
category, _, name = skill_name.partition("/")
|
||||||
for skills_dir in skill_search_dirs():
|
for skills_dir in skill_search_dirs():
|
||||||
candidate = _qualified_skill_file(skills_dir, category, name)
|
candidate = _qualified_skill_file(skills_dir, category, name)
|
||||||
if candidate is not None:
|
if candidate is not None:
|
||||||
return [candidate]
|
return candidate
|
||||||
return []
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _qualified_skill_files(skill_name: str) -> list[Path]:
|
||||||
|
candidate = _qualified_skill_file_for_name(skill_name)
|
||||||
|
return [candidate] if candidate is not None else []
|
||||||
|
|
||||||
|
|
||||||
def _bare_skill_files(skill_name: str) -> list[Path]:
|
def _bare_skill_files(skill_name: str) -> list[Path]:
|
||||||
@@ -145,10 +158,59 @@ def _bare_skill_files(skill_name: str) -> list[Path]:
|
|||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
def get_available_skills() -> dict[str, list[str]]:
|
def _parse_skill_content(content: str, source: Path | None = None) -> tuple[dict[str, str], str]:
|
||||||
grouped: dict[str, list[str]] = {}
|
"""Parse skill frontmatter once and return metadata plus markdown body."""
|
||||||
|
frontmatter = _FRONTMATTER_PATTERN.match(content)
|
||||||
|
if frontmatter is None:
|
||||||
|
return {}, content.lstrip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed: object = yaml.safe_load(frontmatter.group("body"))
|
||||||
|
except yaml.YAMLError as error:
|
||||||
|
logger.warning("Failed to parse skill frontmatter %s: %s", source or "<content>", error)
|
||||||
|
parsed = None
|
||||||
|
if not _is_frontmatter_mapping(parsed):
|
||||||
|
logger.warning("Skill frontmatter is not a mapping: %s", source or "<content>")
|
||||||
|
return {}, content[frontmatter.end() :].lstrip()
|
||||||
|
|
||||||
|
metadata = {str(key): "" if value is None else str(value) for key, value in parsed.items()}
|
||||||
|
return metadata, content[frontmatter.end() :].lstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_skill_metadata(file_path: Path) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
stat = file_path.stat()
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Skill file disappeared while reading metadata: %s", file_path)
|
||||||
|
return {}
|
||||||
|
cache_key = (file_path, stat.st_mtime_ns, stat.st_size)
|
||||||
|
cached = _SKILL_METADATA_CACHE.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
except (OSError, ValueError):
|
||||||
|
logger.warning("Failed to read skill metadata: %s", file_path)
|
||||||
|
return {}
|
||||||
|
metadata, _ = _parse_skill_content(content, file_path)
|
||||||
|
_SKILL_METADATA_CACHE[cache_key] = metadata
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_skills() -> dict[str, list[dict[str, str]]]:
|
||||||
|
grouped: dict[str, list[dict[str, str]]] = {}
|
||||||
for category, name in _iter_user_skill_files():
|
for category, name in _iter_user_skill_files():
|
||||||
grouped.setdefault(category, []).append(name)
|
file_path = _qualified_skill_file_for_name(f"{category}/{name}")
|
||||||
|
if file_path is None:
|
||||||
|
logger.warning(
|
||||||
|
"Skill disappeared while gathering available skills: %s/%s",
|
||||||
|
category,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
metadata = _read_skill_metadata(file_path)
|
||||||
|
description = " ".join(metadata.get("description", "").split())
|
||||||
|
grouped.setdefault(category, []).append({"name": name, "description": description})
|
||||||
return grouped
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
@@ -228,7 +290,8 @@ def load_skills(skill_names: list[str]) -> dict[str, str]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
var_name = skill_name.split("/")[-1]
|
var_name = skill_name.split("/")[-1]
|
||||||
skill_content[var_name] = _FRONTMATTER_PATTERN.sub("", content).lstrip()
|
_, skill_body = _parse_skill_content(content, file_path)
|
||||||
|
skill_content[var_name] = skill_body
|
||||||
logger.debug("Loaded skill: %s -> %s", skill_name, var_name)
|
logger.debug("Loaded skill: %s -> %s", skill_name, var_name)
|
||||||
_track_skill_loaded(var_name, file_path)
|
_track_skill_loaded(var_name, file_path)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ GCP misconfigurations expose project data, service account keys, and lateral mov
|
|||||||
|
|
||||||
**Storage & Data**
|
**Storage & Data**
|
||||||
- Cloud Storage (GCS) buckets and objects
|
- Cloud Storage (GCS) buckets and objects
|
||||||
- BigQuery datasets, Cloud SQL instances, Firestore (see `firebase_firestore` skill)
|
- BigQuery datasets, Cloud SQL instances, Firestore (see `firebase` skill)
|
||||||
- Secret Manager, Cloud KMS keys
|
- Secret Manager, Cloud KMS keys
|
||||||
|
|
||||||
**Compute**
|
**Compute**
|
||||||
|
|||||||
+63
-11
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: firebase-firestore
|
name: firebase
|
||||||
description: Firebase/Firestore security testing covering security rules, Cloud Functions, and client-side trust issues
|
description: Firebase security testing covering Firestore, Storage rules, Realtime Database, Auth, Functions, and client-side trust issues
|
||||||
---
|
---
|
||||||
|
|
||||||
# Firebase / Firestore
|
# Firebase
|
||||||
|
|
||||||
Security testing for Firebase applications. Focus on Firestore/Realtime Database rules, Cloud Storage exposure, callable/onRequest Functions trusting client input, and incorrect ID token validation.
|
Security testing for Firebase applications. Focus on Firestore/Realtime Database rules, Cloud Storage exposure, callable/onRequest Functions trusting client input, and incorrect ID token validation.
|
||||||
|
|
||||||
@@ -30,7 +30,17 @@ Security testing for Firebase applications. Focus on Firestore/Realtime Database
|
|||||||
**Endpoints**
|
**Endpoints**
|
||||||
- Firestore REST: `https://firestore.googleapis.com/v1/projects/<project>/databases/(default)/documents/<path>`
|
- Firestore REST: `https://firestore.googleapis.com/v1/projects/<project>/databases/(default)/documents/<path>`
|
||||||
- Realtime DB: `https://<project>.firebaseio.com/.json`
|
- Realtime DB: `https://<project>.firebaseio.com/.json`
|
||||||
- Storage REST: `https://storage.googleapis.com/storage/v1/b/<bucket>`
|
- GCS JSON API: `https://storage.googleapis.com/storage/v1/b/<bucket>`
|
||||||
|
- Firebase Storage rules API: `https://firebasestorage.googleapis.com/v0/b/<bucket>/o`
|
||||||
|
|
||||||
|
Cloud Storage has two front doors with different authorization engines:
|
||||||
|
|
||||||
|
| Front door | Authorization engine |
|
||||||
|
| --- | --- |
|
||||||
|
| `storage.googleapis.com/<bucket>/<object>` and `/storage/v1/b/<bucket>` | GCS IAM and per-object ACLs |
|
||||||
|
| `firebasestorage.googleapis.com/v0/b/<bucket>/o` | Firebase Storage Security Rules |
|
||||||
|
|
||||||
|
A `403` from a GCS URL does not prove that Firebase Storage rules deny access. Always test both doors.
|
||||||
|
|
||||||
**Auth**
|
**Auth**
|
||||||
- Google-signed ID tokens (iss: `accounts.google.com` or `securetoken.google.com/<project>`)
|
- Google-signed ID tokens (iss: `accounts.google.com` or `securetoken.google.com/<project>`)
|
||||||
@@ -117,9 +127,43 @@ exists(/databases/(default)/documents/orgs/$(org)/members/$(request.auth.uid))
|
|||||||
- Public reads on sensitive buckets/paths
|
- Public reads on sensitive buckets/paths
|
||||||
- Signed URLs with long TTL, no content-disposition controls, replayable across tenants
|
- Signed URLs with long TTL, no content-disposition controls, replayable across tenants
|
||||||
- List operations exposed: `/o?prefix=` enumerates object keys
|
- List operations exposed: `/o?prefix=` enumerates object keys
|
||||||
|
- Firebase Storage rules allowing unauthenticated or overly broad reads and writes
|
||||||
|
|
||||||
|
**Firebase Storage rules checks**
|
||||||
|
|
||||||
|
Probe the rules door separately from GCS IAM and ACLs:
|
||||||
|
|
||||||
|
1. Unauthenticated list: `GET https://firebasestorage.googleapis.com/v0/b/<bucket>/o?prefix=<known-prefix>`
|
||||||
|
2. Unauthenticated read of a known object path
|
||||||
|
3. Unauthenticated write/upload to a uniquely named test object
|
||||||
|
4. Repeat list, read, and write as an anonymous-auth principal when anonymous sign-in is enabled
|
||||||
|
5. Repeat the same matrix as a low-privilege authenticated user
|
||||||
|
|
||||||
|
Write access is as important as read access and is routinely missed. Record status, response body, and object existence after each attempt; clean up only test objects that the test principal created.
|
||||||
|
|
||||||
|
Review rules source when present and flag:
|
||||||
|
|
||||||
|
- `allow read, write: if request.time < timestamp.date(...)` — the common console test-mode time gate
|
||||||
|
- `{allPaths=**}` catch-alls
|
||||||
|
- `request.auth != null` as the sole authorization gate
|
||||||
|
- Claim-presence checks such as `request.auth.token.roles.size() > 0` without role or tenant validation
|
||||||
|
|
||||||
|
Storage rules use OR-across-matches semantics: a later permissive match can reopen a path that an earlier match denied. Review every matching path, not only the most specific-looking deny.
|
||||||
|
|
||||||
|
**Bucket discovery**
|
||||||
|
|
||||||
|
- Extract `storageBucket` from `firebase.apps[0].options` and `NEXT_PUBLIC_FIREBASE_*` values in JavaScript bundles and source.
|
||||||
|
- Check `<project>.appspot.com` and `<project>.firebasestorage.app` bucket conventions.
|
||||||
|
|
||||||
|
**ACL and IAM checks are separate**
|
||||||
|
|
||||||
|
- Sweep object ACLs for `allUsers` and `allAuthenticatedUsers`, including objects made public by Admin SDK `makePublic()` or writers using `public: true`. Per-object public ACLs persist after Firebase rules are tightened and can remain on older prefixes.
|
||||||
|
- Check bucket IAM for `allUsers` and `allAuthenticatedUsers`.
|
||||||
|
- Check whether Uniform Bucket-Level Access is disabled; legacy object ACLs matter when it is off.
|
||||||
|
- Account for CDN caching of previously public objects; cache-bust when verifying a revocation.
|
||||||
|
|
||||||
**Tests**
|
**Tests**
|
||||||
- GET gs:// paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment`
|
- GET GCS object paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment`
|
||||||
- Generate and reuse signed URLs across accounts and paths; try case/URL-encoding variants
|
- Generate and reuse signed URLs across accounts and paths; try case/URL-encoding variants
|
||||||
- Upload HTML/SVG and verify `X-Content-Type-Options: nosniff`; check for script execution
|
- Upload HTML/SVG and verify `X-Content-Type-Options: nosniff`; check for script execution
|
||||||
|
|
||||||
@@ -189,12 +233,19 @@ Apps often implement multi-tenant data models (`orgs/<orgId>/...`). Bind tenant
|
|||||||
|
|
||||||
## Testing Methodology
|
## Testing Methodology
|
||||||
|
|
||||||
1. **Extract config** - Get project config from client bundle
|
1. **Extract config** - Get project and storage bucket config from client bundles and source
|
||||||
2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, admin
|
2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, and admin where authorized
|
||||||
3. **Build matrix** - Resource × Action × Principal across Firestore/Realtime/Storage/Functions
|
3. **Build matrix** - Resource × Action × Principal across Firestore/Realtime/Storage/Functions
|
||||||
4. **SDK vs REST** - Exercise every action via both to detect parity gaps
|
4. **Exercise both Storage doors** - Test Firebase Storage rules endpoints separately from GCS IAM/ACL URLs
|
||||||
5. **Seed IDs** - Start from list/query paths to gather document IDs
|
5. **SDK vs REST** - Exercise every action via both to detect parity gaps
|
||||||
6. **Cross-principal** - Swap document paths, tenants, and user IDs across principals
|
6. **Seed IDs** - Start from list/query paths to gather document and object paths
|
||||||
|
7. **Cross-principal** - Swap document paths, tenants, and user IDs across principals
|
||||||
|
|
||||||
|
## Whitebox Rules Review
|
||||||
|
|
||||||
|
- Inspect `firebase.json`, `.firebaserc`, deployment scripts, CI configuration, and infrastructure code for `storage.rules` / `firestore.rules` declarations.
|
||||||
|
- If `firebase.json` has no `storage` or `firestore` block, or the referenced rules file is absent from the tree, treat the live rules as unmanaged and force the live probe matrix. Absence of rules IaC is itself a finding; never conclude that there is nothing to review.
|
||||||
|
- Correlate configured rule files with deployed project and bucket identifiers. A source rule file for a different project does not establish live protection.
|
||||||
|
|
||||||
## Tooling
|
## Tooling
|
||||||
|
|
||||||
@@ -206,6 +257,7 @@ Apps often implement multi-tenant data models (`orgs/<orgId>/...`). Bind tenant
|
|||||||
## Validation Requirements
|
## Validation Requirements
|
||||||
|
|
||||||
- Owner vs non-owner Firestore queries showing unauthorized access or metadata leak
|
- Owner vs non-owner Firestore queries showing unauthorized access or metadata leak
|
||||||
- Cloud Storage read/write beyond intended scope (public object, signed URL reuse, list exposure)
|
- Firebase Storage unauthenticated, anonymous, or low-privilege read/list/write beyond intended scope, with minimal reproducible requests and observed deltas
|
||||||
|
- GCS object ACL or bucket IAM access beyond intended scope, including public object persistence after rules changes
|
||||||
- Function accepting forged/foreign identity (wrong `aud`/`iss`) or trusting client `uid`/`orgId`
|
- Function accepting forged/foreign identity (wrong `aud`/`iss`) or trusting client `uid`/`orgId`
|
||||||
- Minimal reproducible requests with roles/tokens used and observed deltas
|
- Minimal reproducible requests with roles/tokens used and observed deltas
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
from collections.abc import Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import strix.skills as skills_mod
|
import strix.skills as skills_mod
|
||||||
|
from strix.agents.prompt import render_system_prompt
|
||||||
from strix.skills import (
|
from strix.skills import (
|
||||||
get_all_skill_names,
|
get_all_skill_names,
|
||||||
get_available_skills,
|
get_available_skills,
|
||||||
@@ -12,10 +14,11 @@ from strix.skills import (
|
|||||||
skill_search_dirs,
|
skill_search_dirs,
|
||||||
validate_requested_skills,
|
validate_requested_skills,
|
||||||
)
|
)
|
||||||
|
from strix.utils.resource_paths import get_strix_resource_path
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _clear_extra_dirs() -> None:
|
def _clear_extra_dirs() -> Iterator[None]:
|
||||||
original = list(skills_mod._EXTRA_SKILL_DIRS)
|
original = list(skills_mod._EXTRA_SKILL_DIRS)
|
||||||
skills_mod._EXTRA_SKILL_DIRS.clear()
|
skills_mod._EXTRA_SKILL_DIRS.clear()
|
||||||
try:
|
try:
|
||||||
@@ -37,9 +40,11 @@ def _write_root_skill(root: Path, name: str, body: str) -> None:
|
|||||||
|
|
||||||
def test_no_registration_leaves_builtin_only() -> None:
|
def test_no_registration_leaves_builtin_only() -> None:
|
||||||
assert registered_skill_dirs() == ()
|
assert registered_skill_dirs() == ()
|
||||||
builtin = skills_mod.get_strix_resource_path("skills")
|
builtin = get_strix_resource_path("skills")
|
||||||
assert skill_search_dirs() == (builtin,)
|
assert skill_search_dirs() == (builtin,)
|
||||||
assert {"nmap", "subfinder"}.issubset(get_available_skills()["tooling"])
|
assert {"nmap", "subfinder"}.issubset(
|
||||||
|
{skill["name"] for skill in get_available_skills()["tooling"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_register_is_idempotent_and_ordered(tmp_path: Path) -> None:
|
def test_register_is_idempotent_and_ordered(tmp_path: Path) -> None:
|
||||||
@@ -61,16 +66,125 @@ def test_registered_dir_adds_new_skill(tmp_path: Path) -> None:
|
|||||||
register_skill_dir(tmp_path)
|
register_skill_dir(tmp_path)
|
||||||
|
|
||||||
assert "widget" in get_all_skill_names()
|
assert "widget" in get_all_skill_names()
|
||||||
assert get_available_skills()["extra"] == ["widget"]
|
assert get_available_skills()["extra"] == [{"name": "widget", "description": ""}]
|
||||||
assert load_skills(["widget"]) == {"widget": "widget body"}
|
assert load_skills(["widget"]) == {"widget": "widget body"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_skill_includes_frontmatter_description(tmp_path: Path) -> None:
|
||||||
|
_write_skill(
|
||||||
|
tmp_path,
|
||||||
|
"extra",
|
||||||
|
"widget",
|
||||||
|
"---\nname: widget\ndescription: Useful widget guidance\n---\nwidget body",
|
||||||
|
)
|
||||||
|
register_skill_dir(tmp_path)
|
||||||
|
|
||||||
|
assert get_available_skills()["extra"] == [
|
||||||
|
{"name": "widget", "description": "Useful widget guidance"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_skill_supports_colon_in_description(tmp_path: Path) -> None:
|
||||||
|
_write_skill(
|
||||||
|
tmp_path,
|
||||||
|
"extra",
|
||||||
|
"widget",
|
||||||
|
'---\nname: widget\ndescription: "Useful widget: handles YAML"\n---\nwidget body',
|
||||||
|
)
|
||||||
|
register_skill_dir(tmp_path)
|
||||||
|
|
||||||
|
assert get_available_skills()["extra"] == [
|
||||||
|
{"name": "widget", "description": "Useful widget: handles YAML"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_skill_normalizes_quoted_description(tmp_path: Path) -> None:
|
||||||
|
_write_skill(
|
||||||
|
tmp_path,
|
||||||
|
"extra",
|
||||||
|
"widget",
|
||||||
|
'---\nname: widget\ndescription: "Useful: widget guidance"\n---\nwidget body',
|
||||||
|
)
|
||||||
|
register_skill_dir(tmp_path)
|
||||||
|
|
||||||
|
assert get_available_skills()["extra"] == [
|
||||||
|
{"name": "widget", "description": "Useful: widget guidance"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_skill_normalizes_multiline_descriptions(tmp_path: Path) -> None:
|
||||||
|
_write_skill(
|
||||||
|
tmp_path,
|
||||||
|
"extra",
|
||||||
|
"block",
|
||||||
|
"---\nname: block\n\ndescription: |\n"
|
||||||
|
" First paragraph\n\n Second paragraph\n\n---\nblock body",
|
||||||
|
)
|
||||||
|
_write_skill(
|
||||||
|
tmp_path,
|
||||||
|
"extra",
|
||||||
|
"plain",
|
||||||
|
"---\nname: plain\n\ndescription: First line\n Second line\n\n---\nplain body",
|
||||||
|
)
|
||||||
|
register_skill_dir(tmp_path)
|
||||||
|
|
||||||
|
available = {skill["name"]: skill["description"] for skill in get_available_skills()["extra"]}
|
||||||
|
assert available == {
|
||||||
|
"block": "First paragraph Second paragraph",
|
||||||
|
"plain": "First line Second line",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_skill_supports_block_scalar_trailing_comment(tmp_path: Path) -> None:
|
||||||
|
_write_skill(
|
||||||
|
tmp_path,
|
||||||
|
"extra",
|
||||||
|
"commented",
|
||||||
|
"---\nname: commented\ndescription: | # paragraph\n"
|
||||||
|
" First line\n Second line\n---\ncommented body",
|
||||||
|
)
|
||||||
|
register_skill_dir(tmp_path)
|
||||||
|
|
||||||
|
assert get_available_skills()["extra"] == [
|
||||||
|
{"name": "commented", "description": "First line Second line"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_frontmatter_keeps_skill_body(tmp_path: Path) -> None:
|
||||||
|
_write_skill(
|
||||||
|
tmp_path,
|
||||||
|
"extra",
|
||||||
|
"broken",
|
||||||
|
"---\nname: [broken\ndescription: should be empty\n---\nbroken body",
|
||||||
|
)
|
||||||
|
register_skill_dir(tmp_path)
|
||||||
|
|
||||||
|
assert get_available_skills()["extra"] == [{"name": "broken", "description": ""}]
|
||||||
|
assert load_skills(["extra/broken"]) == {"broken": "broken body"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_prompt_renders_skill_descriptions() -> None:
|
||||||
|
prompt = render_system_prompt(scan_mode="quick", is_root=True)
|
||||||
|
|
||||||
|
assert "- technologies/firebase: Firebase security testing covering" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_prompt_omits_empty_skill_description(tmp_path: Path) -> None:
|
||||||
|
_write_skill(tmp_path, "extra", "widget", "---\nname: widget\ndescription:\n---\nwidget body")
|
||||||
|
register_skill_dir(tmp_path)
|
||||||
|
|
||||||
|
prompt = render_system_prompt(scan_mode="quick", is_root=True)
|
||||||
|
|
||||||
|
assert "- extra/widget\n" in prompt
|
||||||
|
assert "- extra/widget: " not in prompt
|
||||||
|
|
||||||
|
|
||||||
def test_registered_root_skill_is_discoverable_and_valid(tmp_path: Path) -> None:
|
def test_registered_root_skill_is_discoverable_and_valid(tmp_path: Path) -> None:
|
||||||
_write_root_skill(tmp_path, "widget", "widget body")
|
_write_root_skill(tmp_path, "widget", "widget body")
|
||||||
register_skill_dir(tmp_path)
|
register_skill_dir(tmp_path)
|
||||||
|
|
||||||
assert "widget" in get_all_skill_names()
|
assert "widget" in get_all_skill_names()
|
||||||
assert get_available_skills()["root"] == ["widget"]
|
assert get_available_skills()["root"] == [{"name": "widget", "description": ""}]
|
||||||
assert validate_requested_skills(["widget"]) is None
|
assert validate_requested_skills(["widget"]) is None
|
||||||
assert validate_requested_skills(["root/widget"]) is None
|
assert validate_requested_skills(["root/widget"]) is None
|
||||||
assert load_skills(["widget"]) == {"widget": "widget body"}
|
assert load_skills(["widget"]) == {"widget": "widget body"}
|
||||||
@@ -83,8 +197,8 @@ def test_ambiguous_bare_skill_requires_qualified_name(tmp_path: Path) -> None:
|
|||||||
register_skill_dir(tmp_path)
|
register_skill_dir(tmp_path)
|
||||||
|
|
||||||
assert "widget" in get_all_skill_names()
|
assert "widget" in get_all_skill_names()
|
||||||
assert get_available_skills()["alpha"] == ["widget"]
|
assert get_available_skills()["alpha"] == [{"name": "widget", "description": ""}]
|
||||||
assert get_available_skills()["beta"] == ["widget"]
|
assert get_available_skills()["beta"] == [{"name": "widget", "description": ""}]
|
||||||
assert validate_requested_skills(["alpha/widget"]) is None
|
assert validate_requested_skills(["alpha/widget"]) is None
|
||||||
assert validate_requested_skills(["beta/widget"]) is None
|
assert validate_requested_skills(["beta/widget"]) is None
|
||||||
|
|
||||||
|
|||||||
@@ -2378,7 +2378,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strix-agent"
|
name = "strix-agent"
|
||||||
version = "1.5.0"
|
version = "1.5.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "caido-sdk-client" },
|
{ name = "caido-sdk-client" },
|
||||||
|
|||||||
Reference in New Issue
Block a user