Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Before adopting `catnet-core`, please review our [API Stability Policy](docs/con
| `pkg/targets` | `ParseRange` | Target parsing and CIDR utilities. |
| `pkg/discovery` | `Ping`, `ReverseDNS`, `GetMAC` | Host liveness and resolution primitives. |
| `pkg/ports` | `ScanPorts` | Port scanning utilities. |
| `pkg/exporter` | `ExportJSON`, `ExportXML`, `ExportCSV` | Safe result export functions (with CSV injection prevention). |
| `pkg/exporter` | `ExportJSON`, `ExportXML`, `ExportCSV` | Safe result export functions. JSON is the canonical schema reference format. |
| `pkg/scanner` | *Deprecated* | Temporary compatibility wrapper. Do not use for new code. |

## Quickstart
Expand All @@ -39,16 +39,25 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

err := engine.StartScan(ctx, ips, cfg, func(d results.DeviceInfo) {
if d.IsAlive {
fmt.Printf("Found: %s (%s)\n", d.IP, d.MAC)
report, err := engine.StartScan(ctx, ips, cfg, func(event engine.ScanEvent) {
switch event.Type {
case engine.EventResult:
if event.Device != nil && event.Device.IsAlive {
fmt.Printf("Found: %s (%s)\n", event.Device.IP, event.Device.MAC)
}
case engine.EventProgress:
fmt.Printf("Progress: %.2f%%\n", event.Progress*100)
case engine.EventLifecycleStart:
fmt.Println("Scan started...")
case engine.EventLifecycleComplete:
fmt.Println("Scan completed!")
}
}, func(progress float64) {
fmt.Printf("Progress: %.2f%%\n", progress*100)
})

if err != nil {
fmt.Printf("Scan failed: %v\n", err)
} else {
fmt.Printf("Total Scanned: %d, Alive: %d\n", report.Total, report.Alive)
}
}
```
Expand Down
37 changes: 37 additions & 0 deletions docs/contracts/events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Scan Lifecycle Events

The `catnet-core` engine exposes a stream of typed events during scan execution to allow consumers (CLI, TUI, GUI) to react to progress and lifecycle changes without coupling to internal logic.

## Event Signature

Events are dispatched synchronously via the `engine.EventCallback` function:
```go
type EventCallback func(event ScanEvent)
```

## ScanEvent Struct

```go
type ScanEvent struct {
Type ScanEventType
Device *results.DeviceInfo
Progress float64
Message string
}
```

## Event Types

| Type | Description | Included Data |
|------|-------------|---------------|
| `EventLifecycleStart` | Fired once when `StartScan` is invoked. | `Message` |
| `EventLifecycleComplete` | Fired once when `StartScan` completes successfully. | `Message` |
| `EventLifecycleCancel` | Fired once when the scan is cancelled or times out. | `Message` |
| `EventWarning` | Fired when a non-fatal error occurs (e.g. permission issues). | `Message` |
| `EventProgress` | Fired periodically to indicate scan progress. | `Progress` (0.0 to 1.0) |
| `EventResult` | Fired when a device scan completes (whether alive or dead). | `Device` |

## Consumer Responsibilities

1. **Non-blocking callbacks:** Event callbacks are executed synchronously by the engine's worker goroutines. Consumers **must not** perform long-blocking operations in the callback. If processing requires heavy I/O, dispatch it to a channel or separate goroutine.
2. **Thread safety:** The `Device` pointer in `EventResult` points to a copied value, but consumers should avoid mutating it.
Comment on lines +36 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The onEvent callback is invoked concurrently from multiple worker goroutines. It is crucial to explicitly document this behavior so that consumers know they must implement thread-safe callbacks when mutating shared state or updating UI components.

Suggested change
1. **Non-blocking callbacks:** Event callbacks are executed synchronously by the engine's worker goroutines. Consumers **must not** perform long-blocking operations in the callback. If processing requires heavy I/O, dispatch it to a channel or separate goroutine.
2. **Thread safety:** The `Device` pointer in `EventResult` points to a copied value, but consumers should avoid mutating it.
1. **Non-blocking callbacks:** Event callbacks are executed synchronously by the engine's worker goroutines. Consumers **must not** perform long-blocking operations in the callback. If processing requires heavy I/O, dispatch it to a channel or separate goroutine.
2. **Thread safety:** The `onEvent` callback is invoked concurrently from multiple worker goroutines. Consumers **must** ensure that the callback implementation is fully thread-safe (e.g., by using mutexes or channels) when mutating shared state or updating UI components. Additionally, while the `Device` pointer in `EventResult` points to a copied value, consumers should avoid mutating it.

13 changes: 13 additions & 0 deletions pkg/coreerr/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package coreerr

import "errors"

// Sentinel errors representing the structured taxonomy for core operations.
var (
ErrInvalidInput = errors.New("invalid input")
ErrTimeout = errors.New("operation timed out")
ErrCancelled = errors.New("operation cancelled")
ErrPermission = errors.New("permission denied")
ErrExport = errors.New("export failed")
ErrPartial = errors.New("partial failure")
)
19 changes: 19 additions & 0 deletions pkg/coreerr/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package coreerr

import (
"errors"
"fmt"
"testing"
)

func TestErrorWrapping(t *testing.T) {
err := fmt.Errorf("scan failed: %w", ErrTimeout)

if !errors.Is(err, ErrTimeout) {
t.Errorf("Expected err to wrap ErrTimeout, but errors.Is returned false")
}

if errors.Is(err, ErrCancelled) {
t.Errorf("Expected err to not wrap ErrCancelled")
}
}
7 changes: 6 additions & 1 deletion pkg/engine/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import "github.com/mendsec/catnet-core/pkg/results"
type ScanEventType int

const (
EventResult ScanEventType = iota
EventLifecycleStart ScanEventType = iota
EventLifecycleComplete
EventLifecycleCancel
EventWarning
EventResult
EventProgress
)

Expand All @@ -15,6 +19,7 @@ type ScanEvent struct {
Type ScanEventType
Device *results.DeviceInfo
Progress float64
Message string
}

// EventCallback é a assinatura da função de callback síncrono.
Expand Down
32 changes: 32 additions & 0 deletions pkg/engine/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package engine

import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"

"github.com/mendsec/catnet-core/pkg/coreerr"
"github.com/mendsec/catnet-core/pkg/discovery"
"github.com/mendsec/catnet-core/pkg/ports"
"github.com/mendsec/catnet-core/pkg/results"
Expand All @@ -17,8 +19,22 @@ func StartScan(ctx context.Context, ips []string, cfg ScanConfig, onEvent EventC
total := len(ips)
report.Total = total
report.Devices = make([]results.DeviceInfo, 0, total)

if onEvent != nil {
onEvent(ScanEvent{
Type: EventLifecycleStart,
Message: "Scan started",
})
}

if total == 0 {
report.EndTime = time.Now()
if onEvent != nil {
onEvent(ScanEvent{
Type: EventLifecycleComplete,
Message: "Scan completed (empty)",
})
}
return report, nil
}

Expand Down Expand Up @@ -105,5 +121,21 @@ func StartScan(ctx context.Context, ips []string, cfg ScanConfig, onEvent EventC
}
wg.Wait()
report.EndTime = time.Now()

if ctx.Err() == context.DeadlineExceeded {
if onEvent != nil {
onEvent(ScanEvent{Type: EventLifecycleCancel, Message: "Scan timeout"})
}
return report, fmt.Errorf("%w: scan reached timeout", coreerr.ErrTimeout)
} else if ctx.Err() == context.Canceled {
if onEvent != nil {
onEvent(ScanEvent{Type: EventLifecycleCancel, Message: "Scan cancelled"})
}
return report, fmt.Errorf("%w: scan was cancelled", coreerr.ErrCancelled)
}

if onEvent != nil {
onEvent(ScanEvent{Type: EventLifecycleComplete, Message: "Scan completed successfully"})
}
return report, nil
}
9 changes: 5 additions & 4 deletions pkg/exporter/exporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"encoding/xml"
"fmt"
"github.com/mendsec/catnet-core/pkg/coreerr"
"github.com/mendsec/catnet-core/pkg/results"
"strconv"
"strings"
Expand All @@ -15,7 +16,7 @@ import (
func ExportJSON(report *results.ScanReport) ([]byte, error) {
out, err := json.MarshalIndent(report, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to encode JSON: %w", err)
return nil, fmt.Errorf("%w: failed to encode JSON: %v", coreerr.ErrExport, err)
}
return out, nil
}
Expand Down Expand Up @@ -44,7 +45,7 @@ func ExportXML(report *results.ScanReport) ([]byte, error) {
}
out, err := xml.MarshalIndent(res, "", "\t")
if err != nil {
return nil, fmt.Errorf("failed to encode XML: %w", err)
return nil, fmt.Errorf("%w: failed to encode XML: %v", coreerr.ErrExport, err)
}
return append([]byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"), out...), nil
}
Expand All @@ -65,7 +66,7 @@ func ExportCSV(report *results.ScanReport) ([]byte, error) {
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
if err := writer.Write([]string{"IP", "Hostname", "MAC", "Status", "Open Ports"}); err != nil {
return nil, err
return nil, fmt.Errorf("%w: failed to write CSV header: %v", coreerr.ErrExport, err)
}
for _, d := range report.Devices {
status := "Dead"
Expand All @@ -79,7 +80,7 @@ func ExportCSV(report *results.ScanReport) ([]byte, error) {
if err := writer.Write([]string{
sanitizeCSVField(d.IP), sanitizeCSVField(d.Hostname), sanitizeCSVField(d.MAC), status, strings.Join(strPorts, ";"),
}); err != nil {
return nil, err
return nil, fmt.Errorf("%w: failed to write CSV record: %v", coreerr.ErrExport, err)
}
}
writer.Flush()
Expand Down
10 changes: 6 additions & 4 deletions pkg/results/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import "time"

// ScanReport encapsula o resultado completo de uma varredura.
type ScanReport struct {
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
SchemaVersion string `json:"schemaVersion"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Total int `json:"total"`
Alive int `json:"alive"`
Devices []DeviceInfo `json:"devices"`
Expand All @@ -14,7 +15,8 @@ type ScanReport struct {
// NewScanReport cria um novo relatório de varredura.
func NewScanReport() *ScanReport {
return &ScanReport{
StartTime: time.Now(),
Devices: []DeviceInfo{},
SchemaVersion: "1.0.0",
StartTime: time.Now(),
Devices: []DeviceInfo{},
}
}
14 changes: 8 additions & 6 deletions pkg/targets/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import (
"fmt"
"net"
"strings"

"github.com/mendsec/catnet-core/pkg/coreerr"
)

// ParseRange interpreta uma string e retorna uma lista de endereços IP correspondentes.
func ParseRange(input string) ([]string, error) {
input = strings.TrimSpace(input)
if input == "" {
return nil, fmt.Errorf("empty input")
return nil, fmt.Errorf("%w: empty input", coreerr.ErrInvalidInput)
}
if strings.Contains(input, "/") {
return parseCIDR(input)
Comment on lines 18 to 19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The error returned by parseCIDR is returned directly without being wrapped by coreerr.ErrInvalidInput. This breaks the structured error taxonomy for CIDR parsing errors, making it impossible for callers to detect them using errors.Is(err, coreerr.ErrInvalidInput).

Please wrap the error returned by parseCIDR to ensure consistent error handling.

Suggested change
if strings.Contains(input, "/") {
return parseCIDR(input)
if strings.Contains(input, "/") {
ips, err := parseCIDR(input)
if err != nil {
return nil, fmt.Errorf("%w: %v", coreerr.ErrInvalidInput, err)
}
return ips, nil
}

Expand All @@ -21,7 +23,7 @@ func ParseRange(input string) ([]string, error) {
}
parsed := net.ParseIP(input)
if parsed == nil {
return nil, fmt.Errorf("invalid IP format")
return nil, fmt.Errorf("%w: invalid IP format", coreerr.ErrInvalidInput)
}
return []string{parsed.String()}, nil
}
Expand All @@ -44,7 +46,7 @@ func parseCIDR(cidr string) ([]string, error) {
func parseDashRange(dashStr string) ([]string, error) {
parts := strings.Split(dashStr, "-")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid range format, expected start-end")
return nil, fmt.Errorf("%w: invalid range format, expected start-end", coreerr.ErrInvalidInput)
}
startStr := strings.TrimSpace(parts[0])
endStr := strings.TrimSpace(parts[1])
Expand All @@ -57,15 +59,15 @@ func parseDashRange(dashStr string) ([]string, error) {
startIP := net.ParseIP(startStr).To4()
endIP := net.ParseIP(endStr).To4()
if startIP == nil || endIP == nil {
return nil, fmt.Errorf("invalid IP in range")
return nil, fmt.Errorf("%w: invalid IP in range", coreerr.ErrInvalidInput)
}
start := binary.BigEndian.Uint32(startIP)
end := binary.BigEndian.Uint32(endIP)
if start > end {
return nil, fmt.Errorf("start IP is greater than end IP")
return nil, fmt.Errorf("%w: start IP is greater than end IP", coreerr.ErrInvalidInput)
}
if end-start > 65536 {
return nil, fmt.Errorf("range too large (max 65536)")
return nil, fmt.Errorf("%w: range too large (max 65536)", coreerr.ErrInvalidInput)
}
var ips []string
for i := start; i <= end; i++ {
Expand Down
Loading