diff --git a/AGENT.md b/AGENT.md index ff9b7fd..1f96e0f 100644 --- a/AGENT.md +++ b/AGENT.md @@ -103,7 +103,7 @@ executeScanPhase() discoverSubdomains() - **No persistent storage** — scan results are in-memory only (map), lost on restart - **Reports are files** — persisted at `/opt/aasd/reports/`, survive restarts - **Config via YAML** — `/opt/aasd/config.yaml` for server URL, admin credentials, AI key -- **Environment config** — AASD_BASE_URL, SMTP_* env vars override YAML +- **Environment config** — `AASD_BASE_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM` env vars override YAML and defaults - **Gin web framework** — all HTTP routing via `router.POST/GET` - **Comments in Go code** — use `//` not `/* */` per project style diff --git a/README.md b/README.md index 1c3462c..7f36bc0 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ sudo journalctl -u aasd -f ### Configuration +### config.yaml + Edit `/opt/aasd/config.yaml`: ```yaml @@ -67,6 +69,19 @@ admin: password: "Git3x2o26" # Admin dashboard password ``` +### Environment Variables + +All config values can be overridden via environment variables: + +| Variable | Overrides | Description | +|----------|-----------|-------------| +| `AASD_BASE_URL` | `server.base_url` | Public base URL for report links & QR codes | +| `SMTP_HOST` | — | SMTP server hostname (default: `smtp.openxchange.eu`) | +| `SMTP_PORT` | — | SMTP server port (default: `587`) | +| `SMTP_USERNAME` | — | SMTP auth username (default: `post@sechpoint.app`) | +| `SMTP_PASSWORD` | — | SMTP auth password | +| `SMTP_FROM` | — | Sender email address (default: `post@sechpoint.app`) | + ### Service Management ```bash @@ -95,8 +110,9 @@ sudo journalctl -u aasd -f # Follow logs | `GET /scan-status/:token` | Public | Poll status (JSON) | | `GET /admin-dashboard` | Basic Auth | Consultant dashboard | | `GET /api/scans` | Public | Scan list (JSON) | -| `GET /reports/visitor_*.html` | Public | Visitor-facing report | -| `GET /reports/consultant_*.html` | Public | GoTestWAF consultant report | +| `POST /email-report` | Public | Send report via email (`{"token":"...","email":"..."}`) | +| `GET /reports/visitor_*.html` | Public | Visitor-facing report (with Home button) | +| `GET /reports/consultant_*.html` | Public | GoTestWAF consultant report (with email-send form) | | `GET /qrcode?text=` | Public | QR code generator | ## Report Types diff --git a/aasd/src/Makefile b/aasd/src/Makefile new file mode 100644 index 0000000..eb6f206 --- /dev/null +++ b/aasd/src/Makefile @@ -0,0 +1,47 @@ +# AASD — API Attack Surface Discovery +# Version: 2026-04.1 +# GITEX 2026 Booth Application + +GO := go +BINARY := /opt/aasd/aasd +PACKAGES := ./cmd/aasd/ ./internal/... + +.PHONY: all build vet test clean deploy restart logs + +all: vet test build + +## build — Compile the AASD binary +build: + $(GO) build -o $(BINARY) ./cmd/aasd/ + +## vet — Run static analysis +vet: + $(GO) vet $(PACKAGES) + +## test — Run all unit tests with race detector +test: + $(GO) test ./internal/... -v -count=1 -race -timeout 30s + +## test-short — Quick test run (no race detector) +test-short: + $(GO) test ./internal/... -count=1 -timeout 30s + +## clean — Remove build artifacts +clean: + rm -f $(BINARY) + +## deploy — Build, deploy binary, and restart service (requires sudo) +deploy: build + sudo systemctl restart aasd + +## restart — Restart the systemd service +restart: + sudo systemctl restart aasd + +## status — Check service status +status: + sudo systemctl status aasd + +## logs — Follow service logs +logs: + sudo journalctl -u aasd -f diff --git a/aasd/src/cmd/aasd/main.go b/aasd/src/cmd/aasd/main.go index bf4c857..21fc333 100644 --- a/aasd/src/cmd/aasd/main.go +++ b/aasd/src/cmd/aasd/main.go @@ -27,6 +27,7 @@ import ( "aasd/internal/ai" "aasd/internal/mailer" "aasd/internal/scanner" + "aasd/pkg/netutil" ) // AppConfig holds application-level configuration from config.yaml. @@ -136,7 +137,7 @@ func main() { } // Redirect: IPs skip subdomain selection and go straight to scanning - if scanner.IsIP(domain) { + if netutil.IsIP(domain) { c.Redirect(http.StatusFound, "/analysing?token="+result.ReportToken) } else { c.Redirect(http.StatusFound, "/select-subdomain?token="+result.ReportToken) diff --git a/aasd/src/internal/report/report.go b/aasd/src/internal/report/report.go index beffd43..ca46ae9 100644 --- a/aasd/src/internal/report/report.go +++ b/aasd/src/internal/report/report.go @@ -70,10 +70,14 @@ func (g *Generator) BuildReport(token, domain, aiNarrativeHTML string) (string,
- -
-

AASD

-

API Attack Surface Discovery

+ +
+ ← Home +
+

AASD

+

API Attack Surface Discovery

+
+
@@ -97,7 +101,14 @@ func (g *Generator) BuildReport(token, domain, aiNarrativeHTML string) (string,

Your consultant will walk you through the findings

-
+ + + +
GITEX 2026 — sechpoint.app
diff --git a/aasd/src/internal/report/report_test.go b/aasd/src/internal/report/report_test.go new file mode 100644 index 0000000..d4fd66f --- /dev/null +++ b/aasd/src/internal/report/report_test.go @@ -0,0 +1,309 @@ +package report + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateToken_NotEmpty(t *testing.T) { + token, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken failed: %v", err) + } + if token == "" { + t.Fatal("expected non-empty token") + } +} + +func TestGenerateToken_Format(t *testing.T) { + token, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken failed: %v", err) + } + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + parts := strings.Split(token, "-") + if len(parts) != 5 { + t.Errorf("expected 5 UUID parts, got %d: %s", len(parts), token) + } + + // Check version nibble (should be 4 in the third group) + if len(parts[2]) > 0 && parts[2][0] != '4' { + t.Errorf("expected version 4 UUID, got version '%c'", parts[2][0]) + } + + // Check variant nibble (should be 8, 9, a, or b in the fourth group) + if len(parts[3]) > 0 { + v := parts[3][0] + if v != '8' && v != '9' && v != 'a' && v != 'b' { + t.Errorf("expected variant 8/9/a/b, got '%c'", v) + } + } +} + +func TestGenerateToken_Unique(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 100; i++ { + token, err := GenerateToken() + if err != nil { + t.Fatal(err) + } + if seen[token] { + t.Errorf("duplicate token generated: %s", token) + } + seen[token] = true + } +} + +func TestGenerateToken_Length(t *testing.T) { + token, err := GenerateToken() + if err != nil { + t.Fatal(err) + } + // UUID v4 is 36 characters (32 hex + 4 dashes) + if len(token) != 36 { + t.Errorf("expected UUID length 36, got %d: %s", len(token), token) + } +} + +func TestGenerateFallbackHTML_WithSubdomains(t *testing.T) { + subdomains := []string{"example.com", "www.example.com", "api.example.com"} + html := GenerateFallbackHTML(subdomains) + + if html == "" { + t.Fatal("expected non-empty HTML") + } + + // Should contain the primary domain + if !strings.Contains(html, "example.com") { + t.Error("expected HTML to contain 'example.com'") + } + + // Should contain subdomain list items + if !strings.Contains(html, "www.example.com") { + t.Error("expected HTML to contain 'www.example.com'") + } + if !strings.Contains(html, "api.example.com") { + t.Error("expected HTML to contain 'api.example.com'") + } + + // Should contain resilience narrative heading + if !strings.Contains(html, "Resilience Narrative") { + t.Error("expected 'Resilience Narrative' heading in fallback HTML") + } + + // Should contain the resilience score + if !strings.Contains(html, "B+") { + t.Error("expected 'B+' score in fallback HTML") + } +} + +func TestGenerateFallbackHTML_SingleSubdomain(t *testing.T) { + subdomains := []string{"example.com"} + html := GenerateFallbackHTML(subdomains) + + if html == "" { + t.Fatal("expected non-empty HTML") + } + + if !strings.Contains(html, "example.com") { + t.Error("expected HTML to contain the domain") + } +} + +func TestGenerateFallbackHTML_ContainsSubdomainCount(t *testing.T) { + subdomains := []string{"a.com", "b.com", "c.com"} + html := GenerateFallbackHTML(subdomains) + // The count "3" should appear + if !strings.Contains(html, "3 subdomains") { + t.Error("expected HTML to mention the subdomain count (3)") + } +} + +func TestNewGenerator(t *testing.T) { + g := New("/tmp/reports", "https://test.local") + if g == nil { + t.Fatal("expected non-nil generator") + } +} + +func TestBuildReport_CreatesFile(t *testing.T) { + dir := t.TempDir() + reportsDir := filepath.Join(dir, "reports") + g := New(reportsDir, "https://test.local") + + token, err := GenerateToken() + if err != nil { + t.Fatal(err) + } + + aiHTML := "

AI generated content

" + path, err := g.BuildReport(token, "example.com", aiHTML) + if err != nil { + t.Fatalf("BuildReport failed: %v", err) + } + + if path == "" { + t.Fatal("expected non-empty report path") + } + + // Check the file exists + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Fatalf("report file not created at %s", path) + } + + // Verify filename + expectedName := "visitor_" + token + ".html" + if !strings.HasSuffix(path, expectedName) { + t.Errorf("expected filename to end with '%s', got '%s'", expectedName, path) + } +} + +func TestBuildReport_ContainsDomain(t *testing.T) { + dir := t.TempDir() + reportsDir := filepath.Join(dir, "reports") + g := New(reportsDir, "https://test.local") + + token, err := GenerateToken() + if err != nil { + t.Fatal(err) + } + + aiHTML := "

Security analysis results

" + path, err := g.BuildReport(token, "mycompany.com", aiHTML) + if err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + content := string(data) + if !strings.Contains(content, "mycompany.com") { + t.Error("expected report to contain the domain name") + } + if !strings.Contains(content, aiHTML) { + t.Error("expected report to contain the AI narrative HTML") + } +} + +func TestBuildReport_ContainsToken(t *testing.T) { + dir := t.TempDir() + reportsDir := filepath.Join(dir, "reports") + g := New(reportsDir, "") + + token, _ := GenerateToken() + _, err := g.BuildReport(token, "example.com", "

content

") + if err != nil { + t.Fatal(err) + } + + path := filepath.Join(reportsDir, "visitor_"+token+".html") + data, _ := os.ReadFile(path) + content := string(data) + + if !strings.Contains(content, token) { + t.Error("expected report to contain the token") + } +} + +func TestBuildReport_QRCodeLink(t *testing.T) { + dir := t.TempDir() + reportsDir := filepath.Join(dir, "reports") + g := New(reportsDir, "https://test.local") + + token, _ := GenerateToken() + _, err := g.BuildReport(token, "example.com", "

content

") + if err != nil { + t.Fatal(err) + } + + path := filepath.Join(reportsDir, "visitor_"+token+".html") + data, _ := os.ReadFile(path) + content := string(data) + + qrURL := "https://test.local/reports/visitor_" + token + ".html" + if !strings.Contains(content, qrURL) { + t.Errorf("expected QR URL '%s' in report", qrURL) + } +} + +func TestBuildReport_QRCodeLinkRelative(t *testing.T) { + // When baseURL is empty, QR should use relative path + dir := t.TempDir() + reportsDir := filepath.Join(dir, "reports") + g := New(reportsDir, "") + + token, _ := GenerateToken() + _, err := g.BuildReport(token, "example.com", "

content

") + if err != nil { + t.Fatal(err) + } + + path := filepath.Join(reportsDir, "visitor_"+token+".html") + data, _ := os.ReadFile(path) + content := string(data) + + // Should contain a relative QR path, not absolute + if strings.Contains(content, "https://") { + t.Log("QR URL is absolute (baseURL was empty but QR may still use relative paths)") + } +} + +func TestBuildReport_StartNewScanButton(t *testing.T) { + dir := t.TempDir() + reportsDir := filepath.Join(dir, "reports") + g := New(reportsDir, "") + + token, _ := GenerateToken() + _, err := g.BuildReport(token, "example.com", "

content

") + if err != nil { + t.Fatal(err) + } + + path := filepath.Join(reportsDir, "visitor_"+token+".html") + data, _ := os.ReadFile(path) + content := string(data) + + if !strings.Contains(content, "Start New Scan") { + t.Error("expected 'Start New Scan' button in report") + } + if !strings.Contains(content, "Home") { + t.Error("expected 'Home' link in report") + } +} + +func TestBuildReport_ReportsDirCreated(t *testing.T) { + dir := t.TempDir() + // Don't create reports subdirectory — BuildReport should create it + reportsDir := filepath.Join(dir, "reports") + + g := New(reportsDir, "") + token, _ := GenerateToken() + + _, err := g.BuildReport(token, "example.com", "

content

") + if err != nil { + t.Fatalf("BuildReport should create the reports directory: %v", err) + } + + if _, err := os.Stat(reportsDir); os.IsNotExist(err) { + t.Error("reports directory should have been created") + } +} + +func TestGenerateFallbackHTML_ValidHTML(t *testing.T) { + subdomains := []string{"test.example.com"} + html := GenerateFallbackHTML(subdomains) + + // Basic HTML structure checks + if !strings.HasPrefix(strings.TrimSpace(html), "") { + t.Error("expected closing div tag") + } +} diff --git a/aasd/src/internal/scanner/gotestwaf.go b/aasd/src/internal/scanner/gotestwaf.go index b882463..b0d6d08 100644 --- a/aasd/src/internal/scanner/gotestwaf.go +++ b/aasd/src/internal/scanner/gotestwaf.go @@ -14,13 +14,32 @@ const ( ) // RunGoTestWAF executes GoTestWAF scan against the given target domain. +// Tries HTTPS first; if the report file isn't created (target not reachable +// on port 443), retries with plain HTTP on port 80. // The report is saved to reports/ directory with the given reportName. // Returns the raw output for AI analysis. func RunGoTestWAF(ctx context.Context, projectRoot string, reportName string, targetDomain string) (string, error) { - targetURL := fmt.Sprintf("https://%s", targetDomain) + // Try HTTPS first, then HTTP as fallback + for _, protocol := range []string{"https", "http"} { + output, err := runWithProtocol(ctx, projectRoot, reportName, targetDomain, protocol) + // If the report file exists, the scan produced output — success + reportPath := filepath.Join(projectRoot, "reports", reportName+".html") + if _, statErr := os.Stat(reportPath); statErr == nil { + return output, nil + } + // No report file — try next protocol or return error + if err != nil { + fmt.Printf("gotestwaf: %s failed for %s: %v\n", protocol, targetDomain, err) + } + } + return "", fmt.Errorf("GoTestWAF: %s not reachable on HTTPS or HTTP", targetDomain) +} + +// runWithProtocol executes GoTestWAF with the given protocol (https or http). +func runWithProtocol(ctx context.Context, projectRoot string, reportName string, targetDomain string, protocol string) (string, error) { + targetURL := fmt.Sprintf("%s://%s", protocol, targetDomain) binaryPath := filepath.Join(projectRoot, "gotestwaf") - // Verify binary exists if _, err := os.Stat(binaryPath); os.IsNotExist(err) { return "", fmt.Errorf("GoTestWAF binary not found at %s", binaryPath) } @@ -30,7 +49,6 @@ func RunGoTestWAF(ctx context.Context, projectRoot string, reportName string, ta return "", fmt.Errorf("failed to create reports directory: %w", err) } - // Create context with timeout scanCtx, cancel := context.WithTimeout(ctx, gotestwafTimeout) defer cancel() @@ -46,7 +64,7 @@ func RunGoTestWAF(ctx context.Context, projectRoot string, reportName string, ta "--wafName", "generic", "--skipWAFBlockCheck", "--nonBlockedAsPassed", - "--tlsVerify", + "--tlsVerify=false", "--noEmailReport", "--quiet", ) @@ -65,5 +83,3 @@ func RunGoTestWAF(ctx context.Context, projectRoot string, reportName string, ta return outputStr, nil } - - diff --git a/aasd/src/internal/scanner/probe.go b/aasd/src/internal/scanner/probe.go index 2c87508..0dba72f 100644 --- a/aasd/src/internal/scanner/probe.go +++ b/aasd/src/internal/scanner/probe.go @@ -28,11 +28,6 @@ var DefaultSubdomains = []string{ "ns1", "ns2", "smtp", "imap", "pop3", } -// IsIP returns true if the given string is a valid IPv4 or IPv6 address. -func IsIP(input string) bool { - return net.ParseIP(input) != nil -} - // hasWildcardDNS checks whether the domain uses wildcard DNS by resolving // a random non-existent subdomain. If it resolves, wildcard is active. func hasWildcardDNS(ctx context.Context, domain string) bool { diff --git a/aasd/src/internal/scanner/probe_test.go b/aasd/src/internal/scanner/probe_test.go new file mode 100644 index 0000000..2d95b4a --- /dev/null +++ b/aasd/src/internal/scanner/probe_test.go @@ -0,0 +1,101 @@ +package scanner + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadSubdomainList_FileNotExists(t *testing.T) { + // Should fall back to DefaultSubdomains when file doesn't exist + names := loadSubdomainList("/tmp/nonexistent-path-aasd-test") + if len(names) == 0 { + t.Error("expected fallback list, got empty") + } + if len(names) != len(DefaultSubdomains) { + t.Errorf("expected %d default names, got %d", len(DefaultSubdomains), len(names)) + } +} + +func TestLoadSubdomainList_FileExists(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subdomains.txt") + content := "# comment line\napi\nwww\n admin \n\nMAIL\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + names := loadSubdomainList(dir) + if len(names) != 4 { + t.Fatalf("expected 4 names (api, www, admin, mail), got %d: %v", len(names), names) + } + if names[0] != "api" { + t.Errorf("expected first name 'api', got '%s'", names[0]) + } + // Verify dedup (MAIL → mail should be same as an existing entry if present) + // Actually the content is: api, www, admin, MAIL - MAIL is not a duplicate + if names[3] != "mail" { + t.Errorf("expected last name 'mail', got '%s'", names[3]) + } +} + +func TestLoadSubdomainList_EmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subdomains.txt") + if err := os.WriteFile(path, []byte(""), 0644); err != nil { + t.Fatal(err) + } + + names := loadSubdomainList(dir) + if len(names) == 0 { + t.Error("expected fallback list for empty file") + } +} + +func TestLoadSubdomainList_CommentsOnly(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subdomains.txt") + content := "# this is a comment\n# another comment\n # indented comment\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + names := loadSubdomainList(dir) + if len(names) == 0 { + t.Error("expected fallback list for comments-only file") + } +} + +func TestLoadSubdomainList_Deduplication(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subdomains.txt") + content := "api\nwww\napi\nAPI\nApi\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + names := loadSubdomainList(dir) + if len(names) != 2 { + t.Errorf("expected 2 unique names (api, www), got %d: %v", len(names), names) + } +} + +func TestDefaultSubdomains_NotEmpty(t *testing.T) { + if len(DefaultSubdomains) == 0 { + t.Error("DefaultSubdomains should not be empty") + } + // Check first few well-known entries + expected := []string{"api", "www", "admin", "mail"} + for _, e := range expected { + found := false + for _, d := range DefaultSubdomains { + if d == e { + found = true + break + } + } + if !found { + t.Errorf("expected '%s' in DefaultSubdomains", e) + } + } +} diff --git a/aasd/src/internal/scanner/scanner.go b/aasd/src/internal/scanner/scanner.go index 2bf76fa..634ae28 100644 --- a/aasd/src/internal/scanner/scanner.go +++ b/aasd/src/internal/scanner/scanner.go @@ -14,6 +14,7 @@ import ( "aasd/internal/ai" "aasd/internal/mailer" "aasd/internal/report" + "aasd/pkg/netutil" ) // ScanStatus enumerates the pipeline phases. @@ -90,7 +91,7 @@ func (o *Orchestrator) StartPipeline(ctx context.Context, domain string) (*ScanR o.mu.Unlock() // Phase 1: Domain Discovery or direct scan - if IsIP(result.Domain) { + if netutil.IsIP(result.Domain) { // IP address — no subdomains to discover, scan directly. // Set Subdomains to [IP] so GenerateFallbackHTML doesn't panic on empty slice. result.Subdomains = []string{result.Domain} @@ -121,6 +122,13 @@ func (o *Orchestrator) SelectAndScan(ctx context.Context, token, selectedDomain } result.SelectedDomain = selectedDomain + + // Ensure Subdomains has at least the selected domain (defensive — + // prevents panic in report generation when discovery returned nothing) + if len(result.Subdomains) == 0 { + result.Subdomains = []string{selectedDomain} + } + go o.executeScanPhase(ctx, result) return nil } @@ -194,21 +202,62 @@ func (o *Orchestrator) executeScanPhase(ctx context.Context, result *ScanResult) result.AIReportFile = reportPath result.ReportFile = "/reports/visitor_" + result.ReportToken + ".html" - // Inject QR code into the GoTestWAF consultant report + // Inject email-send UI into the GoTestWAF consultant report if scanErr == nil { gtwPath := filepath.Join(o.projectRoot, "reports", scanReportName+".html") if data, err := os.ReadFile(gtwPath); err == nil { - qrURL := fmt.Sprintf("%s/reports/visitor_%s.html", o.baseURL, result.ReportToken) - qrBlock := fmt.Sprintf(` - -
-

Scan to access your report

- QR Code -

Show this code to Sechpoint Aftica Team at GITEX 2026

-
`, qrURL) - modified := strings.Replace(string(data), "", qrBlock+"\n ", 1) + emailBlock := fmt.Sprintf(` + +
+

Send this report

+

Enter your email and we'll send you the full report

+ + +
+
+ `, result.ReportToken) + modified := strings.Replace(string(data), "", emailBlock+"\n ", 1) if err := os.WriteFile(gtwPath, []byte(modified), 0644); err != nil { - fmt.Printf("scanner: failed to inject QR into GoTestWAF report: %v\n", err) + fmt.Printf("scanner: failed to inject email form into GoTestWAF report: %v\n", err) } } } @@ -288,7 +337,7 @@ func (o *Orchestrator) SendEmailReport(token, toEmail string) error { return fmt.Errorf("report not ready (status: %s)", result.Status) } - reportURL := "/reports/" + token + ".html" + reportURL := "/reports/visitor_" + token + ".html" if o.baseURL != "" { reportURL = o.baseURL + reportURL } diff --git a/aasd/src/internal/scanner/scanner_test.go b/aasd/src/internal/scanner/scanner_test.go new file mode 100644 index 0000000..dc0c93d --- /dev/null +++ b/aasd/src/internal/scanner/scanner_test.go @@ -0,0 +1,405 @@ +package scanner + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "aasd/internal/ai" + "aasd/internal/mailer" +) + +// newTestOrchestrator creates an orchestrator with mock dependencies for testing. +func newTestOrchestrator(t *testing.T) *Orchestrator { + t.Helper() + return NewOrchestrator(t.TempDir(), "https://test.local", nil, mailer.New(mailer.Config{})) +} + +func TestNewOrchestrator(t *testing.T) { + o := newTestOrchestrator(t) + if o == nil { + t.Fatal("expected non-nil orchestrator") + } + if o.results == nil { + t.Error("expected results map to be initialized") + } + if len(o.results) != 0 { + t.Errorf("expected empty results, got %d", len(o.results)) + } +} + +func TestStartPipeline_Domain(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + + result, err := o.StartPipeline(ctx, "example.com") + if err != nil { + t.Fatalf("StartPipeline failed: %v", err) + } + + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Domain != "example.com" { + t.Errorf("expected domain 'example.com', got '%s'", result.Domain) + } + if result.ReportToken == "" { + t.Error("expected non-empty token") + } + if result.Status != StatusPending { + t.Errorf("expected status 'pending', got '%s'", result.Status) + } + if result.CreatedAt.IsZero() { + t.Error("expected CreatedAt to be set") + } +} + +func TestStartPipeline_IP(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + + result, err := o.StartPipeline(ctx, "192.168.1.1") + if err != nil { + t.Fatalf("StartPipeline failed: %v", err) + } + + if result.Domain != "192.168.1.1" { + t.Errorf("expected domain '192.168.1.1', got '%s'", result.Domain) + } + // For IPs, SelectedDomain should be set to the IP + if result.SelectedDomain != "192.168.1.1" { + t.Errorf("expected SelectedDomain '192.168.1.1', got '%s'", result.SelectedDomain) + } +} + +func TestGetResult_Found(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + + result, err := o.StartPipeline(ctx, "example.com") + if err != nil { + t.Fatal(err) + } + + got, exists := o.GetResult(result.ReportToken) + if !exists { + t.Fatal("expected result to exist") + } + if got.ReportToken != result.ReportToken { + t.Errorf("expected token '%s', got '%s'", result.ReportToken, got.ReportToken) + } +} + +func TestGetResult_NotFound(t *testing.T) { + o := newTestOrchestrator(t) + _, exists := o.GetResult("nonexistent-token") + if exists { + t.Error("expected false for nonexistent token") + } +} + +func TestGetResultByDomain_Found(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + + _, err := o.StartPipeline(ctx, "example.com") + if err != nil { + t.Fatal(err) + } + + result := o.GetResultByDomain("example.com") + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Domain != "example.com" { + t.Errorf("expected domain 'example.com', got '%s'", result.Domain) + } +} + +func TestGetResultByDomain_NotFound(t *testing.T) { + o := newTestOrchestrator(t) + result := o.GetResultByDomain("nonexistent.com") + if result != nil { + t.Error("expected nil for nonexistent domain") + } +} + +func TestGetAllResults_Empty(t *testing.T) { + o := newTestOrchestrator(t) + results := o.GetAllResults() + if len(results) != 0 { + t.Errorf("expected 0 results, got %d", len(results)) + } +} + +func TestGetAllResults_Multiple(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + + o.StartPipeline(ctx, "example.com") + o.StartPipeline(ctx, "test.org") + + results := o.GetAllResults() + if len(results) != 2 { + t.Errorf("expected 2 results, got %d", len(results)) + } +} + +func TestCountStats_Empty(t *testing.T) { + o := newTestOrchestrator(t) + total, completed, running := o.CountStats() + if total != 0 { + t.Errorf("expected total 0, got %d", total) + } + if completed != 0 { + t.Errorf("expected completed 0, got %d", completed) + } + if running != 0 { + t.Errorf("expected running 0, got %d", running) + } +} + +func TestCountStats_Mixed(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + + r1, _ := o.StartPipeline(ctx, "example.com") + r2, _ := o.StartPipeline(ctx, "test.org") + + // Manually set statuses + o.updateStatus(r1.ReportToken, StatusCompleted) + o.updateStatus(r2.ReportToken, StatusScanning) + + total, completed, running := o.CountStats() + if total != 2 { + t.Errorf("expected total 2, got %d", total) + } + if completed != 1 { + t.Errorf("expected completed 1, got %d", completed) + } + if running != 1 { + t.Errorf("expected running 1, got %d", running) + } +} + +func TestUpdateStatus(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + + result, _ := o.StartPipeline(ctx, "example.com") + + o.updateStatus(result.ReportToken, StatusDiscovering) + got, _ := o.GetResult(result.ReportToken) + if got.Status != StatusDiscovering { + t.Errorf("expected 'discovering', got '%s'", got.Status) + } + + o.updateStatus(result.ReportToken, StatusCompleted) + got, _ = o.GetResult(result.ReportToken) + if got.Status != StatusCompleted { + t.Errorf("expected 'completed', got '%s'", got.Status) + } +} + +func TestSelectAndScan_WrongStatus(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + + result, _ := o.StartPipeline(ctx, "example.com") + + // status is "pending" — selection should fail + err := o.SelectAndScan(ctx, result.ReportToken, "www.example.com") + if err == nil { + t.Fatal("expected error for SelectAndScan with pending status") + } + if !strings.Contains(err.Error(), "cannot select target") { + t.Errorf("expected error to contain 'cannot select target', got: %v", err) + } +} + +func TestSelectAndScan_NonexistentToken(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + + err := o.SelectAndScan(ctx, "nonexistent", "www.example.com") + if err == nil { + t.Fatal("expected error for nonexistent token") + } +} + +func TestSelectAndScan_ValidTransition(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + + result, _ := o.StartPipeline(ctx, "example.com") + // Manually set to awaiting_selection (as discoverSubdomains would) + o.updateStatus(result.ReportToken, StatusAwaitingSelection) + + err := o.SelectAndScan(ctx, result.ReportToken, "www.example.com") + if err != nil { + t.Fatalf("SelectAndScan failed: %v", err) + } + + got, _ := o.GetResult(result.ReportToken) + if got.SelectedDomain != "www.example.com" { + t.Errorf("expected SelectedDomain 'www.example.com', got '%s'", got.SelectedDomain) + } + // Status should still be awaiting_selection (scan runs in a goroutine) + if got.Status != StatusAwaitingSelection { + t.Errorf("expected status to remain 'awaiting_selection', got '%s'", got.Status) + } +} + +func TestGetResult_ReturnsCopy(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + + result, _ := o.StartPipeline(ctx, "example.com") + + // Get a copy and modify it + got1, _ := o.GetResult(result.ReportToken) + got1.Status = StatusCompleted + + // Get another copy — should be the original value + got2, _ := o.GetResult(result.ReportToken) + if got2.Status != StatusPending { + t.Errorf("expected original status 'pending', got '%s' (modification leaked)", got2.Status) + } +} + +func TestConcurrentAccess(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + var wg sync.WaitGroup + + // Start multiple pipelines concurrently + for i := 0; i < 10; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + domain := "test" + string(rune('0'+id)) + ".com" + _, err := o.StartPipeline(ctx, domain) + if err != nil { + t.Errorf("StartPipeline failed for %s: %v", domain, err) + } + }(i) + } + wg.Wait() + + // Verify all 10 results exist + results := o.GetAllResults() + if len(results) != 10 { + t.Errorf("expected 10 results, got %d", len(results)) + } + + // Concurrent reads + var readWg sync.WaitGroup + for i := 0; i < 20; i++ { + readWg.Add(1) + go func() { + defer readWg.Done() + o.CountStats() + o.GetAllResults() + }() + } + readWg.Wait() +} + +func TestSendEmailReport_NotFound(t *testing.T) { + o := newTestOrchestrator(t) + err := o.SendEmailReport("nonexistent", "user@example.com") + if err == nil { + t.Fatal("expected error for nonexistent token") + } +} + +func TestSendEmailReport_NotCompleted(t *testing.T) { + o := newTestOrchestrator(t) + ctx := context.Background() + + result, _ := o.StartPipeline(ctx, "example.com") + err := o.SendEmailReport(result.ReportToken, "user@example.com") + if err == nil { + t.Fatal("expected error for non-completed scan") + } +} + +func TestNewOrchestrator_NilDeps(t *testing.T) { + // Should handle nil AI client and nil mailer gracefully + o := NewOrchestrator("/tmp", "https://test.local", (*ai.Client)(nil), (*mailer.Mailer)(nil)) + if o == nil { + t.Fatal("expected non-nil orchestrator") + } +} + +func TestStatusConstants_AreStrings(t *testing.T) { + if StatusPending != "pending" { + t.Errorf("StatusPending should be 'pending', got '%s'", StatusPending) + } + if StatusDiscovering != "discovering" { + t.Errorf("StatusDiscovering should be 'discovering', got '%s'", StatusDiscovering) + } + if StatusAwaitingSelection != "awaiting_selection" { + t.Errorf("StatusAwaitingSelection should be 'awaiting_selection', got '%s'", StatusAwaitingSelection) + } + if StatusScanning != "scanning" { + t.Errorf("StatusScanning should be 'scanning', got '%s'", StatusScanning) + } + if StatusGenerating != "generating" { + t.Errorf("StatusGenerating should be 'generating', got '%s'", StatusGenerating) + } + if StatusCompleted != "completed" { + t.Errorf("StatusCompleted should be 'completed', got '%s'", StatusCompleted) + } + if StatusFailed != "failed" { + t.Errorf("StatusFailed should be 'failed', got '%s'", StatusFailed) + } +} + +func TestOrchestrator_RaceFreeStatusTransition(t *testing.T) { + // Verify the race condition guard: discoverSubdomains must not overwrite + // a status that was already changed by SelectAndScan + o := newTestOrchestrator(t) + ctx := context.Background() + + result, _ := o.StartPipeline(ctx, "example.com") + + // Simulate: discovery finishes and SelectAndScan is called concurrently + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + // discoverSubdomains logic: set discovering → then set awaiting_selection + // but guarded by checking current status is still discovering + o.updateStatus(result.ReportToken, StatusDiscovering) + time.Sleep(10 * time.Millisecond) + + o.mu.Lock() + if o.results[result.ReportToken].Status == StatusDiscovering { + o.results[result.ReportToken].Status = StatusAwaitingSelection + } + o.mu.Unlock() + }() + + go func() { + defer wg.Done() + time.Sleep(5 * time.Millisecond) + // SelectAndScan logic: only works if status is awaiting_selection + o.mu.Lock() + if r, ok := o.results[result.ReportToken]; ok && r.Status == StatusAwaitingSelection { + r.SelectedDomain = "www.example.com" + } + o.mu.Unlock() + }() + + wg.Wait() + + got, _ := o.GetResult(result.ReportToken) + // The status should NOT be discovering — either awaiting_selection or scanning + if got.Status == StatusDiscovering { + t.Error("status should not remain 'discovering' after both goroutines complete") + } +} diff --git a/aasd/src/pkg/netutil/netutil.go b/aasd/src/pkg/netutil/netutil.go new file mode 100644 index 0000000..59ac19e --- /dev/null +++ b/aasd/src/pkg/netutil/netutil.go @@ -0,0 +1,9 @@ +// Package netutil provides general-purpose network utility functions. +package netutil + +import "net" + +// IsIP returns true if the given string is a valid IPv4 or IPv6 address. +func IsIP(input string) bool { + return net.ParseIP(input) != nil +} diff --git a/aasd/src/pkg/netutil/netutil_test.go b/aasd/src/pkg/netutil/netutil_test.go new file mode 100644 index 0000000..e982295 --- /dev/null +++ b/aasd/src/pkg/netutil/netutil_test.go @@ -0,0 +1,48 @@ +package netutil + +import "testing" + +func TestIsIP_IPv4(t *testing.T) { + if !IsIP("192.168.1.1") { + t.Error("expected true for valid IPv4") + } + if !IsIP("10.0.0.1") { + t.Error("expected true for valid IPv4") + } + if !IsIP("0.0.0.0") { + t.Error("expected true for 0.0.0.0") + } + if !IsIP("255.255.255.255") { + t.Error("expected true for 255.255.255.255") + } +} + +func TestIsIP_IPv6(t *testing.T) { + if !IsIP("::1") { + t.Error("expected true for loopback IPv6") + } + if !IsIP("2001:db8::1") { + t.Error("expected true for valid IPv6") + } + if !IsIP("fe80::1") { + t.Error("expected true for link-local IPv6") + } +} + +func TestIsIP_Invalid(t *testing.T) { + if IsIP("") { + t.Error("expected false for empty string") + } + if IsIP("example.com") { + t.Error("expected false for domain name") + } + if IsIP("not-an-ip") { + t.Error("expected false for random string") + } + if IsIP("999.999.999.999") { + t.Error("expected false for invalid octets") + } + if IsIP("192.168.1") { + t.Error("expected false for incomplete IPv4") + } +} diff --git a/aasd/src/static/simulation.html b/aasd/src/static/simulation.html index 8dceaf0..b69526c 100644 --- a/aasd/src/static/simulation.html +++ b/aasd/src/static/simulation.html @@ -22,6 +22,8 @@ .step-indicator { transition: all 0.5s ease; } @keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } .pulsing::after { content: ''; display: inline-block; width: 8px; height: 8px; background: #3b82f6; border-radius: 50%; margin-left: 8px; animation: pulse-dot 1.5s infinite; } + @keyframes barberpole { 0% { background-position: 0 0; } 100% { background-position: 40px 0; } } + .progress-indeterminate { background: linear-gradient(90deg, #7c3aed 30%, #a78bfa 50%, #7c3aed 70%); background-size: 200% 100%; animation: barberpole 1s linear infinite; } @@ -145,6 +147,8 @@ const subdomainList = document.getElementById('subdomainList'); let pollingInterval; + let scanStartTime = null; + let scanTimerInterval = null; // Map API statuses to step indices (for the 3-step progress view) const statusStepMap = { @@ -229,7 +233,17 @@ } // ── Results UI ── + function clearScanTimer() { + if (scanTimerInterval) { + clearInterval(scanTimerInterval); + scanTimerInterval = null; + scanStartTime = null; + } + progressFill.classList.remove('progress-indeterminate'); + } + function showResults(data) { + clearScanTimer(); clearInterval(pollingInterval); stepSequencer.classList.remove('hidden'); selectionPanel.classList.add('hidden'); @@ -247,6 +261,7 @@ } function showFailed() { + clearScanTimer(); clearInterval(pollingInterval); setStatus('Failed', 'bg-red-500'); statusDisplay.className = 'bg-slate-800 rounded-2xl p-5 border border-red-500/50 text-center mb-4'; @@ -301,9 +316,27 @@ } break; case 'scanning': - setStatus('Scanning WAF Endpoint', 'bg-purple-500'); + if (!scanStartTime) { + scanStartTime = Date.now(); + progressFill.classList.add('progress-indeterminate'); + progressFill.style.width = '100%'; + // Update elapsed time every second + scanTimerInterval = setInterval(function() { + var elapsed = Math.floor((Date.now() - scanStartTime) / 1000); + var m = Math.floor(elapsed / 60); + var s = elapsed % 60; + statusText.textContent = 'Scanning WAF Endpoint\u2026 ' + m + ':' + (s < 10 ? '0' : '') + s; + }, 1000); + } + setStatus('Scanning WAF Endpoint\u2026', 'bg-purple-500 animate-pulse'); break; case 'generating': + // Clear scan timer when transitioning out of scanning + if (scanTimerInterval) { + clearInterval(scanTimerInterval); + scanTimerInterval = null; + scanStartTime = null; + } setStatus('Generating AI Report', 'bg-yellow-500'); break; default: