summaryrefslogtreecommitdiff
path: root/internal/ui/loot.go
diff options
context:
space:
mode:
authorspiperac <[email protected]>2026-06-15 06:42:13 +0200
committerspiperac <[email protected]>2026-06-15 06:42:13 +0200
commit0a477fd288d064d265395c88dbd0f261b723804e (patch)
tree9b129f8f41c907285b89ed8385679934bb59c14f /internal/ui/loot.go
Initial release
Diffstat (limited to 'internal/ui/loot.go')
-rw-r--r--internal/ui/loot.go394
1 files changed, 394 insertions, 0 deletions
diff --git a/internal/ui/loot.go b/internal/ui/loot.go
new file mode 100644
index 0000000..df98ee0
--- /dev/null
+++ b/internal/ui/loot.go
@@ -0,0 +1,394 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+ "text/template"
+ "time"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/assets"
+ internalhttp "github.com/shiv/internal/http"
+ "github.com/shiv/internal/logger"
+ "github.com/shiv/internal/store"
+ "github.com/shiv/internal/ui/widgets"
+)
+
+var severities = []string{"Critical", "High", "Medium", "Low", "Info"}
+
+var severityImportance = map[string]widget.Importance{
+ "Critical": widget.DangerImportance,
+ "High": widget.DangerImportance,
+ "Medium": widget.WarningImportance,
+ "Low": widget.MediumImportance,
+ "Info": widget.LowImportance,
+}
+
+type lootTab struct {
+ projectStore *store.Store
+ win fyne.Window
+ repeater *repeaterTab
+ entries []store.LootEntry
+
+ table *widgets.DataTable
+ notesArea *widgets.TextView
+ viewReqBtn *widget.Button
+ deleteBtn *widget.Button
+ exportBtn *widget.Button
+
+ selectedIdx int
+}
+
+var lootColumns = []widgets.DataTableColumn{
+ {Header: "Severity", Width: 300},
+ {Header: "Title", Width: 500},
+ {Header: "Created", Width: 360},
+}
+
+func newLootTab(projectStore *store.Store, win fyne.Window, repeater *repeaterTab) *lootTab {
+ return &lootTab{
+ projectStore: projectStore,
+ win: win,
+ repeater: repeater,
+ selectedIdx: -1,
+ }
+}
+
+func (l *lootTab) build() fyne.CanvasObject {
+ l.table = widgets.NewDataTable()
+ l.table.SetWindow(l.win)
+ l.table.Columns = lootColumns
+ l.table.RowCount = func() int { return len(l.entries) }
+ l.table.CellValue = func(row, col int) string {
+ if row >= len(l.entries) {
+ return ""
+ }
+ entry := l.entries[row]
+ switch col {
+ case 0:
+ return entry.Severity
+ case 1:
+ return entry.Title
+ case 2:
+ return entry.CreatedAt.Format("2006-01-02 15:04")
+ }
+ return ""
+ }
+ l.table.CellStyle = func(row, col int) widget.Importance {
+ if col != 0 || row >= len(l.entries) {
+ return widget.MediumImportance
+ }
+ if imp, ok := severityImportance[l.entries[row].Severity]; ok {
+ return imp
+ }
+ return widget.MediumImportance
+ }
+ l.table.RowID = func(row int) int64 {
+ if row >= len(l.entries) {
+ return 0
+ }
+ return int64(l.entries[row].ID)
+ }
+ l.table.OnSelect = func(row int) {
+ if row >= len(l.entries) {
+ return
+ }
+ l.selectedIdx = row
+ l.deleteBtn.Enable()
+ entry := l.entries[row]
+ l.notesArea.SetText(fmt.Sprintf("Severity: %s\nCreated: %s\n\n%s",
+ entry.Severity, entry.CreatedAt.Format("2006-01-02 15:04"), entry.Notes))
+ if entry.HistoryID != nil || entry.RawRequest != "" {
+ l.viewReqBtn.Enable()
+ } else {
+ l.viewReqBtn.Disable()
+ }
+ }
+ l.table.MenuItems = func(row int) []widgets.ContextMenuItem {
+ if row >= len(l.entries) {
+ return nil
+ }
+ entry := l.entries[row]
+ return []widgets.ContextMenuItem{
+ {
+ Label: "Delete",
+ Action: func() {
+ dialog.ShowConfirm("Delete", fmt.Sprintf("Delete '%s'?", entry.Title), func(ok bool) {
+ if !ok {
+ return
+ }
+ if err := l.projectStore.DeleteLoot(entry.ID); err != nil {
+ logger.Error("loot: delete: %v", err)
+ return
+ }
+ l.selectedIdx = -1
+ l.deleteBtn.Disable()
+ l.notesArea.SetText("")
+ }, l.win)
+ },
+ },
+ {
+ Label: "View Request",
+ Action: func() {
+ if entry.HistoryID != nil || entry.RawRequest != "" {
+ l.showLinkedRequest(entry)
+ }
+ },
+ },
+ }
+ }
+
+ tableObj := l.table.Build()
+
+ l.notesArea = widgets.NewTextView()
+ l.notesArea.SetWindow(l.win)
+ l.notesArea.SetPlaceHolder("Select an entry to view details...")
+
+ l.deleteBtn = widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.entries) {
+ return
+ }
+ entry := l.entries[l.selectedIdx]
+ dialog.ShowConfirm("Delete", fmt.Sprintf("Delete '%s'?", entry.Title), func(ok bool) {
+ if !ok {
+ return
+ }
+ if err := l.projectStore.DeleteLoot(entry.ID); err != nil {
+ logger.Error("loot: delete: %v", err)
+ return
+ }
+ l.selectedIdx = -1
+ l.deleteBtn.Disable()
+ l.notesArea.SetText("")
+ }, l.win)
+ })
+ l.deleteBtn.Disable()
+
+ addBtn := widget.NewButtonWithIcon("Add", theme.ContentAddIcon(), func() {
+ l.showAddDialog(nil, "", "")
+ })
+ addBtn.Importance = widget.HighImportance
+
+ l.exportBtn = widget.NewButtonWithIcon("Export Markdown", theme.DocumentSaveIcon(), func() {
+ l.exportMarkdown()
+ })
+
+ l.viewReqBtn = widget.NewButtonWithIcon("View Request", theme.SearchIcon(), func() {
+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.entries) {
+ return
+ }
+ l.showLinkedRequest(l.entries[l.selectedIdx])
+ })
+ l.viewReqBtn.Disable()
+
+ split := container.NewVSplit(
+ container.NewBorder(container.NewHBox(addBtn, l.deleteBtn, l.viewReqBtn, l.exportBtn), nil, nil, nil, tableObj),
+ container.NewBorder(paneHeader("Notes"), nil, nil, nil, l.notesArea.Build()),
+ )
+ split.SetOffset(0.6)
+
+ l.reload()
+
+ // Watch for new loot entries added by plugins or other goroutines.
+ go func() {
+ for range l.projectStore.LootEntries {
+ fyne.Do(func() {
+ l.reload()
+ })
+ }
+ }()
+
+ return split
+}
+
+func (l *lootTab) reload() {
+ entries, err := l.projectStore.AllLoot()
+ if err != nil {
+ logger.Error("loot: load: %v", err)
+ return
+ }
+ l.entries = entries
+ l.table.Refresh()
+}
+
+func (l *lootTab) showAddDialog(historyID *uint64, rawRequest string, rawResponse string) {
+ titleEntry := widget.NewEntry()
+ titleEntry.SetPlaceHolder("e.g. Admin credentials found")
+
+ severitySelect := widget.NewSelect(severities, nil)
+ severitySelect.SetSelected("High")
+
+ notesEntry := widget.NewMultiLineEntry()
+ notesEntry.SetPlaceHolder("Describe the finding, include evidence, impact...")
+ notesEntry.SetMinRowsVisible(6)
+
+ form := widget.NewForm(
+ widget.NewFormItem("Title", titleEntry),
+ widget.NewFormItem("Severity", severitySelect),
+ widget.NewFormItem("Notes", notesEntry),
+ )
+
+ d := dialog.NewCustomConfirm("Add Loot", "Save", "Cancel",
+ container.NewGridWrap(fyne.NewSize(500, 350), form),
+ func(confirmed bool) {
+ if !confirmed {
+ return
+ }
+ title := strings.TrimSpace(titleEntry.Text)
+ if title == "" {
+ return
+ }
+ if _, err := l.projectStore.AddLoot(store.LootEntry{
+ Title: title,
+ Severity: severitySelect.Selected,
+ Notes: notesEntry.Text,
+ HistoryID: historyID,
+ RawRequest: rawRequest,
+ RawResponse: rawResponse,
+ }); err != nil {
+ logger.Error("loot: add: %v", err)
+ dialog.ShowError(err, l.win)
+ return
+ }
+ }, l.win)
+ closeOnEscape(l.win, d.Dismiss)
+ d.Show()
+}
+
+func (l *lootTab) exportMarkdown() {
+ entries, err := l.projectStore.AllLoot()
+ if err != nil {
+ logger.Error("loot: export: %v", err)
+ return
+ }
+ if len(entries) == 0 {
+ dialog.ShowInformation("Export", "No loot entries to export.", l.win)
+ return
+ }
+
+ type entryData struct {
+ Severity string
+ Title string
+ Date string
+ Notes string
+ Request string
+ Response string
+ }
+ type templateData struct {
+ Generated string
+ Entries []entryData
+ }
+
+ data := templateData{Generated: time.Now().Format("2006-01-02 15:04")}
+ for _, entry := range entries {
+ ed := entryData{
+ Severity: entry.Severity,
+ Title: entry.Title,
+ Date: entry.CreatedAt.Format("2006-01-02 15:04"),
+ Notes: entry.Notes,
+ }
+ if entry.HistoryID != nil {
+ if tx, err := l.projectStore.GetTransaction(*entry.HistoryID); err == nil {
+ ed.Request = FormatStoreRequest(*tx)
+ ed.Response = FormatStoreResponse(*tx)
+ }
+ } else {
+ ed.Request = entry.RawRequest
+ ed.Response = entry.RawResponse
+ }
+ data.Entries = append(data.Entries, ed)
+ }
+
+ tmpl, err := template.New("findings").Parse(assets.FindingsTemplate)
+ if err != nil {
+ logger.Error("loot: parse template: %v", err)
+ dialog.ShowError(err, l.win)
+ return
+ }
+
+ var builder strings.Builder
+ if err := tmpl.Execute(&builder, data); err != nil {
+ logger.Error("loot: execute template: %v", err)
+ dialog.ShowError(err, l.win)
+ return
+ }
+
+ saveDialog := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) {
+ if err != nil || wc == nil {
+ return
+ }
+ defer wc.Close()
+ if _, err := wc.Write([]byte(builder.String())); err != nil {
+ logger.Error("loot: write export: %v", err)
+ dialog.ShowError(err, l.win)
+ }
+ }, l.win)
+ saveDialog.SetFileName(fmt.Sprintf("findings-%s.md", time.Now().Format("2006-01-02")))
+ saveDialog.Show()
+}
+
+func (l *lootTab) showLinkedRequest(entry store.LootEntry) {
+ if entry.HistoryID != nil {
+ go func() {
+ tx, err := l.projectStore.GetTransaction(*entry.HistoryID)
+ if err != nil {
+ logger.Error("loot: get linked request: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ l.showRequestResponseDialog(
+ fmt.Sprintf("Linked Request — %s %s", tx.Method, tx.URL),
+ FormatStoreRequest(*tx),
+ FormatStoreResponse(*tx),
+ tx.Host, tx.Port, tx.TLS,
+ )
+ })
+ }()
+ } else {
+ host, port, useTLS := internalhttp.ParseHostFromRaw(entry.RawRequest)
+ l.showRequestResponseDialog("Linked Request", entry.RawRequest, entry.RawResponse, host, port, useTLS)
+ }
+}
+
+func (l *lootTab) showRequestResponseDialog(title, rawRequest, rawResponse, host string, port int, useTLS bool) {
+ reqEntry := widgets.NewTextView()
+ reqEntry.SetWindow(l.win)
+ reqEntry.SetText(rawRequest)
+
+ respEntry := widgets.NewTextView()
+ respEntry.SetWindow(l.win)
+ respEntry.SetText(rawResponse)
+
+ sendBtn := widget.NewButtonWithIcon("Send to Repeater", theme.MailForwardIcon(), nil)
+
+ split := container.NewHSplit(
+ container.NewBorder(paneHeader("Request"), nil, nil, nil, reqEntry.Build()),
+ container.NewBorder(paneHeader("Response"), nil, nil, nil, respEntry.Build()),
+ )
+ split.SetOffset(0.5)
+
+ d := dialog.NewCustom(title, "Close",
+ container.NewBorder(nil, sendBtn, nil, nil, split),
+ l.win)
+
+ sendBtn.OnTapped = func() {
+ path := PathOf(internalhttp.ExtractURL(rawRequest))
+ if len(path) > 20 {
+ path = path[:20] + "..."
+ }
+ l.repeater.AddTab(
+ fmt.Sprintf("%s %s", internalhttp.ExtractMethod(rawRequest), path),
+ host, port, useTLS, rawRequest,
+ )
+ d.Hide()
+ }
+
+ closeOnEscape(l.win, d.Dismiss)
+ d.Show()
+ d.Resize(fyne.NewSize(900, 600))
+}