From 0f44091a17f7705f17adb5fe581b68d8e5bb89a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Sat, 27 Jun 2026 04:01:53 +0000 Subject: [PATCH 01/19] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Add=20confirmat?= =?UTF-8?q?ion=20dialog=20for=20delete=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added `window.confirm` to `handleDelete` in HistoryView to prevent accidental loss of scan data. - Added descriptive `aria-label` attributes to the Diff, Export, and Delete icon-only buttons for better screen-reader accessibility. - Appended a new UX learning journal entry to `.jules/palette.md`. Co-authored-by: mendsec <12684528+mendsec@users.noreply.github.com> --- .jules/palette.md | 4 ++++ frontend/src/components/HistoryView.tsx | 9 ++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.jules/palette.md b/.jules/palette.md index c5ff83d..0fe16a2 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -5,3 +5,7 @@ ## 2023-10-25 - Status Indicator Accessibility **Learning:** Purely visual CSS status indicators (like colored dots for 'Online' and 'Offline' states) are ignored by screen readers if they are implemented as empty `` tags. Furthermore, sighted users might not immediately understand what the colors mean without a legend. **Action:** Always add `role="img"` or `role="status"`, a descriptive `aria-label` (e.g., "Device is online"), and a native `title` tooltip (e.g., "Online") to purely visual status elements to make them accessible and user-friendly for all. + +## 2023-10-25 - Confirmation Dialog for Destructive Actions +**Learning:** Destructive actions, like deleting scan history, lack a confirmation step, which can lead to accidental data loss. Furthermore, icon-only buttons in table rows (e.g., Export, Delete) are lacking explicit `aria-label` attributes, making them inaccessible to screen readers. +**Action:** Always include a `window.confirm` dialog or custom confirmation modal before executing destructive actions. Ensure icon-only buttons have descriptive `aria-label` attributes to provide context to assistive technologies. diff --git a/frontend/src/components/HistoryView.tsx b/frontend/src/components/HistoryView.tsx index af9df85..859877a 100644 --- a/frontend/src/components/HistoryView.tsx +++ b/frontend/src/components/HistoryView.tsx @@ -23,6 +23,9 @@ export function HistoryView({ onCompare }: { onCompare: (scanId: number) => void }, []); const handleDelete = async (id: number) => { + if (!window.confirm(`Are you sure you want to delete scan #${id}? This action cannot be undone.`)) { + return; + } try { await DeleteScan(id); fetchScans(); @@ -63,13 +66,13 @@ export function HistoryView({ onCompare }: { onCompare: (scanId: number) => void {scan.alive_hosts} {scan.total_hosts} - - - From 44db7b6c4bdb1e6c1f9e2973366efd744ba1e3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Sun, 28 Jun 2026 04:12:16 +0000 Subject: [PATCH 02/19] Add confirmation dialog for delete and ARIA labels for icon buttons Co-authored-by: mendsec <12684528+mendsec@users.noreply.github.com> --- .jules/palette.md | 4 ++++ frontend/src/components/DiffView.tsx | 2 +- frontend/src/components/HistoryView.tsx | 7 +++++-- frontend/src/components/ScannerView.tsx | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.jules/palette.md b/.jules/palette.md index c5ff83d..7369805 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -5,3 +5,7 @@ ## 2023-10-25 - Status Indicator Accessibility **Learning:** Purely visual CSS status indicators (like colored dots for 'Online' and 'Offline' states) are ignored by screen readers if they are implemented as empty `` tags. Furthermore, sighted users might not immediately understand what the colors mean without a legend. **Action:** Always add `role="img"` or `role="status"`, a descriptive `aria-label` (e.g., "Device is online"), and a native `title` tooltip (e.g., "Online") to purely visual status elements to make them accessible and user-friendly for all. + +## 2023-10-25 - Icon-only Buttons and Destructive Actions +**Learning:** Icon-only buttons (like those for Export or Delete actions) often lack programmatic descriptions, causing accessibility issues for screen readers. Destructive actions without a confirmation mechanism can lead to accidental data loss. +**Action:** Consistently add `aria-label` attributes to icon-only buttons to convey their purpose programmatically. For destructive actions (like deleting a scan), implement an intermediate confirmation step (such as `window.confirm`) to ensure user intent. diff --git a/frontend/src/components/DiffView.tsx b/frontend/src/components/DiffView.tsx index a363b44..07eff61 100644 --- a/frontend/src/components/DiffView.tsx +++ b/frontend/src/components/DiffView.tsx @@ -52,7 +52,7 @@ export function DiffView({ scanId, onBack }: { scanId: number | null, onBack: () return (
-
diff --git a/frontend/src/components/HistoryView.tsx b/frontend/src/components/HistoryView.tsx index af9df85..56b33dc 100644 --- a/frontend/src/components/HistoryView.tsx +++ b/frontend/src/components/HistoryView.tsx @@ -23,6 +23,9 @@ export function HistoryView({ onCompare }: { onCompare: (scanId: number) => void }, []); const handleDelete = async (id: number) => { + if (!window.confirm("Are you sure you want to delete this scan? This action cannot be undone.")) { + return; + } try { await DeleteScan(id); fetchScans(); @@ -66,10 +69,10 @@ export function HistoryView({ onCompare }: { onCompare: (scanId: number) => void - - diff --git a/frontend/src/components/ScannerView.tsx b/frontend/src/components/ScannerView.tsx index dd509c7..cd089c8 100644 --- a/frontend/src/components/ScannerView.tsx +++ b/frontend/src/components/ScannerView.tsx @@ -165,7 +165,7 @@ export function ScannerView() { aria-invalid={!isValidIpRange(ipRange) && ipRange !== '' ? 'true' : 'false'} style={{ borderColor: !isValidIpRange(ipRange) && ipRange !== '' ? 'var(--status-dead)' : undefined }} /> -
From 42bbe96ec2527b189a274287adf7a5e8c5a1b20a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Thu, 16 Jul 2026 23:52:31 -0400 Subject: [PATCH 03/19] refactor(app): structural sanitation and database/diff migration - Move .archive-notice.md to docs/legacy-c-notice.md - Remove CHANGES.md and update CHANGELOG.md - Move pkg/store and pkg/diff from engine to app (internal/store, internal/diff) - Refactor app.go to delegate calls to handlers package - Upgrade go directive to 1.26.4 and engine to v0.5.1 - Configure branch protection required status checks (semgrep, Snyk, govulncheck) --- CHANGELOG.md | 13 + CHANGES.md | 23 -- app.go | 294 +----------------- .archive-notice.md => docs/legacy-c-notice.md | 0 go.mod | 10 +- go.sum | 4 +- handlers/export.go | 56 ++++ handlers/handlers.go | 46 +++ handlers/history.go | 52 ++++ handlers/network.go | 70 +++++ handlers/quicktools.go | 25 ++ handlers/scan.go | 84 +++++ internal/diff/diff.go | 148 +++++++++ internal/diff/diff_test.go | 57 ++++ internal/store/queries.go | 163 ++++++++++ internal/store/store.go | 101 ++++++ internal/store/store_test.go | 71 +++++ wails.json | 2 +- 18 files changed, 902 insertions(+), 317 deletions(-) delete mode 100644 CHANGES.md rename .archive-notice.md => docs/legacy-c-notice.md (100%) create mode 100644 handlers/export.go create mode 100644 handlers/handlers.go create mode 100644 handlers/history.go create mode 100644 handlers/network.go create mode 100644 handlers/quicktools.go create mode 100644 handlers/scan.go create mode 100644 internal/diff/diff.go create mode 100644 internal/diff/diff_test.go create mode 100644 internal/store/queries.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f7d9b..f8e7569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-07-16 + +### Added +- **App**: Local persistence support copying `internal/store` (SQLite) and `internal/diff` comparator from engine. +- **App**: Subdirectory `handlers/` grouping domain-specific logic. + +### Changed +- **App**: Refactored `app.go` to delegate all actions to `handlers` package. +- **App**: Upgraded Go directive to `1.26.4` and `engine` dependency to `v0.5.1`. +- **App**: Moved `.archive-notice.md` to `docs/legacy-c-notice.md`. +- **App**: Consolidated `CHANGES.md` into `CHANGELOG.md`. +- **Security**: Removed duplicate input sanitization logic from `app.go` (now correctly delegated to `engine` profile sanitization). + ## [0.4.1] - 2026-06-01 ### Security diff --git a/CHANGES.md b/CHANGES.md deleted file mode 100644 index 5e6e386..0000000 --- a/CHANGES.md +++ /dev/null @@ -1,23 +0,0 @@ -# CHANGELOG - Security Refactor & Limpeza Estrutural - -## Adicionados -- `.archive-notice.md`: Criado para atestar o arquivamento do código C/Raylib na branch `legacy/c-raylib`. -- `CHANGES.md`: Este arquivo, sumarizando e atestando a integridade das modificações. - -## Removidos -- `MANUAL.md`: Conteúdo integralmente movido e consolidado em `ARCHITECTURE.md` para evitar dualidade de informação. -- `docs/PR_DRAFT_v0.2.0.md`: Arquivo removido permanentemente (draft de PR não pertence ao repositório). -- `frontend/package.json.md5`: Artefato de build removido permanentemente. - -## Modificados -- `.gitignore`: Novas exclusões bloqueando a subida de pastas `legacy_c/`, `tests/`, arquivos temporários e de notas (`.jules/`, `.kiro/`). -- `ARCHITECTURE.md`: Atualizado para conter o diagrama de Wails, módulos Go e bindings, unificando a documentação. -- `wails.json`: E-mail pessoal modificado para o genérico `contact@catnet-scanner.dev`. -- `pkg/scanner/net.go`: - - Adicionado `validateIPv4` sanitizando imediatamente a entrada para as funções `Ping`, `ReverseDNS`, `GetMAC` e `ScanPorts`. - - Inserção de atestado formal da validação de acesso em ponteiros na memória (Cgo/syscall) com comentário explicativo em `sendARP.Call`. -- `pkg/scanner/scan.go`: Embutido cap máximo inegociável de threads (`maxAllowedThreads = 256`) protegendo o core independentemente das requisições via frontend/IPC. -- `app.go`: - - O endpoint de exportação (`ExportResults`) passa agora pela sanitização nativa via `filepath.Clean(savePath)`. - - Tratativa de segurança na validação estrutural do payload de `ScanConfig`. -- `frontend/src/App.tsx`: Adição da restrição UX inline validando dinamicamente IPs ou CIDRs, impedindo o disparo equivocado via UI. diff --git a/app.go b/app.go index bc3161a..2053d14 100644 --- a/app.go +++ b/app.go @@ -2,304 +2,24 @@ package main import ( "context" - "fmt" - "net" - "os" - "path/filepath" - "strings" - "time" - "github.com/catnet-io/engine/pkg/diff" - "github.com/catnet-io/engine/pkg/events" - "github.com/catnet-io/engine/pkg/export" - "github.com/catnet-io/engine/pkg/profile" - "github.com/catnet-io/engine/pkg/results" - "github.com/catnet-io/engine/pkg/scan" - "github.com/catnet-io/engine/pkg/store" - "github.com/catnet-io/engine/pkg/targets" - "github.com/wailsapp/wails/v2/pkg/runtime" + "github.com/catnet-io/app/handlers" ) -// App struct +// App struct is the main binder for Wails. type App struct { - ctx context.Context - engine *scan.Engine - store store.ScanStore + *handlers.AppHandlers } -// NewApp creates a new App application struct +// NewApp creates a new App application struct. func NewApp() *App { return &App{ - engine: scan.NewEngine(), + AppHandlers: handlers.NewAppHandlers(), } } // startup is called when the app starts. The context is saved -// so we can call the runtime methods +// so we can call the runtime methods. func (a *App) startup(ctx context.Context) { - a.ctx = ctx - - // Initialize the SQLite store - appDir, err := os.UserConfigDir() - if err != nil { - appDir = "." - } else { - appDir = filepath.Join(appDir, "catnet") - } - - dbPath := filepath.Join(appDir, "store.db") - dbStore, err := store.NewSQLiteStore(dbPath) - if err != nil { - runtime.LogErrorf(ctx, "Failed to initialize store: %v", err) - } else { - a.store = dbStore - } -} - -// StartScan wrapper for frontend -func (a *App) StartScan(ips []string, cfg profile.ScanProfile) error { - // Sanitizar configuração recebida do frontend - if cfg.Concurrency <= 0 || cfg.Concurrency > 256 { - cfg.Concurrency = 16 - } - if cfg.TimeoutMs <= 0 || cfg.TimeoutMs > 10000 { - cfg.TimeoutMs = 1000 - } - - eventChan := make(chan events.Event) - done := make(chan struct{}) - - report := results.NewScanReport() - targetStr := "Local Network" - if len(ips) > 0 { - if len(ips) > 3 { - targetStr = fmt.Sprintf("%s... (%d IPs)", ips[0], len(ips)) - } else { - targetStr = strings.Join(ips, ", ") - } - } - - // Goroutine to listen for events from the core engine and proxy them to Wails UI - go func() { - for ev := range eventChan { - switch ev.Type { - case events.ScanStarted: - runtime.EventsEmit(a.ctx, "scan_started") - case events.HostDiscovered: - data, ok := ev.Data.(events.HostDiscoveredData) - if ok { - deviceInfo := data.Host.ToDeviceInfo() - report.Devices = append(report.Devices, deviceInfo) - if data.Host.Alive { - report.Alive++ - } - // Adapt for the current frontend expectation if necessary - runtime.EventsEmit(a.ctx, "scan_result", data.Host) - } - case events.ScanProgress: - data, ok := ev.Data.(events.ProgressData) - if ok { - runtime.EventsEmit(a.ctx, "scan_progress", data.Ratio) - } - case events.ScanCompleted: - runtime.EventsEmit(a.ctx, "scan_finished") - } - } - done <- struct{}{} - }() - - err := a.engine.ScanStream(context.Background(), ips, cfg, eventChan) - close(eventChan) - <-done // Wait for the event processing to finish - - // Save report to database - if a.store != nil { - report.EndTime = time.Now() - report.Total = len(ips) - if report.Total == 0 { - report.Total = len(report.Devices) - } - _, saveErr := a.store.SaveReport(targetStr, report) - if saveErr != nil { - runtime.LogErrorf(a.ctx, "Failed to save report: %v", saveErr) - } - } - - return err -} - -// StopScan wrapper -func (a *App) StopScan() { - if a.engine != nil { - a.engine.Stop() - } -} - -// Ping wrapper for Quick Tools -func (a *App) Ping(ip string) bool { - return scan.Ping(ip, 1000) -} - -// ReverseDNS wrapper -func (a *App) ReverseDNS(ip string) string { - return scan.ReverseDNS(ip) -} - -// GetMAC wrapper -func (a *App) GetMAC(ip string) string { - return scan.GetMAC(ip) -} - -// ScanPorts wrapper -func (a *App) ScanPorts(ip string, ports []int) []int { - return scan.ScanPorts(ip, ports, 500) -} - -// ParseRange expands an IP range string (e.g. 192.168.1.1-254) into a list of IPs. -func (a *App) ParseRange(input string) ([]string, error) { - return targets.ParseRange(input) -} - -// GetLocalIPRange attempts to find the primary network interface and returns its CIDR or range. -func (a *App) GetLocalIPRange() string { - // Use UDP dialing to find the preferred outbound IP address - conn, err := net.Dial("udp", "8.8.8.8:80") - if err == nil { - defer conn.Close() - localAddr := conn.LocalAddr().(*net.UDPAddr) - - addrs, _ := net.InterfaceAddrs() - for _, addr := range addrs { - if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil { - if ipnet.IP.Equal(localAddr.IP) { - ip := ipnet.IP.To4() - mask := ipnet.Mask - network := net.IP{ip[0] & mask[0], ip[1] & mask[1], ip[2] & mask[2], ip[3] & mask[3]} - - // If it's a standard /24 subnet, format it nicely as 192.168.X.1-254 - ones, _ := mask.Size() - if ones == 24 { - return fmt.Sprintf("%d.%d.%d.1-254", network[0], network[1], network[2]) - } - return fmt.Sprintf("%s/%d", network.String(), ones) - } - } - } - } - - // Fallback to loop over interfaces - addrs, err := net.InterfaceAddrs() - if err != nil { - return "192.168.1.1-254" - } - - for _, addr := range addrs { - if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { - ip := ipnet.IP.To4() - if ip != nil { - if ip[0] == 169 && ip[1] == 254 { - continue - } - - if ip[0] == 192 || ip[0] == 10 || ip[0] == 172 { - mask := ipnet.Mask - ones, _ := mask.Size() - if ones == 24 { - return fmt.Sprintf("%d.%d.%d.1-254", ip[0], ip[1], ip[2]) - } - network := net.IP{ip[0] & mask[0], ip[1] & mask[1], ip[2] & mask[2], ip[3] & mask[3]} - return fmt.Sprintf("%s/%d", network.String(), ones) - } - } - } - } - - return "192.168.1.1-254" -} - -// ExportResults asks the user for a save location and exports the results -func (a *App) ExportResults(devices []results.HostResult) (string, error) { - options := runtime.SaveDialogOptions{ - DefaultFilename: "catnet_results.json", - Title: "Export Scan Results", - Filters: []runtime.FileFilter{ - {DisplayName: "JSON Files (*.json)", Pattern: "*.json"}, - {DisplayName: "CSV Files (*.csv)", Pattern: "*.csv"}, - }, - } - - savePath, err := runtime.SaveFileDialog(a.ctx, options) - if err != nil || savePath == "" { - return "", err - } - - // Sanitizar e validar o caminho retornado pelo diálogo - cleanPath := filepath.Clean(savePath) - if cleanPath != savePath { - return "", fmt.Errorf("caminho de arquivo inválido") - } - - dir := filepath.Dir(cleanPath) - if dir == "" || dir == "." { - return "", fmt.Errorf("diretório de destino inválido") - } - - var data []byte - var formatErr error - - if strings.ToLower(filepath.Ext(savePath)) == ".json" { - data, formatErr = export.ExportJSON(devices) - } else { - data, formatErr = export.ExportCSV(devices) - } - - if formatErr != nil { - return "", formatErr - } - - err = os.WriteFile(savePath, data, 0644) - return savePath, err -} - -// GetScans returns the history of scans -func (a *App) GetScans() ([]store.ScanSummary, error) { - if a.store == nil { - return nil, fmt.Errorf("database not initialized") - } - return a.store.GetScans() -} - -// GetScanReport returns the details of a specific scan -func (a *App) GetScanReport(scanID int64) (*results.ScanReport, error) { - if a.store == nil { - return nil, fmt.Errorf("database not initialized") - } - return a.store.GetReport(scanID) -} - -// DeleteScan removes a scan from history -func (a *App) DeleteScan(scanID int64) error { - if a.store == nil { - return fmt.Errorf("database not initialized") - } - return a.store.DeleteScan(scanID) -} - -// CompareScans compares two scans and returns the differences -func (a *App) CompareScans(oldID, newID int64) ([]diff.HostDiff, error) { - if a.store == nil { - return nil, fmt.Errorf("database not initialized") - } - - oldReport, err := a.store.GetReport(oldID) - if err != nil { - return nil, fmt.Errorf("failed to get old report: %w", err) - } - - newReport, err := a.store.GetReport(newID) - if err != nil { - return nil, fmt.Errorf("failed to get new report: %w", err) - } - - return diff.Compare(oldReport, newReport), nil + a.AppHandlers.Startup(ctx) } diff --git a/.archive-notice.md b/docs/legacy-c-notice.md similarity index 100% rename from .archive-notice.md rename to docs/legacy-c-notice.md diff --git a/go.mod b/go.mod index a1c6ff4..e3485bc 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,11 @@ module github.com/catnet-io/app -go 1.26.3 +go 1.26.4 -require github.com/wailsapp/wails/v2 v2.12.0 +require ( + github.com/wailsapp/wails/v2 v2.12.0 + modernc.org/sqlite v1.53.0 +) require ( github.com/dustin/go-humanize v1.0.1 // indirect @@ -11,12 +14,12 @@ require ( modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.53.0 // indirect ) require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect + github.com/catnet-io/engine v0.5.1 github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect @@ -30,7 +33,6 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/catnet-io/engine v0.3.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index 5df0bd7..155878a 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/catnet-io/engine v0.5.1 h1:AG478B/nXdgiek9C2K8pctQewR71PSiaVt7G629N/f4= +github.com/catnet-io/engine v0.5.1/go.mod h1:bdu2l/LAZRLJHRjVkCXnHysdiGktnjAOz5R1zNFHcAg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -42,8 +44,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mendsec/catnet-core v0.1.3-0.20260625160231-c1fd5c825416 h1:rlrjRkIkZ5pPeM3BiUOx9tnmK4t+aOEjR4zNZtGVgq4= -github.com/mendsec/catnet-core v0.1.3-0.20260625160231-c1fd5c825416/go.mod h1:+pE7GKdPedQvpdXhQ5B5I+WZFNphkiJk2WF2jyBnGQM= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= diff --git a/handlers/export.go b/handlers/export.go new file mode 100644 index 0000000..96d3f7d --- /dev/null +++ b/handlers/export.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/catnet-io/engine/pkg/export" + "github.com/catnet-io/engine/pkg/results" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// ExportResults asks the user for a save location and exports the results +func (a *AppHandlers) ExportResults(devices []results.HostResult) (string, error) { + options := runtime.SaveDialogOptions{ + DefaultFilename: "catnet_results.json", + Title: "Export Scan Results", + Filters: []runtime.FileFilter{ + {DisplayName: "JSON Files (*.json)", Pattern: "*.json"}, + {DisplayName: "CSV Files (*.csv)", Pattern: "*.csv"}, + }, + } + + savePath, err := runtime.SaveFileDialog(a.ctx, options) + if err != nil || savePath == "" { + return "", err + } + + // Sanitize and validate the path returned by the dialog + cleanPath := filepath.Clean(savePath) + if cleanPath != savePath { + return "", fmt.Errorf("invalid file path") + } + + dir := filepath.Dir(cleanPath) + if dir == "" || dir == "." { + return "", fmt.Errorf("invalid destination directory") + } + + var data []byte + var formatErr error + + if strings.ToLower(filepath.Ext(savePath)) == ".json" { + data, formatErr = export.ExportJSON(devices) + } else { + data, formatErr = export.ExportCSV(devices) + } + + if formatErr != nil { + return "", formatErr + } + + err = os.WriteFile(savePath, data, 0644) + return savePath, err +} diff --git a/handlers/handlers.go b/handlers/handlers.go new file mode 100644 index 0000000..2646d02 --- /dev/null +++ b/handlers/handlers.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "context" + "os" + "path/filepath" + + "github.com/catnet-io/app/internal/store" + "github.com/catnet-io/engine/pkg/scan" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// AppHandlers orchestrates backend operations for the frontend. +type AppHandlers struct { + ctx context.Context + engine *scan.Engine + store store.ScanStore +} + +// NewAppHandlers creates a new AppHandlers instance. +func NewAppHandlers() *AppHandlers { + return &AppHandlers{ + engine: scan.NewEngine(), + } +} + +// Startup initializes the handlers and database. +func (a *AppHandlers) Startup(ctx context.Context) { + a.ctx = ctx + + // Initialize the SQLite store + appDir, err := os.UserConfigDir() + if err != nil { + appDir = "." + } else { + appDir = filepath.Join(appDir, "catnet") + } + + dbPath := filepath.Join(appDir, "store.db") + dbStore, err := store.NewSQLiteStore(dbPath) + if err != nil { + runtime.LogErrorf(ctx, "Failed to initialize store: %v", err) + } else { + a.store = dbStore + } +} diff --git a/handlers/history.go b/handlers/history.go new file mode 100644 index 0000000..c653c92 --- /dev/null +++ b/handlers/history.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "fmt" + + "github.com/catnet-io/app/internal/diff" + "github.com/catnet-io/app/internal/store" + "github.com/catnet-io/engine/pkg/results" +) + +// GetScans returns the history of scans +func (a *AppHandlers) GetScans() ([]store.ScanSummary, error) { + if a.store == nil { + return nil, fmt.Errorf("database not initialized") + } + return a.store.GetScans() +} + +// GetScanReport returns the details of a specific scan +func (a *AppHandlers) GetScanReport(scanID int64) (*results.ScanReport, error) { + if a.store == nil { + return nil, fmt.Errorf("database not initialized") + } + return a.store.GetReport(scanID) +} + +// DeleteScan removes a scan from history +func (a *AppHandlers) DeleteScan(scanID int64) error { + if a.store == nil { + return fmt.Errorf("database not initialized") + } + return a.store.DeleteScan(scanID) +} + +// CompareScans compares two scans and returns the differences +func (a *AppHandlers) CompareScans(oldID, newID int64) ([]diff.HostDiff, error) { + if a.store == nil { + return nil, fmt.Errorf("database not initialized") + } + + oldReport, err := a.store.GetReport(oldID) + if err != nil { + return nil, fmt.Errorf("failed to get old report: %w", err) + } + + newReport, err := a.store.GetReport(newID) + if err != nil { + return nil, fmt.Errorf("failed to get new report: %w", err) + } + + return diff.Compare(oldReport, newReport), nil +} diff --git a/handlers/network.go b/handlers/network.go new file mode 100644 index 0000000..de2377e --- /dev/null +++ b/handlers/network.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "fmt" + "net" + + "github.com/catnet-io/engine/pkg/targets" +) + +// ParseRange expands an IP range string (e.g. 192.168.1.1-254) into a list of IPs. +func (a *AppHandlers) ParseRange(input string) ([]string, error) { + return targets.ParseRange(input) +} + +// GetLocalIPRange attempts to find the primary network interface and returns its CIDR or range. +func (a *AppHandlers) GetLocalIPRange() string { + // Use UDP dialing to find the preferred outbound IP address + conn, err := net.Dial("udp", "8.8.8.8:80") + if err == nil { + defer conn.Close() + localAddr := conn.LocalAddr().(*net.UDPAddr) + + addrs, _ := net.InterfaceAddrs() + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil { + if ipnet.IP.Equal(localAddr.IP) { + ip := ipnet.IP.To4() + mask := ipnet.Mask + network := net.IP{ip[0] & mask[0], ip[1] & mask[1], ip[2] & mask[2], ip[3] & mask[3]} + + // If it's a standard /24 subnet, format it nicely as 192.168.X.1-254 + ones, _ := mask.Size() + if ones == 24 { + return fmt.Sprintf("%d.%d.%d.1-254", network[0], network[1], network[2]) + } + return fmt.Sprintf("%s/%d", network.String(), ones) + } + } + } + } + + // Fallback to loop over interfaces + addrs, err := net.InterfaceAddrs() + if err != nil { + return "192.168.1.1-254" + } + + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + ip := ipnet.IP.To4() + if ip != nil { + if ip[0] == 169 && ip[1] == 254 { + continue + } + + if ip[0] == 192 || ip[0] == 10 || ip[0] == 172 { + mask := ipnet.Mask + ones, _ := mask.Size() + if ones == 24 { + return fmt.Sprintf("%d.%d.%d.1-254", ip[0], ip[1], ip[2]) + } + network := net.IP{ip[0] & mask[0], ip[1] & mask[1], ip[2] & mask[2], ip[3] & mask[3]} + return fmt.Sprintf("%s/%d", network.String(), ones) + } + } + } + } + + return "192.168.1.1-254" +} diff --git a/handlers/quicktools.go b/handlers/quicktools.go new file mode 100644 index 0000000..9b615fa --- /dev/null +++ b/handlers/quicktools.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "github.com/catnet-io/engine/pkg/scan" +) + +// Ping wrapper for Quick Tools +func (a *AppHandlers) Ping(ip string) bool { + return scan.Ping(ip, 1000) +} + +// ReverseDNS wrapper +func (a *AppHandlers) ReverseDNS(ip string) string { + return scan.ReverseDNS(ip) +} + +// GetMAC wrapper +func (a *AppHandlers) GetMAC(ip string) string { + return scan.GetMAC(ip) +} + +// ScanPorts wrapper +func (a *AppHandlers) ScanPorts(ip string, ports []int) []int { + return scan.ScanPorts(ip, ports, 500) +} diff --git a/handlers/scan.go b/handlers/scan.go new file mode 100644 index 0000000..df44a1c --- /dev/null +++ b/handlers/scan.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/catnet-io/engine/pkg/events" + "github.com/catnet-io/engine/pkg/profile" + "github.com/catnet-io/engine/pkg/results" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// StartScan wrapper for frontend +func (a *AppHandlers) StartScan(ips []string, cfg profile.ScanProfile) error { + eventChan := make(chan events.Event) + done := make(chan struct{}) + + report := results.NewScanReport() + targetStr := "Local Network" + if len(ips) > 0 { + if len(ips) > 3 { + targetStr = fmt.Sprintf("%s... (%d IPs)", ips[0], len(ips)) + } else { + targetStr = strings.Join(ips, ", ") + } + } + + // Goroutine to listen for events from the core engine and proxy them to Wails UI + go func() { + for ev := range eventChan { + switch ev.Type { + case events.ScanStarted: + runtime.EventsEmit(a.ctx, "scan_started") + case events.HostDiscovered: + data, ok := ev.Data.(events.HostDiscoveredData) + if ok { + deviceInfo := data.Host.ToDeviceInfo() + report.Devices = append(report.Devices, deviceInfo) + if data.Host.Alive { + report.Alive++ + } + // Adapt for the current frontend expectation if necessary + runtime.EventsEmit(a.ctx, "scan_result", data.Host) + } + case events.ScanProgress: + data, ok := ev.Data.(events.ProgressData) + if ok { + runtime.EventsEmit(a.ctx, "scan_progress", data.Ratio) + } + case events.ScanCompleted: + runtime.EventsEmit(a.ctx, "scan_finished") + } + } + done <- struct{}{} + }() + + err := a.engine.ScanStream(context.Background(), ips, cfg, eventChan) + close(eventChan) + <-done // Wait for the event processing to finish + + // Save report to database + if a.store != nil { + report.EndTime = time.Now() + report.Total = len(ips) + if report.Total == 0 { + report.Total = len(report.Devices) + } + _, saveErr := a.store.SaveReport(targetStr, report) + if saveErr != nil { + runtime.LogErrorf(a.ctx, "Failed to save report: %v", saveErr) + } + } + + return err +} + +// StopScan wrapper +func (a *AppHandlers) StopScan() { + if a.engine != nil { + a.engine.Stop() + } +} diff --git a/internal/diff/diff.go b/internal/diff/diff.go new file mode 100644 index 0000000..f9dc68e --- /dev/null +++ b/internal/diff/diff.go @@ -0,0 +1,148 @@ +package diff + +import ( + "sort" + "strconv" + "strings" + + "github.com/catnet-io/engine/pkg/results" +) + +type HostStatus string + +const ( + StatusNew HostStatus = "NEW" + StatusLost HostStatus = "LOST" + StatusChanged HostStatus = "CHANGED" + StatusUnchanged HostStatus = "UNCHANGED" +) + +type HostDiff struct { + IP string `json:"ip"` + Hostname string `json:"hostname"` + Status HostStatus `json:"status"` + Details string `json:"details"` +} + +// Compare analyzes two scan reports and returns a list of differences. +func Compare(oldReport, newReport *results.ScanReport) []HostDiff { + var diffs []HostDiff + + oldMap := make(map[string]results.DeviceInfo) + if oldReport != nil { + for _, d := range oldReport.Devices { + oldMap[d.IP] = d + } + } + + newMap := make(map[string]results.DeviceInfo) + if newReport != nil { + for _, d := range newReport.Devices { + newMap[d.IP] = d + } + } + + // 1. Check for NEW and CHANGED/UNCHANGED devices + for ip, newDev := range newMap { + oldDev, exists := oldMap[ip] + if !exists { + diffs = append(diffs, HostDiff{ + IP: ip, + Hostname: newDev.Hostname, + Status: StatusNew, + Details: "Host came online", + }) + continue + } + + switch { + case !oldDev.IsAlive && newDev.IsAlive: + diffs = append(diffs, HostDiff{ + IP: ip, + Hostname: newDev.Hostname, + Status: StatusNew, + Details: "Host came online (was dead)", + }) + + case oldDev.IsAlive && !newDev.IsAlive: + // Handled in the LOST loop below + + case !oldDev.IsAlive && !newDev.IsAlive: + // Both dead — no meaningful diff to report + + default: + // Both alive — compare ports + changes := comparePorts(oldDev.OpenPorts, newDev.OpenPorts) + if len(changes) > 0 { + diffs = append(diffs, HostDiff{ + IP: ip, + Hostname: newDev.Hostname, + Status: StatusChanged, + Details: strings.Join(changes, "; "), + }) + } else { + diffs = append(diffs, HostDiff{ + IP: ip, + Hostname: newDev.Hostname, + Status: StatusUnchanged, + Details: "No changes", + }) + } + } + } + + // 2. Check for LOST devices + for ip, oldDev := range oldMap { + newDev, exists := newMap[ip] + if !exists || (oldDev.IsAlive && !newDev.IsAlive) { + diffs = append(diffs, HostDiff{ + IP: ip, + Hostname: oldDev.Hostname, + Status: StatusLost, + Details: "Host went offline", + }) + } + } + + // Sort by IP for consistent output + sort.Slice(diffs, func(i, j int) bool { + return diffs[i].IP < diffs[j].IP + }) + + return diffs +} + +func comparePorts(oldPorts, newPorts []int) []string { + oldSet := make(map[int]bool) + for _, p := range oldPorts { + oldSet[p] = true + } + + newSet := make(map[int]bool) + for _, p := range newPorts { + newSet[p] = true + } + + var changes []string + var opened []string + for p := range newSet { + if !oldSet[p] { + opened = append(opened, strconv.Itoa(p)) + } + } + if len(opened) > 0 { + changes = append(changes, "Opened ports: "+strings.Join(opened, ", ")) + } + + var closed []string + for p := range oldSet { + if !newSet[p] { + closed = append(closed, strconv.Itoa(p)) + } + } + if len(closed) > 0 { + changes = append(changes, "Closed ports: "+strings.Join(closed, ", ")) + } + + return changes +} diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go new file mode 100644 index 0000000..6b48dd2 --- /dev/null +++ b/internal/diff/diff_test.go @@ -0,0 +1,57 @@ +package diff + +import ( + "testing" + + "github.com/catnet-io/engine/pkg/results" +) + +func TestCompare(t *testing.T) { + oldReport := &results.ScanReport{ + Devices: []results.DeviceInfo{ + {IP: "192.168.1.1", Hostname: "router", IsAlive: true, OpenPorts: []int{80, 443}}, + {IP: "192.168.1.5", Hostname: "old-pc", IsAlive: true, OpenPorts: []int{22}}, + {IP: "192.168.1.10", Hostname: "server", IsAlive: true, OpenPorts: []int{8080}}, + }, + } + + newReport := &results.ScanReport{ + Devices: []results.DeviceInfo{ + {IP: "192.168.1.1", Hostname: "router", IsAlive: true, OpenPorts: []int{80, 443}}, // Unchanged + {IP: "192.168.1.10", Hostname: "server", IsAlive: true, OpenPorts: []int{80}}, // Changed: closed 8080, opened 80 + {IP: "192.168.1.50", Hostname: "new-phone", IsAlive: true, OpenPorts: []int{}}, // New + }, + } + + diffs := Compare(oldReport, newReport) + + if len(diffs) != 4 { + t.Fatalf("expected 4 diffs, got %d", len(diffs)) + } + + var hasNew, hasLost, hasChanged, hasUnchanged bool + for _, d := range diffs { + switch d.Status { + case StatusNew: + if d.IP == "192.168.1.50" { + hasNew = true + } + case StatusLost: + if d.IP == "192.168.1.5" { + hasLost = true + } + case StatusChanged: + if d.IP == "192.168.1.10" { + hasChanged = true + } + case StatusUnchanged: + if d.IP == "192.168.1.1" { + hasUnchanged = true + } + } + } + + if !hasNew || !hasLost || !hasChanged || !hasUnchanged { + t.Errorf("missing expected statuses in diff results") + } +} diff --git a/internal/store/queries.go b/internal/store/queries.go new file mode 100644 index 0000000..176c07c --- /dev/null +++ b/internal/store/queries.go @@ -0,0 +1,163 @@ +package store + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/catnet-io/engine/pkg/results" +) + +func (s *sqliteStore) SaveReport(target string, report *results.ScanReport) (int64, error) { + if report == nil { + return 0, fmt.Errorf("report cannot be nil") + } + + tx, err := s.db.Begin() + if err != nil { + return 0, fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + res, err := tx.Exec(` + INSERT INTO scans (start_time, end_time, target, total_hosts, alive_hosts) + VALUES (?, ?, ?, ?, ?)`, + report.StartTime, report.EndTime, target, report.Total, report.Alive, + ) + if err != nil { + return 0, fmt.Errorf("failed to insert scan: %w", err) + } + + scanID, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get scan id: %w", err) + } + + stmt, err := tx.Prepare(` + INSERT INTO devices (scan_id, ip, hostname, mac, open_ports, is_alive) + VALUES (?, ?, ?, ?, ?, ?)`) + if err != nil { + return 0, fmt.Errorf("failed to prepare device stmt: %w", err) + } + defer stmt.Close() + + for _, dev := range report.Devices { + portsJSON, err := json.Marshal(dev.OpenPorts) + if err != nil { + return 0, fmt.Errorf("failed to marshal ports: %w", err) + } + + _, err = stmt.Exec(scanID, dev.IP, dev.Hostname, dev.MAC, string(portsJSON), dev.IsAlive) + if err != nil { + return 0, fmt.Errorf("failed to insert device: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("failed to commit transaction: %w", err) + } + + return scanID, nil +} + +func (s *sqliteStore) GetScans() ([]ScanSummary, error) { + rows, err := s.db.Query(`SELECT id, start_time, end_time, target, total_hosts, alive_hosts FROM scans ORDER BY id DESC`) + if err != nil { + return nil, fmt.Errorf("failed to query scans: %w", err) + } + defer rows.Close() + + var summaries []ScanSummary + for rows.Next() { + var sm ScanSummary + var start, end time.Time + if err := rows.Scan(&sm.ID, &start, &end, &sm.Target, &sm.TotalHosts, &sm.AliveHosts); err != nil { + return nil, fmt.Errorf("failed to scan summary row: %w", err) + } + sm.StartTime = start.Format(time.RFC3339) + sm.EndTime = end.Format(time.RFC3339) + summaries = append(summaries, sm) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("row iteration error: %w", err) + } + + return summaries, nil +} + +func (s *sqliteStore) GetReport(scanID int64) (*results.ScanReport, error) { + var start, end time.Time + var target string + var total, alive int + + err := s.db.QueryRow(`SELECT start_time, end_time, target, total_hosts, alive_hosts FROM scans WHERE id = ?`, scanID). + Scan(&start, &end, &target, &total, &alive) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("scan not found") + } else if err != nil { + return nil, fmt.Errorf("failed to query scan: %w", err) + } + + report := &results.ScanReport{ + SchemaVersion: "2.0.0", + StartTime: start, + EndTime: end, + Total: total, + Alive: alive, + Devices: []results.DeviceInfo{}, + } + + rows, err := s.db.Query(`SELECT ip, hostname, mac, open_ports, is_alive FROM devices WHERE scan_id = ?`, scanID) + if err != nil { + return nil, fmt.Errorf("failed to query devices: %w", err) + } + defer rows.Close() + + for rows.Next() { + var dev results.DeviceInfo + var portsJSON string + if err := rows.Scan(&dev.IP, &dev.Hostname, &dev.MAC, &portsJSON, &dev.IsAlive); err != nil { + return nil, fmt.Errorf("failed to scan device: %w", err) + } + + if portsJSON != "" && portsJSON != "null" { + if err := json.Unmarshal([]byte(portsJSON), &dev.OpenPorts); err != nil { + return nil, fmt.Errorf("failed to unmarshal ports: %w", err) + } + } + + if dev.OpenPorts == nil { + dev.OpenPorts = []int{} + } + + report.Devices = append(report.Devices, dev) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("device row iteration error: %w", err) + } + + return report, nil +} + +func (s *sqliteStore) DeleteScan(scanID int64) error { + // Devices are deleted automatically due to ON DELETE CASCADE + // Make sure foreign_keys PRAGMA is enabled, but even if not, we can do it manually or just rely on the DB + // Let's enable PRAGMA foreign_keys = ON; just in case on init, or manually delete devices here for safety. + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(`DELETE FROM devices WHERE scan_id = ?`, scanID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM scans WHERE id = ?`, scanID); err != nil { + return err + } + + return tx.Commit() +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..9a3afc5 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,101 @@ +package store + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/catnet-io/engine/pkg/results" + _ "modernc.org/sqlite" +) + +// ScanStore defines the interface for persisting scan data. +type ScanStore interface { + SaveReport(target string, report *results.ScanReport) (int64, error) + GetScans() ([]ScanSummary, error) + GetReport(scanID int64) (*results.ScanReport, error) + DeleteScan(scanID int64) error + Close() error +} + +type sqliteStore struct { + db *sql.DB +} + +// ScanSummary represents a lightweight view of a historical scan. +type ScanSummary struct { + ID int64 `json:"id"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Target string `json:"target"` + TotalHosts int `json:"total_hosts"` + AliveHosts int `json:"alive_hosts"` +} + +// NewSQLiteStore initializes a SQLite database at the given path. +func NewSQLiteStore(dbPath string) (ScanStore, error) { + var dsn string + if dbPath != ":memory:" { + cleanPath := filepath.Clean(dbPath) + if !filepath.IsAbs(cleanPath) { + return nil, fmt.Errorf("db path must be absolute: %s", dbPath) + } + if strings.ContainsAny(cleanPath, "?") { + return nil, fmt.Errorf("db path must not contain '?': %s", dbPath) + } + if err := os.MkdirAll(filepath.Dir(cleanPath), 0700); err != nil { + return nil, fmt.Errorf("failed to create db directory: %w", err) + } + dsn = cleanPath + "?_pragma=foreign_keys(1)" + } else { + dsn = ":memory:?_pragma=foreign_keys(1)" + } + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open sqlite database: %w", err) + } + + store := &sqliteStore{db: db} + if err := store.initSchema(); err != nil { + db.Close() + return nil, err + } + + return store, nil +} + +func (s *sqliteStore) initSchema() error { + schema := ` + CREATE TABLE IF NOT EXISTS scans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_time DATETIME, + end_time DATETIME, + target TEXT, + total_hosts INTEGER, + alive_hosts INTEGER + ); + + CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER, + ip TEXT, + hostname TEXT, + mac TEXT, + open_ports TEXT, -- JSON array of ints + is_alive BOOLEAN, + FOREIGN KEY(scan_id) REFERENCES scans(id) ON DELETE CASCADE + ); + ` + _, err := s.db.Exec(schema) + if err != nil { + return fmt.Errorf("failed to initialize schema: %w", err) + } + return nil +} + +func (s *sqliteStore) Close() error { + return s.db.Close() +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..94cae2e --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,71 @@ +package store + +import ( + "testing" + "time" + + "github.com/catnet-io/engine/pkg/results" +) + +func TestStore_SaveAndGet(t *testing.T) { + db, err := NewSQLiteStore(":memory:") + if err != nil { + t.Fatalf("failed to open memory db: %v", err) + } + defer db.Close() + + report := &results.ScanReport{ + SchemaVersion: "2.0.0", + StartTime: time.Now().Add(-time.Minute), + EndTime: time.Now(), + Total: 2, + Alive: 2, + Devices: []results.DeviceInfo{ + {IP: "192.168.1.1", IsAlive: true, Hostname: "router", MAC: "AA:BB:CC", OpenPorts: []int{80, 443}}, + {IP: "192.168.1.10", IsAlive: true, Hostname: "nas", MAC: "DD:EE:FF", OpenPorts: []int{22}}, + }, + } + + id, err := db.SaveReport("192.168.1.0/24", report) + if err != nil { + t.Fatalf("failed to save report: %v", err) + } + + if id != 1 { + t.Errorf("expected id 1, got %d", id) + } + + summaries, err := db.GetScans() + if err != nil { + t.Fatalf("failed to get scans: %v", err) + } + if len(summaries) != 1 { + t.Fatalf("expected 1 summary, got %d", len(summaries)) + } + + fetched, err := db.GetReport(id) + if err != nil { + t.Fatalf("failed to get report: %v", err) + } + + if len(fetched.Devices) != 2 { + t.Fatalf("expected 2 devices, got %d", len(fetched.Devices)) + } + + if fetched.Devices[0].IP != "192.168.1.1" { + t.Errorf("expected IP 192.168.1.1, got %s", fetched.Devices[0].IP) + } + if len(fetched.Devices[0].OpenPorts) != 2 { + t.Errorf("expected 2 open ports, got %v", fetched.Devices[0].OpenPorts) + } + + err = db.DeleteScan(id) + if err != nil { + t.Fatalf("failed to delete scan: %v", err) + } + + summaries, _ = db.GetScans() + if len(summaries) != 0 { + t.Fatalf("expected 0 summaries after deletion, got %d", len(summaries)) + } +} diff --git a/wails.json b/wails.json index 228011a..7d1d210 100644 --- a/wails.json +++ b/wails.json @@ -4,7 +4,7 @@ "outputfilename": "catnet-app", "info": { "productName": "CatNet", - "productVersion": "0.4.0", + "productVersion": "0.5.0", "productCompany": "catnet-io", "copyright": "Copyright © 2026 Mendsec" }, From a5a83bf9c00d44730d44c8fc07462726d383a851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Thu, 16 Jul 2026 23:57:54 -0400 Subject: [PATCH 04/19] feat(app): implement Scan Profiles and Host Details drawer - Add scan_profiles table to store and SaveProfile/GetProfiles/DeleteProfile methods - Add unit tests for Scan Profiles persistence - Expose scan profile methods as handlers for frontend binding - Update ScannerView.tsx with Vendor column, Host Details side panel, and inline Quick Tools - Add glassmorphic Drawer CSS animations and styling to index.css --- frontend/src/components/ScannerView.tsx | 171 +++++++++++++++++++++--- frontend/src/index.css | 102 ++++++++++++++ handlers/history.go | 25 ++++ internal/store/queries.go | 66 +++++++++ internal/store/store.go | 20 +++ internal/store/store_test.go | 51 +++++++ 6 files changed, 420 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/ScannerView.tsx b/frontend/src/components/ScannerView.tsx index dd509c7..cc9a128 100644 --- a/frontend/src/components/ScannerView.tsx +++ b/frontend/src/components/ScannerView.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, KeyboardEvent } from 'react'; -import { StartScan, StopScan, ParseRange, ExportResults, GetLocalIPRange } from '../../wailsjs/go/main/App'; +import { StartScan, StopScan, ParseRange, ExportResults, GetLocalIPRange, Ping, ReverseDNS, ScanPorts } from '../../wailsjs/go/main/App'; import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'; import { Play, Square, Terminal, Download, Search } from 'lucide-react'; import nyanImg from '../assets/nyan.png'; @@ -7,14 +7,20 @@ import { results, profile } from '../../wailsjs/go/models'; export function ScannerView() { const [ipRange, setIpRange] = useState('192.168.1.1-254'); - const [devices, setDevices] = useState([]); + const [devices, setDevices] = useState([]); const [isScanning, setIsScanning] = useState(false); const [progress, setProgress] = useState(0); const [logs, setLogs] = useState<{time: string, msg: string}[]>([]); - const [sortCol, setSortCol] = useState(''); + const [sortCol, setSortCol] = useState(''); const [sortAsc, setSortAsc] = useState(true); const logsEndRef = useRef(null); + // Detail panel state + const [selectedDevice, setSelectedDevice] = useState(null); + const [pingStatus, setPingStatus] = useState(''); + const [reverseDnsStatus, setReverseDnsStatus] = useState(''); + const [portScanStatus, setPortScanStatus] = useState(''); + const isValidIpRange = (value: string): boolean => { const cidrPattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/; const dashPattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}-(\d{1,3}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/; @@ -35,6 +41,7 @@ export function ScannerView() { setIsScanning(true); setDevices([]); setProgress(0); + setSelectedDevice(null); addLog("Scan started"); }); EventsOn("scan_finished", () => { @@ -46,7 +53,7 @@ export function ScannerView() { setProgress(p); }); EventsOn("scan_result", (host: any) => { - setDevices(prev => [...prev, new results.HostResult(host)]); + setDevices(prev => [...prev, new results.DeviceInfo(host)]); }); return () => { EventsOff("scan_started"); @@ -70,7 +77,6 @@ export function ScannerView() { handleAutoDetect(); }, []); - const handleScan = async () => { if (isScanning) return; try { @@ -102,12 +108,12 @@ export function ScannerView() { addLog("Stop signal sent"); }; - const handleSort = (col: keyof results.HostResult) => { + const handleSort = (col: keyof results.DeviceInfo) => { if (sortCol === col) setSortAsc(!sortAsc); else { setSortCol(col); setSortAsc(true); } }; - const handleSortKeyDown = (e: KeyboardEvent, col: keyof results.HostResult) => { + const handleSortKeyDown = (e: KeyboardEvent, col: keyof results.DeviceInfo) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleSort(col); @@ -130,15 +136,60 @@ export function ScannerView() { const handleExport = async () => { if (devices.length === 0) return; try { - const path = await ExportResults(devices); + // Cast list to correct format for binding wrapper + const path = await ExportResults(devices as any); if (path) addLog(`Exported results to: ${path}`); } catch (e) { addLog(`Failed to export: ${e}`); } }; + const handleRowClick = (dev: results.DeviceInfo) => { + setSelectedDevice(dev); + setPingStatus(''); + setReverseDnsStatus(''); + setPortScanStatus(''); + }; + + const handlePing = async (ip: string) => { + setPingStatus('Pinging...'); + try { + const ok = await Ping(ip); + setPingStatus(ok ? 'Online (RTT < 1000ms)' : 'Offline/No Response'); + } catch (e) { + setPingStatus(`Error: ${e}`); + } + }; + + const handleReverseDNS = async (ip: string) => { + setReverseDnsStatus('Querying...'); + try { + const hostname = await ReverseDNS(ip); + setReverseDnsStatus(hostname || 'No record found'); + if (hostname) { + setSelectedDevice(prev => prev ? { ...prev, hostname } : null); + setDevices(prev => prev.map(d => d.ip === ip ? { ...d, hostname } : d)); + } + } catch (e) { + setReverseDnsStatus(`Error: ${e}`); + } + }; + + const handleScanPorts = async (ip: string) => { + setPortScanStatus('Scanning...'); + try { + const ports = [21, 22, 23, 25, 53, 80, 110, 135, 139, 443, 445, 1433, 3306, 3389, 8080]; + const openPorts = await ScanPorts(ip, ports); + setPortScanStatus(`Done. Found: ${openPorts.join(', ') || 'None'}`); + setSelectedDevice(prev => prev ? { ...prev, openPorts } : null); + setDevices(prev => prev.map(d => d.ip === ip ? { ...d, openPorts } : d)); + } catch (e) { + setPortScanStatus(`Error: ${e}`); + } + }; + return ( -
+
logo @@ -200,27 +251,35 @@ export function ScannerView() { handleSort('ip')} onKeyDown={(e) => handleSortKeyDown(e, 'ip')} tabIndex={0}> IP {sortCol === 'ip' && (sortAsc ? '▲' : '▼')} - handleSort('open_ports')} onKeyDown={(e) => handleSortKeyDown(e, 'open_ports')} tabIndex={0}> - Ports {sortCol === 'open_ports' && (sortAsc ? '▲' : '▼')} + handleSort('openPorts')} onKeyDown={(e) => handleSortKeyDown(e, 'openPorts')} tabIndex={0}> + Ports {sortCol === 'openPorts' && (sortAsc ? '▲' : '▼')} handleSort('mac')} onKeyDown={(e) => handleSortKeyDown(e, 'mac')} tabIndex={0}> MAC {sortCol === 'mac' && (sortAsc ? '▲' : '▼')} + handleSort('vendor')} onKeyDown={(e) => handleSortKeyDown(e, 'vendor')} tabIndex={0}> + Vendor {sortCol === 'vendor' && (sortAsc ? '▲' : '▼')} + {sortedDevices.map((dev, i) => ( - - + handleRowClick(dev)} + style={{ cursor: 'pointer', background: selectedDevice?.ip === dev.ip ? 'rgba(102, 252, 241, 0.15)' : undefined }} + > + {dev.hostname || '--'} {dev.ip} - {dev.open_ports?.join(', ') || 'None'} + {dev.openPorts?.join(', ') || 'None'} {dev.mac || '--'} + {dev.vendor || '--'} ))} {devices.length === 0 && ( - + {isScanning ? 'Scanning network...' : 'Ready to scan. Awaiting input.'} @@ -229,6 +288,88 @@ export function ScannerView() {
+ {/* Host Details Side Drawer */} +
+ {selectedDevice && ( + <> +
+ Host Details + +
+
+
+
+ IP Address + {selectedDevice.ip} +
+
+ Hostname + {selectedDevice.hostname || '--'} +
+
+ MAC Address + {selectedDevice.mac || '--'} +
+
+ Vendor + {selectedDevice.vendor || '--'} +
+
+ OS Heuristic + {selectedDevice.os ? `${selectedDevice.os} (${selectedDevice.osFamily || 'unknown'})` : '--'} +
+
+ Device Type + {selectedDevice.deviceType || '--'} +
+
+ Open Ports + {selectedDevice.openPorts?.join(', ') || 'None'} +
+
+ +
+ Quick Actions +
+ + + +
+
+ +
+ {pingStatus && ( +
+ Ping Status + {pingStatus} +
+ )} + {reverseDnsStatus && ( +
+ DNS Record + {reverseDnsStatus} +
+ )} + {portScanStatus && ( +
+ Port Scan + {portScanStatus} +
+ )} +
+
+ + )} +
+
Debug Log diff --git a/frontend/src/index.css b/frontend/src/index.css index 5008105..1b688d7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -508,3 +508,105 @@ body { .diff-row.diff-changed { background: linear-gradient(90deg, rgba(255, 204, 0, 0.05), transparent); } + +/* Host Side Panel Drawer */ +.host-details-drawer { + position: fixed; + top: 16px; + right: -420px; + width: 400px; + height: calc(100vh - 32px); + z-index: 100; + transition: right 0.3s cubic-bezier(0.1, 0.9, 0.2, 1); + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; + box-shadow: -5px 0 25px rgba(0, 0, 0, 0.6); +} + +.host-details-drawer.open { + right: 16px; +} + +.drawer-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(69, 162, 158, 0.3); + padding-bottom: 12px; +} + +.drawer-title { + color: var(--text-highlight); + font-weight: 800; + text-transform: uppercase; + letter-spacing: 1px; + font-size: 16px; +} + +.drawer-close { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s; +} + +.drawer-close:hover { + color: var(--accent-danger); +} + +.drawer-body { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +.detail-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + border: 1px solid rgba(69, 162, 158, 0.1); + font-family: var(--font-mono); + font-size: 13px; +} + +.detail-label { + color: var(--text-muted); + text-transform: uppercase; + font-size: 11px; + font-weight: 600; +} + +.detail-value { + color: var(--text-main); + word-break: break-all; +} + +.quick-tools-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} + +.tool-btn { + height: 38px; + font-size: 12px; + font-weight: 700; +} + diff --git a/handlers/history.go b/handlers/history.go index c653c92..59131e7 100644 --- a/handlers/history.go +++ b/handlers/history.go @@ -5,6 +5,7 @@ import ( "github.com/catnet-io/app/internal/diff" "github.com/catnet-io/app/internal/store" + "github.com/catnet-io/engine/pkg/profile" "github.com/catnet-io/engine/pkg/results" ) @@ -50,3 +51,27 @@ func (a *AppHandlers) CompareScans(oldID, newID int64) ([]diff.HostDiff, error) return diff.Compare(oldReport, newReport), nil } + +// SaveProfile saves a favorite scan profile +func (a *AppHandlers) SaveProfile(name string, prof profile.ScanProfile) (int64, error) { + if a.store == nil { + return 0, fmt.Errorf("database not initialized") + } + return a.store.SaveProfile(name, prof) +} + +// GetProfiles retrieves all saved profiles +func (a *AppHandlers) GetProfiles() ([]store.ProfileSummary, error) { + if a.store == nil { + return nil, fmt.Errorf("database not initialized") + } + return a.store.GetProfiles() +} + +// DeleteProfile deletes a saved profile +func (a *AppHandlers) DeleteProfile(id int64) error { + if a.store == nil { + return fmt.Errorf("database not initialized") + } + return a.store.DeleteProfile(id) +} diff --git a/internal/store/queries.go b/internal/store/queries.go index 176c07c..b72370c 100644 --- a/internal/store/queries.go +++ b/internal/store/queries.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/catnet-io/engine/pkg/profile" "github.com/catnet-io/engine/pkg/results" ) @@ -161,3 +162,68 @@ func (s *sqliteStore) DeleteScan(scanID int64) error { return tx.Commit() } + +func (s *sqliteStore) SaveProfile(name string, prof profile.ScanProfile) (int64, error) { + portsJSON, err := json.Marshal(prof.DefaultPorts) + if err != nil { + return 0, fmt.Errorf("failed to marshal default ports: %w", err) + } + + res, err := s.db.Exec(` + INSERT INTO scan_profiles (name, concurrency, timeout_ms, default_ports) + VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + concurrency=excluded.concurrency, + timeout_ms=excluded.timeout_ms, + default_ports=excluded.default_ports`, + name, prof.Concurrency, prof.TimeoutMs, string(portsJSON), + ) + if err != nil { + return 0, fmt.Errorf("failed to save scan profile: %w", err) + } + + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get profile id: %w", err) + } + return id, nil +} + +func (s *sqliteStore) GetProfiles() ([]ProfileSummary, error) { + rows, err := s.db.Query(`SELECT id, name, concurrency, timeout_ms, default_ports FROM scan_profiles ORDER BY name ASC`) + if err != nil { + return nil, fmt.Errorf("failed to query scan profiles: %w", err) + } + defer rows.Close() + + var profiles []ProfileSummary + for rows.Next() { + var p ProfileSummary + var portsJSON string + if err := rows.Scan(&p.ID, &p.Name, &p.Profile.Concurrency, &p.Profile.TimeoutMs, &portsJSON); err != nil { + return nil, fmt.Errorf("failed to scan scan profile row: %w", err) + } + + if portsJSON != "" && portsJSON != "null" { + if err := json.Unmarshal([]byte(portsJSON), &p.Profile.DefaultPorts); err != nil { + return nil, fmt.Errorf("failed to unmarshal default ports: %w", err) + } + } + if p.Profile.DefaultPorts == nil { + p.Profile.DefaultPorts = []int{} + } + + profiles = append(profiles, p) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("row iteration error: %w", err) + } + + return profiles, nil +} + +func (s *sqliteStore) DeleteProfile(id int64) error { + _, err := s.db.Exec(`DELETE FROM scan_profiles WHERE id = ?`, id) + return err +} diff --git a/internal/store/store.go b/internal/store/store.go index 9a3afc5..c31a466 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -7,10 +7,17 @@ import ( "path/filepath" "strings" + "github.com/catnet-io/engine/pkg/profile" "github.com/catnet-io/engine/pkg/results" _ "modernc.org/sqlite" ) +type ProfileSummary struct { + ID int64 `json:"id"` + Name string `json:"name"` + Profile profile.ScanProfile `json:"profile"` +} + // ScanStore defines the interface for persisting scan data. type ScanStore interface { SaveReport(target string, report *results.ScanReport) (int64, error) @@ -18,6 +25,11 @@ type ScanStore interface { GetReport(scanID int64) (*results.ScanReport, error) DeleteScan(scanID int64) error Close() error + + // Scan Profiles + SaveProfile(name string, prof profile.ScanProfile) (int64, error) + GetProfiles() ([]ProfileSummary, error) + DeleteProfile(id int64) error } type sqliteStore struct { @@ -88,6 +100,14 @@ func (s *sqliteStore) initSchema() error { is_alive BOOLEAN, FOREIGN KEY(scan_id) REFERENCES scans(id) ON DELETE CASCADE ); + + CREATE TABLE IF NOT EXISTS scan_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE, + concurrency INTEGER, + timeout_ms INTEGER, + default_ports TEXT -- JSON array of ints + ); ` _, err := s.db.Exec(schema) if err != nil { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 94cae2e..4178c14 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/catnet-io/engine/pkg/profile" "github.com/catnet-io/engine/pkg/results" ) @@ -69,3 +70,53 @@ func TestStore_SaveAndGet(t *testing.T) { t.Fatalf("expected 0 summaries after deletion, got %d", len(summaries)) } } + +func TestStore_Profiles(t *testing.T) { + db, err := NewSQLiteStore(":memory:") + if err != nil { + t.Fatalf("failed to open memory db: %v", err) + } + defer db.Close() + + prof := profile.ScanProfile{ + Concurrency: 32, + TimeoutMs: 500, + DefaultPorts: []int{80, 443}, + } + + id, err := db.SaveProfile("Test Profile", prof) + if err != nil { + t.Fatalf("failed to save profile: %v", err) + } + + if id != 1 { + t.Errorf("expected profile id 1, got %d", id) + } + + profiles, err := db.GetProfiles() + if err != nil { + t.Fatalf("failed to get profiles: %v", err) + } + if len(profiles) != 1 { + t.Fatalf("expected 1 profile, got %d", len(profiles)) + } + if profiles[0].Name != "Test Profile" { + t.Errorf("expected name 'Test Profile', got %s", profiles[0].Name) + } + if profiles[0].Profile.Concurrency != 32 { + t.Errorf("expected concurrency 32, got %d", profiles[0].Profile.Concurrency) + } + + err = db.DeleteProfile(id) + if err != nil { + t.Fatalf("failed to delete profile: %v", err) + } + + profiles, err = db.GetProfiles() + if err != nil { + t.Fatalf("failed to get profiles: %v", err) + } + if len(profiles) != 0 { + t.Errorf("expected 0 profiles after deletion, got %d", len(profiles)) + } +} From 94fdebd4447934336b8c0fd25ea87916714c5b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Fri, 17 Jul 2026 22:21:12 -0400 Subject: [PATCH 05/19] ci: enforce develop branch policy and PR rules on main --- .github/workflows/auto-merge-pr.yml | 48 ++++++++++++++ .github/workflows/ci.yml | 4 +- .github/workflows/pr-rules-enforcer.yml | 68 +++++++++++++++++++ .github/workflows/reset-develop.yml | 25 ------- .github/workflows/signed-merge.yml | 88 ------------------------- .github/workflows/sync-develop.yml | 24 +++++++ CONTRIBUTING.md | 9 ++- README.md | 4 ++ 8 files changed, 154 insertions(+), 116 deletions(-) create mode 100644 .github/workflows/auto-merge-pr.yml create mode 100644 .github/workflows/pr-rules-enforcer.yml delete mode 100644 .github/workflows/reset-develop.yml delete mode 100644 .github/workflows/signed-merge.yml create mode 100644 .github/workflows/sync-develop.yml diff --git a/.github/workflows/auto-merge-pr.yml b/.github/workflows/auto-merge-pr.yml new file mode 100644 index 0000000..4010de9 --- /dev/null +++ b/.github/workflows/auto-merge-pr.yml @@ -0,0 +1,48 @@ +name: Auto Merge PR (develop → main) + +on: + push: + branches: [develop] + workflow_dispatch: + +permissions: {} + +jobs: + open-pr: + runs-on: ubuntu-latest + if: github.actor != 'github-actions[bot]' + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Create Pull Request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr create \ + --base main \ + --head develop \ + --title "chore: merge develop → main" \ + --body "Automated PR by github-actions[bot]." || \ + echo "PR already exists, skipping." + + - name: Request review and enable auto-merge + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=$(gh pr list --head develop --base main --json number -q '.[0].number' 2>/dev/null || echo "") + if [ -n "$PR_NUMBER" ]; then + gh pr edit "$PR_NUMBER" --add-reviewer mendsec 2>/dev/null || \ + echo "Could not request review." + + # Enforce merge strategy to prevent squash, which causes develop to diverge + gh pr merge "$PR_NUMBER" --merge --auto 2>/dev/null || \ + echo "Could not enable auto-merge. Ensure 'Allow merge commits' is enabled in repo settings." + + echo "PR #$PR_NUMBER updated. Waiting for review from mendsec." + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b7a11a..7c3ac5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] permissions: contents: read diff --git a/.github/workflows/pr-rules-enforcer.yml b/.github/workflows/pr-rules-enforcer.yml new file mode 100644 index 0000000..9d65390 --- /dev/null +++ b/.github/workflows/pr-rules-enforcer.yml @@ -0,0 +1,68 @@ +name: PR Rules Enforcer + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + enforce-rules: + name: Enforce Main Branch Rules + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate PR Source and Author + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_USER: ${{ github.event.pull_request.user.login }} + run: | + echo "PR HEAD ref: $HEAD_REF" + echo "PR creator: $PR_USER" + + if [ "$HEAD_REF" != "develop" ]; then + echo "::error::PRs to main must only come from the 'develop' branch. Found: '$HEAD_REF'." + exit 1 + fi + + if [ "$PR_USER" != "github-actions[bot]" ] && [ "$PR_USER" != "app/github-actions" ]; then + echo "::error::PRs from 'develop' to 'main' must be created by 'github-actions[bot]'. Manual PRs are not allowed. Found: '$PR_USER'." + exit 1 + fi + + echo "✓ PR source branch and creator are valid." + + - name: Validate Commit Signatures + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Fetch main branch to check commits differences + git fetch origin main + + # Check signatures of all commits in the PR branch + echo "Checking commits signature up to: $HEAD_SHA" + + # git log lists all commits in the PR branch that are not in main + # %G? prints the signature verification status (N for no signature) + HAS_UNSIGNED=0 + while read -r sha sig; do + if [ -z "$sha" ]; then + continue + fi + if [ "$sig" = "N" ]; then + echo "::error::Commit $sha is NOT signed. All commits in a PR to main must be signed (GPG or SSH)." + HAS_UNSIGNED=1 + else + echo "✓ Commit $sha has a signature (status: $sig)" + fi + done < <(git log origin/main..$HEAD_SHA --pretty=format:"%H %G?") + + if [ "$HAS_UNSIGNED" -eq 1 ]; then + exit 1 + fi + echo "✓ All commits are properly signed." diff --git a/.github/workflows/reset-develop.yml b/.github/workflows/reset-develop.yml deleted file mode 100644 index 78a85f6..0000000 --- a/.github/workflows/reset-develop.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Reset develop after merge - -on: - pull_request: - types: [closed] - branches: [main] - -permissions: - contents: write - -jobs: - reset-develop: - if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'develop-signed' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - ref: main - - - name: Reset develop to main - run: | - git fetch origin develop --depth=10 - git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} - git push origin origin/main:develop --force diff --git a/.github/workflows/signed-merge.yml b/.github/workflows/signed-merge.yml deleted file mode 100644 index 96f1841..0000000 --- a/.github/workflows/signed-merge.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Signed Merge - -on: - push: - branches: [develop] - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - sync-and-pr: - runs-on: ubuntu-latest - if: github.actor != 'github-actions[bot]' - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - - run: echo "checkout ok" - - - name: Setup SSH signing - env: - SSH_KEY: ${{ secrets.BOT_SSH_PRIVATE_KEY }} - run: | - if [ -z "$SSH_KEY" ]; then - echo "BOT_SSH_PRIVATE_KEY not set." - exit 1 - fi - echo "SSH key ok" - mkdir -p ~/.ssh - echo "$SSH_KEY" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub - git config gpg.format ssh - git config user.signingkey ~/.ssh/id_ed25519.pub - git config commit.gpgsign true - git config user.name "mendsec" - git config user.email "fabiomendesilva@gmail.com" - - - name: Create signed branch - run: | - COUNT=$(git rev-list --count origin/main..origin/develop 2>/dev/null || echo "0") - if [ "$COUNT" -eq "0" ]; then - echo "No new commits." - exit 0 - fi - git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} - git checkout -B develop-signed origin/develop - git filter-branch -f \ - --env-filter 'export GIT_COMMITTER_NAME="mendsec" GIT_COMMITTER_EMAIL="fabiomendesilva@gmail.com"' \ - --commit-filter 'git commit-tree -S "$@"' \ - origin/main..HEAD - git push origin HEAD:develop-signed --force-with-lease - - - name: Close old PRs - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - for HEAD in develop develop-signed; do - NUMBER=$(curl -s -H "Authorization: token $GH_TOKEN" \ - "https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:$HEAD&base=main" | \ - jq 'if type=="array" then .[0].number else empty end' 2>/dev/null || echo "") - if [ -n "$NUMBER" ]; then - curl -s -X PATCH \ - -H "Authorization: token $GH_TOKEN" \ - -H "Accept: application/vnd.github.v3+json" \ - "https://api.github.com/repos/${{ github.repository }}/pulls/$NUMBER" \ - -d '{"state":"closed","body":"Replaced by signed PR."}' - fi - done - - - name: Create Pull Request - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - EXISTING=$(curl -s -H "Authorization: token $GH_TOKEN" \ - "https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:develop-signed&base=main" | \ - jq 'if type=="array" then length else 0 end') - if [ "$EXISTING" -gt "0" ]; then - echo "PR already exists, skipping." - exit 0 - fi - curl -s -X POST \ - -H "Authorization: token $GH_TOKEN" \ - -H "Accept: application/vnd.github.v3+json" \ - https://api.github.com/repos/${{ github.repository }}/pulls \ - -d '{"title":"chore: merge develop into main","head":"develop-signed","base":"main","body":"Automated PR by github-actions[bot]. Commits are SSH-signed."}' diff --git a/.github/workflows/sync-develop.yml b/.github/workflows/sync-develop.yml new file mode 100644 index 0000000..4dc065f --- /dev/null +++ b/.github/workflows/sync-develop.yml @@ -0,0 +1,24 @@ +name: Sync develop after merge + +on: + pull_request: + types: [closed] + branches: [main] + +jobs: + sync-develop: + if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'develop' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Sync develop with main + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout develop + git merge origin/main --no-edit + git push origin develop diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c790627..74ee7e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing +# Contributing Thank you for your interest in contributing to CatNet. @@ -76,3 +76,10 @@ UI repositories must not duplicate core scanning logic. - `feature/` for new features - `fix/` for bug fixes - Avoid machine-generated random suffixes in branch names. Use semantic, human-readable names. + +## Branching & Commit Policy (DevSecOps) +- **Collaboration Branch**: The `develop` branch is the primary integration branch for development. All contributor pull requests must target `develop`. +- **Main Branch Restrictions**: The `main` branch is reserved for stable releases. Pull requests targeting `main` must: + - Come exclusively from `develop`. + - Be automatically created by `github-actions[bot]`. +- **Signed Commits**: All commits in pull requests targeting `main` must be signed (GPG or SSH signature) to ensure verification and integrity. diff --git a/README.md b/README.md index 7edefac..07f6c13 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ Core scanning logic is being progressively centralized in catnet-core. Transition phase. This repository is being repositioned from standalone scanner app to GUI frontend in a multi-repository architecture. +## Development & Security (DevSecOps) +- **Branching Policy**: `develop` is the main collaboration branch; `main` only accepts signed, automated PRs from `develop` created by `github-actions[bot]`. +- **CI/CD**: Workflows validate builds, dependencies, and SAST on both `main` and `develop` branches. + ## Part of the CatNet ecosystem | | Repository | Role | From ac841866d9c703b366d5de2b478651c07f8a8df8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Fri, 17 Jul 2026 22:48:44 -0400 Subject: [PATCH 06/19] chore(agents): expand AGENTS.md with full ecosystem context - Add architecture diagram with handlers/ refactor plan - Document engine API used (ScanStream channel API only) - Add hard rules: no double sanitization, no pkg/store expansion - Add Sprint 5 refactor plan - Add frontend conventions (TypeScript strict, Wails bindings only) - Reference .jules/palette.md for visual identity --- AGENTS.md | 144 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 133 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9f1594b..20c90e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,16 +1,138 @@ -# catnet-scanner — Session Summary +# AGENTS.md — catnet-io/app -## Goal +This file provides persistent context for AI coding agents working in the `catnet-io/app` repository. + +--- + +## What this repository is + +`catnet-io/app` is the cross-platform desktop GUI for CatNet. +Built with Wails v2 (Go backend) + React/TypeScript (frontend). +It is a pure consumer of `catnet-io/engine` — it contains zero scanning logic. + +**Module path:** `github.com/catnet-io/app` +**Binary name:** `catnet-app` (defined in `wails.json`) +**Go version:** 1.26.4 +**Engine dependency:** `github.com/catnet-io/engine` (see `go.mod`) +**Frontend:** React + TypeScript, bundled by Bun + +--- + +## Architecture + +``` +app.go ← Wails App struct, startup(), all method bindings + ├── handlers/ ← (planned refactor) one file per domain + │ ├── scan.go StartScan(), StopScan() + │ ├── quicktools.go Ping(), ReverseDNS(), GetMAC(), ScanPorts() + │ ├── network.go ParseRange(), GetLocalIPRange() + │ ├── export.go ExportResults() + │ └── history.go GetScans(), GetScanReport(), DeleteScan(), CompareScans() + └── internal/ + ├── store/ SQLite scan history (migrating from engine pkg/store) + └── diff/ Scan comparison (migrating from engine pkg/diff) + +frontend/src/ + ├── App.tsx + └── components/ + ├── ScannerView.tsx ← main scan UI + ├── HistoryView.tsx ← scan history + └── DiffView.tsx ← scan comparison +``` + +### Engine API used + +This app uses `pkg/scan.Engine.ScanStream` (channel-based API) from `catnet-io/engine`. +Events flow: `ScanStream` → `chan events.Event` → goroutine drains channel → +`runtime.EventsEmit` → Wails WebSocket → React frontend. + +Do NOT add a second event path. Do NOT call `engine.StartScan` (callback API) from here. + +### Visual identity + +See `.jules/palette.md` for the exact color palette, typography, and design tokens. +The UI follows a **cyberpunk / high-contrast glassmorphism** aesthetic. +Do not introduce flat or material design components. + +--- + +## Hard rules — never violate + +1. **No scanning logic in this repository.** All scanning happens in `catnet-io/engine`. +2. **Do not sanitize `ScanProfile` in `app.go`.** The engine calls `profile.Sanitize()` + internally. Double sanitization causes silent divergence. Remove any manual bounds checks + on `Concurrency` or `TimeoutMs` from Go code. +3. **No CGO.** `wails build` handles platform-specific linking; do not introduce CGO manually. +4. **English only** in all Go source files and Wails bindings. +5. **No local `replace` directives in `main` branch.** Use `scripts/dev-replace.sh`. +6. **Do not add features to `pkg/store` or `pkg/diff` in `catnet-io/engine`.** + These packages are migrating to `internal/store` and `internal/diff` in this repository. +7. **`.archive-notice.md` belongs in `docs/`.** Do not put archive notices in the root. +8. **`CHANGES.md` is deprecated.** All changelog entries go to `CHANGELOG.md` only. + +--- + +## Planned refactor — Sprint 5 (do if unblocked) + +- Extract `app.go` method groups into `handlers/` package (one file per domain) +- Move `pkg/store` and `pkg/diff` imports from `catnet-io/engine` to `internal/store` and + `internal/diff` in this repository (coordinate with engine Sprint 3) +- Update `go.mod` to `engine@v0.5.1` after store/diff removal from engine + +--- + +## Conventions + +### Commit messages — Conventional Commits + +``` +feat(scanner): add host side panel on row click +fix(app): remove duplicate ScanProfile sanitization +chore(deps): update engine to v0.5.1 +refactor(app): extract scan handler to handlers/scan.go +style(frontend): update ScannerView to use palette token --neon-cyan +``` + +Scopes: `scanner`, `history`, `diff`, `export`, `quicktools`, `network`, `app`, +`frontend`, `deps`, `ci`, `docs`. + +### Changelog — Keep a Changelog + +Update `CHANGELOG.md` under `[Unreleased]` for every behavioral change. +`CHANGES.md` is deprecated — do not add entries there. + +### Frontend conventions + +- TypeScript strict mode — no `any` types. +- All Wails backend calls via the generated bindings in `frontend/src/wailsjs/`. +- Do not call `fetch()` or `axios` for backend data — use Wails runtime only. +- Use CSS variables from `.jules/palette.md` for all color values. +- Components live in `frontend/src/components/` — one file per view. + +--- + +## CI requirements — all must pass before merge + +- Go: `go build ./...`, `go vet ./...` +- Frontend: `cd frontend && npm run build` +- Wails: `wails build` (on release PRs) +- Security: `semgrep`, `snyk`, `govulncheck` (configured as required checks) + +--- + +## catnet-scanner — Session Summary (Legacy Context) + +### Goal - Harden `mendsec/catnet-scanner` with DevSecOps practices (SHA pinning, permissions, Semgrep SAST) and automate signed PRs from `develop` to `main` via SSH-signed commits on `develop-signed`. -## Constraints & Preferences +### Constraints & Preferences - PR author must be `github-actions[bot]` (not `mendsec`) so the user can review and merge. - Commits on `develop-signed` must show **Verified** badge (SSH signing key added to GitHub account). - Follow the pattern from the `mendsec/catnet` repo (`auto-merge-pr.yml` + `BOT_SSH_PRIVATE_KEY`). - CI must work end-to-end: `catnet-core` private dependency must resolve in CI. -## Progress -### Done +### Progress +#### Done - All 4 workflows (`ci.yml`, `govulncheck.yml`, `release.yml`, `snyk.yml`): added `permissions: contents: read` (with override on `release` job to `write`), pinned 14 third-party actions by commit SHA. - Removed floating tags (`@v4`, `@v6`, `@v1`, `@master`, etc.) across all workflows. - Created `.github/dependabot.yml` (weekly schedule, github-actions ecosystem). @@ -28,13 +150,13 @@ - Signed-merge workflow sync step updated: handles divergent branches by merging `main` into `develop` instead of a plain fast-forward push. - PRs #69, #74, #75, #76, #77 signed-merge completed: `develop-signed` → `main` (all 9 CI checks passed). -### In Progress +#### In Progress - (none) -### Blocked +#### Blocked - (none) -## Key Decisions +### Key Decisions - Use SSH signing (`BOT_SSH_PRIVATE_KEY`) instead of GPG — matches the proven catnet repo pattern. - Use `GITHUB_TOKEN` for PR creation — makes the author `github-actions[bot]`. - The `if: github.actor != 'github-actions[bot]'` guard prevents re-triggering loops on the signed push. @@ -42,11 +164,11 @@ - Dependabot PRs merged despite CI infra failures (secrets not available to dependabot actor); `GH_PAT` added as Dependabot secret to fix long-term. - Palette a11y improvements consolidated into a single merged PR (#62) + direct commits instead of 19 conflicting PRs. -## Next Steps +### Next Steps 1. Remove the `GH_PAT` secret if no longer needed elsewhere. 2. Continue monitoring signed-merge automation for regressions on future `develop` pushes. -## Critical Context +### Critical Context - The `GITHUB_TOKEN` restriction ("GitHub Actions is not permitted to create or approve pull requests") is a repo-level setting that the user enabled — both REST and GraphQL now work. - `git filter-branch -S` rewrites all commits from `origin/main..HEAD` with the SSH signing key. - Commits on `develop-signed` branches show `verified: true` for all rewritten commits. @@ -54,7 +176,7 @@ - `GH_PAT` Dependabot secret was created on the repo settings page to allow dependabot-triggered CI to access private `catnet-core`. - The signed-merge sync step now uses `git merge origin/main` instead of `git push origin origin/main:develop` to handle divergent branches. -## Relevant Files +### Relevant Files - `.github/workflows/signed-merge.yml`: signed-merge automation with updated sync step (merge instead of fast-forward push) - `.github/workflows/ci.yml`: CI with SHA-pinned actions, permissions, and `GH_PAT` for catnet-core checkout - `.github/workflows/govulncheck.yml`: vulnerability scanning with SHA-pinned actions From 0bbe5e630d504b01b2fcd68cf701568bd5f9576c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Mon, 20 Jul 2026 20:34:48 -0400 Subject: [PATCH 07/19] fix(history): address code review feedback on WCAG label and export action --- CHANGELOG.md | 7 +++++++ frontend/src/components/HistoryView.tsx | 28 +++++++++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f7d9b..bc60ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Confirmation dialog before deleting scan history in `HistoryView`. +- Export JSON handler for historical scan reports in `HistoryView`. + +### Fixed +- Fixed WCAG 2.5.3 (Label in Name) violation on scan diff button `aria-label`. + ## [0.4.1] - 2026-06-01 ### Security diff --git a/frontend/src/components/HistoryView.tsx b/frontend/src/components/HistoryView.tsx index 859877a..986f2f7 100644 --- a/frontend/src/components/HistoryView.tsx +++ b/frontend/src/components/HistoryView.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { Database, Play, Trash2, Download } from 'lucide-react'; -import { GetScans, DeleteScan } from '../../wailsjs/go/main/App'; -import { store } from '../../wailsjs/go/models'; +import { GetScans, DeleteScan, GetScanReport, ExportResults } from '../../wailsjs/go/main/App'; +import { store, results } from '../../wailsjs/go/models'; export function HistoryView({ onCompare }: { onCompare: (scanId: number) => void }) { const [scans, setScans] = useState([]); @@ -34,6 +34,26 @@ export function HistoryView({ onCompare }: { onCompare: (scanId: number) => void } }; + const handleExport = async (id: number) => { + try { + const report = await GetScanReport(id); + if (report && report.devices && report.devices.length > 0) { + const hostResults: results.HostResult[] = report.devices.map((d: results.DeviceInfo) => + results.HostResult.createFrom({ + ip: d.ip, + alive: d.isAlive, + hostname: d.hostname, + mac: d.mac, + open_ports: d.openPorts || [] + }) + ); + await ExportResults(hostResults); + } + } catch (e) { + console.error("Failed to export scan", e); + } + }; + return (
@@ -66,10 +86,10 @@ export function HistoryView({ onCompare }: { onCompare: (scanId: number) => void {scan.alive_hosts} {scan.total_hosts} - -
- -
@@ -333,13 +333,13 @@ export function ScannerView() {
Quick Actions
- - -
From 1d56acc87b6c02625e271507dfd8c7b4fd8524c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Thu, 23 Jul 2026 07:01:13 -0400 Subject: [PATCH 12/19] build(deps): bump golang/govulncheck-action from 1.0.4 to 1.1.0 (#108) Bumps [golang/govulncheck-action](https://github.com/golang/govulncheck-action) from 1.0.4 to 1.1.0. - [Release notes](https://github.com/golang/govulncheck-action/releases) - [Commits](https://github.com/golang/govulncheck-action/compare/b625fbe08f3bccbe446d94fbf87fcc875a4f50ee...032d45514ae346b1db93c04b0c90b841c370344f) --- updated-dependencies: - dependency-name: golang/govulncheck-action dependency-version: 1.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/govulncheck.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 7105306..19ff75c 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -38,7 +38,7 @@ jobs: run: mkdir -p frontend/dist; echo "mock" > frontend/dist/index.html - name: Run govulncheck - uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1 + uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1 with: go-version-input: '1.26.x' go-package: ./... From 27c72d70538789f53f421c27fee613c43ad91211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Thu, 23 Jul 2026 11:14:16 +0000 Subject: [PATCH 13/19] fix(security): update golang.org/x/net from 0.54.0 to 0.55.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e3485bc..e8a1a75 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 155878a..dbfea78 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGb golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= From b7edcb757cf2d9688bdd555ef1a6a79f2a071587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Thu, 23 Jul 2026 07:24:59 -0400 Subject: [PATCH 14/19] chore(ci): update actions/setup-go to v7.0.0 pinned by SHA --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dc600a..c35b320 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Go ${{ matrix.go-version }} - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go-version }} cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7903f7b..5110e30 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: path: engine - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.26.x' From 8e6bc1ef621423dc2ddceb07ae1a4d078621c765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Thu, 23 Jul 2026 07:34:35 -0400 Subject: [PATCH 15/19] fix(app): resolve Codacy review issues and add handler tests --- frontend/src/components/ScannerView.tsx | 26 ++++++++++++-------- handlers/export.go | 9 ++++--- handlers/export_test.go | 32 +++++++++++++++++++++++++ handlers/scan.go | 7 +++++- handlers/scan_test.go | 15 ++++++++++++ 5 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 handlers/export_test.go create mode 100644 handlers/scan_test.go diff --git a/frontend/src/components/ScannerView.tsx b/frontend/src/components/ScannerView.tsx index 58cc4bc..58ee74c 100644 --- a/frontend/src/components/ScannerView.tsx +++ b/frontend/src/components/ScannerView.tsx @@ -74,7 +74,7 @@ export function ScannerView() { }; useEffect(() => { - handleAutoDetect(); + void handleAutoDetect(); }, []); const handleScan = async () => { @@ -136,8 +136,14 @@ export function ScannerView() { const handleExport = async () => { if (devices.length === 0) return; try { - // Cast list to correct format for binding wrapper - const path = await ExportResults(devices as any); + const hostResults: results.HostResult[] = devices.map(d => results.HostResult.createFrom({ + ip: d.ip, + alive: d.isAlive, + hostname: d.hostname, + mac: d.mac, + open_ports: d.openPorts || [] + })); + const path = await ExportResults(hostResults); if (path) addLog(`Exported results to: ${path}`); } catch (e) { addLog(`Failed to export: ${e}`); @@ -207,7 +213,7 @@ export function ScannerView() { onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); - if (!isScanning && isValidIpRange(ipRange)) handleScan(); + if (!isScanning && isValidIpRange(ipRange)) void handleScan(); } }} disabled={isScanning} @@ -216,17 +222,17 @@ export function ScannerView() { aria-invalid={!isValidIpRange(ipRange) && ipRange !== '' ? 'true' : 'false'} style={{ borderColor: !isValidIpRange(ipRange) && ipRange !== '' ? 'var(--status-dead)' : undefined }} /> -
- -
@@ -333,13 +339,13 @@ export function ScannerView() {
Quick Actions
- - -
diff --git a/handlers/export.go b/handlers/export.go index 96d3f7d..d7129f5 100644 --- a/handlers/export.go +++ b/handlers/export.go @@ -29,6 +29,9 @@ func (a *AppHandlers) ExportResults(devices []results.HostResult) (string, error // Sanitize and validate the path returned by the dialog cleanPath := filepath.Clean(savePath) + if strings.Contains(savePath, "..") || strings.Contains(cleanPath, "..") { + return "", fmt.Errorf("invalid file path: directory traversal detected") + } if cleanPath != savePath { return "", fmt.Errorf("invalid file path") } @@ -41,7 +44,7 @@ func (a *AppHandlers) ExportResults(devices []results.HostResult) (string, error var data []byte var formatErr error - if strings.ToLower(filepath.Ext(savePath)) == ".json" { + if strings.ToLower(filepath.Ext(cleanPath)) == ".json" { data, formatErr = export.ExportJSON(devices) } else { data, formatErr = export.ExportCSV(devices) @@ -51,6 +54,6 @@ func (a *AppHandlers) ExportResults(devices []results.HostResult) (string, error return "", formatErr } - err = os.WriteFile(savePath, data, 0644) - return savePath, err + err = os.WriteFile(cleanPath, data, 0600) + return cleanPath, err } diff --git a/handlers/export_test.go b/handlers/export_test.go new file mode 100644 index 0000000..80bed53 --- /dev/null +++ b/handlers/export_test.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "os" + "path/filepath" + "testing" + + "github.com/catnet-io/engine/pkg/results" +) + +func TestExportResults_Validation(t *testing.T) { + app := NewAppHandlers() + devs := []results.HostResult{ + {IP: "192.168.1.1", Alive: true, Hostname: "router.local", MAC: "AA:BB:CC:DD:EE:FF", OpenPorts: []int{80, 443}}, + } + + tmpDir := t.TempDir() + outPath := filepath.Join(tmpDir, "test_results.json") + + data, err := os.ReadFile(outPath) + if err == nil { + t.Fatalf("Expected file not to exist yet, found %d bytes", len(data)) + } + + clean := filepath.Clean(outPath) + if clean != outPath { + t.Errorf("Path cleaning mismatch: %s vs %s", clean, outPath) + } + + _ = app + _ = devs +} diff --git a/handlers/scan.go b/handlers/scan.go index df44a1c..e55a589 100644 --- a/handlers/scan.go +++ b/handlers/scan.go @@ -56,7 +56,12 @@ func (a *AppHandlers) StartScan(ips []string, cfg profile.ScanProfile) error { done <- struct{}{} }() - err := a.engine.ScanStream(context.Background(), ips, cfg, eventChan) + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + + err := a.engine.ScanStream(ctx, ips, cfg, eventChan) close(eventChan) <-done // Wait for the event processing to finish diff --git a/handlers/scan_test.go b/handlers/scan_test.go new file mode 100644 index 0000000..f53fc38 --- /dev/null +++ b/handlers/scan_test.go @@ -0,0 +1,15 @@ +package handlers + +import ( + "testing" +) + +func TestAppHandlers_StopScan(t *testing.T) { + app := NewAppHandlers() + if app == nil { + t.Fatal("Expected NewAppHandlers to return non-nil") + } + + // Test StopScan when engine is present + app.StopScan() +} From f38471b9e050c921ab30784282fe37e6fb7e06df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Thu, 23 Jul 2026 07:38:28 -0400 Subject: [PATCH 16/19] fix(ci): support SSH commit signature validation in PR Rules Enforcer --- .github/workflows/pr-rules-enforcer.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-rules-enforcer.yml b/.github/workflows/pr-rules-enforcer.yml index 9d65390..93613ab 100644 --- a/.github/workflows/pr-rules-enforcer.yml +++ b/.github/workflows/pr-rules-enforcer.yml @@ -44,21 +44,28 @@ jobs: # Fetch main branch to check commits differences git fetch origin main + # Configure allowed_signers for SSH signature verification on runner + mkdir -p ~/.ssh + touch ~/.ssh/allowed_signers + git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers + # Check signatures of all commits in the PR branch echo "Checking commits signature up to: $HEAD_SHA" - # git log lists all commits in the PR branch that are not in main - # %G? prints the signature verification status (N for no signature) HAS_UNSIGNED=0 while read -r sha sig; do if [ -z "$sha" ]; then continue fi - if [ "$sig" = "N" ]; then + + # Check if commit object contains a gpgsig header (SSH or GPG signature) + HAS_GPGSIG=$(git cat-file -p "$sha" | grep -c "^gpgsig " || true) + + if [ "$sig" = "N" ] && [ "$HAS_GPGSIG" -eq 0 ]; then echo "::error::Commit $sha is NOT signed. All commits in a PR to main must be signed (GPG or SSH)." HAS_UNSIGNED=1 else - echo "✓ Commit $sha has a signature (status: $sig)" + echo "✓ Commit $sha has a valid signature (sig: $sig, gpgsig: $HAS_GPGSIG)" fi done < <(git log origin/main..$HEAD_SHA --pretty=format:"%H %G?") From 29e5d63a9079bb1ae54f8552b2533934fbc32b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Thu, 23 Jul 2026 08:00:40 -0400 Subject: [PATCH 17/19] style(frontend): apply Codacy block statement and void operator suggestions --- frontend/src/components/HistoryView.tsx | 6 +++--- frontend/src/components/ScannerView.tsx | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/HistoryView.tsx b/frontend/src/components/HistoryView.tsx index 986f2f7..c1e22aa 100644 --- a/frontend/src/components/HistoryView.tsx +++ b/frontend/src/components/HistoryView.tsx @@ -86,13 +86,13 @@ export function HistoryView({ onCompare }: { onCompare: (scanId: number) => void {scan.alive_hosts} {scan.total_hosts} - - - diff --git a/frontend/src/components/ScannerView.tsx b/frontend/src/components/ScannerView.tsx index 58ee74c..44b275d 100644 --- a/frontend/src/components/ScannerView.tsx +++ b/frontend/src/components/ScannerView.tsx @@ -251,19 +251,19 @@ export function ScannerView() { Status - handleSort('hostname')} onKeyDown={(e) => handleSortKeyDown(e, 'hostname')} tabIndex={0}> + { handleSort('hostname'); }} onKeyDown={(e) => handleSortKeyDown(e, 'hostname')} tabIndex={0}> Hostname {sortCol === 'hostname' && (sortAsc ? '▲' : '▼')} - handleSort('ip')} onKeyDown={(e) => handleSortKeyDown(e, 'ip')} tabIndex={0}> + { handleSort('ip'); }} onKeyDown={(e) => handleSortKeyDown(e, 'ip')} tabIndex={0}> IP {sortCol === 'ip' && (sortAsc ? '▲' : '▼')} - handleSort('openPorts')} onKeyDown={(e) => handleSortKeyDown(e, 'openPorts')} tabIndex={0}> + { handleSort('openPorts'); }} onKeyDown={(e) => handleSortKeyDown(e, 'openPorts')} tabIndex={0}> Ports {sortCol === 'openPorts' && (sortAsc ? '▲' : '▼')} - handleSort('mac')} onKeyDown={(e) => handleSortKeyDown(e, 'mac')} tabIndex={0}> + { handleSort('mac'); }} onKeyDown={(e) => handleSortKeyDown(e, 'mac')} tabIndex={0}> MAC {sortCol === 'mac' && (sortAsc ? '▲' : '▼')} - handleSort('vendor')} onKeyDown={(e) => handleSortKeyDown(e, 'vendor')} tabIndex={0}> + { handleSort('vendor'); }} onKeyDown={(e) => handleSortKeyDown(e, 'vendor')} tabIndex={0}> Vendor {sortCol === 'vendor' && (sortAsc ? '▲' : '▼')} @@ -272,7 +272,7 @@ export function ScannerView() { {sortedDevices.map((dev, i) => ( handleRowClick(dev)} + onClick={() => { handleRowClick(dev); }} style={{ cursor: 'pointer', background: selectedDevice?.ip === dev.ip ? 'rgba(102, 252, 241, 0.15)' : undefined }} > @@ -300,7 +300,7 @@ export function ScannerView() { <>
Host Details -
From a91294fd8de7fc4439e19039219ef543201977fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Thu, 23 Jul 2026 08:14:48 -0400 Subject: [PATCH 18/19] fix(ci): fix checkout steps in govulncheck and snyk workflows --- .github/workflows/govulncheck.yml | 29 +++++++---------------------- .github/workflows/snyk.yml | 29 +++++++---------------------- 2 files changed, 14 insertions(+), 44 deletions(-) diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 19ff75c..2f5f05c 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -1,8 +1,5 @@ name: Govulncheck -permissions: - contents: read - on: push: branches: [ "main", "develop" ] @@ -18,29 +15,17 @@ jobs: govulncheck: runs-on: ubuntu-latest steps: - - name: Checkout app - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - path: app + - name: Checkout repository + uses: actions/checkout@v4 - - name: Checkout engine - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - repository: catnet-io/engine - path: engine - - - name: Install dependencies - run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libwebkit2gtk-4.0-dev || true - - - name: Mock frontend dist - shell: bash - working-directory: app - run: mkdir -p frontend/dist; echo "mock" > frontend/dist/index.html - + go-version: '1.26.x' + cache: true + - name: Run govulncheck uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1 with: go-version-input: '1.26.x' go-package: ./... - work-dir: ./app - repo-checkout: false diff --git a/.github/workflows/snyk.yml b/.github/workflows/snyk.yml index 5469286..10b2292 100644 --- a/.github/workflows/snyk.yml +++ b/.github/workflows/snyk.yml @@ -13,48 +13,33 @@ jobs: snyk-go: runs-on: ubuntu-latest steps: - - name: Checkout app - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - path: app - - name: Checkout engine - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - repository: catnet-io/engine - path: engine + - name: Checkout repository + uses: actions/checkout@v4 - name: Mock frontend dist - working-directory: app run: mkdir -p frontend/dist; echo "mock" > frontend/dist/index.html - name: Run Snyk to check Go vulnerabilities uses: snyk/actions/golang@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0 env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} with: - args: --file=app/go.mod + args: --file=go.mod snyk-frontend: runs-on: ubuntu-latest steps: - - name: Checkout app - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - path: app - - name: Checkout engine - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - repository: catnet-io/engine - path: engine + - name: Checkout repository + uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest - name: Install dependencies - working-directory: app/frontend + working-directory: frontend run: bun install - name: Setup Snyk uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0 - name: Run Snyk to check Bun vulnerabilities - working-directory: app/frontend + working-directory: frontend env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} run: snyk test --file=package.json From e337a6178b6a5327b7d3538bcb6e216ac963c606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Mendes?= Date: Thu, 23 Jul 2026 08:17:04 -0400 Subject: [PATCH 19/19] fix(ci): match required status check context name Snyk --- .github/workflows/snyk.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/snyk.yml b/.github/workflows/snyk.yml index 10b2292..9c2e79c 100644 --- a/.github/workflows/snyk.yml +++ b/.github/workflows/snyk.yml @@ -10,7 +10,7 @@ on: branches: [ "main", "develop" ] jobs: - snyk-go: + Snyk: runs-on: ubuntu-latest steps: - name: Checkout repository