diff options
| author | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
|---|---|---|
| committer | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
| commit | 0a477fd288d064d265395c88dbd0f261b723804e (patch) | |
| tree | 9b129f8f41c907285b89ed8385679934bb59c14f /internal/ui/loot.go | |
Initial release
Diffstat (limited to 'internal/ui/loot.go')
| -rw-r--r-- | internal/ui/loot.go | 394 |
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)) +} |
