fix: email report URL, duplicate case; feat: Home button, email UI, scan timer; test: 44 unit tests; refactor: IsIP to pkg/netutil; docs: SMTP env vars

- Fix email report URL: /{token}.html → /visitor_{token}.html
- Remove duplicate case 'generating' in simulation.html polling
- Add defensive guard against empty Subdomains in SelectAndScan
- Add Home link and Start New Scan button to visitor report
- Replace QR code injection with email-send form in consultant report
- Add scan timer animation (barberpole + elapsed counter) to frontend
- Move IsIP() from scanner/probe.go to pkg/netutil for reuse
- Add 44 unit tests across scanner, report, and netutil packages
- Create Makefile with build/vet/test/deploy targets
- Document SMTP environment variables in README and AGENT.md
This commit is contained in:
administrator 2026-04-29 07:30:42 +00:00
parent 4f533c6c8f
commit b91406ada4
14 changed files with 1074 additions and 34 deletions

View file

@ -103,7 +103,7 @@ executeScanPhase() discoverSubdomains()
- **No persistent storage** — scan results are in-memory only (map), lost on restart - **No persistent storage** — scan results are in-memory only (map), lost on restart
- **Reports are files** — persisted at `/opt/aasd/reports/`, survive restarts - **Reports are files** — persisted at `/opt/aasd/reports/`, survive restarts
- **Config via YAML**`/opt/aasd/config.yaml` for server URL, admin credentials, AI key - **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` - **Gin web framework** — all HTTP routing via `router.POST/GET`
- **Comments in Go code** — use `//` not `/* */` per project style - **Comments in Go code** — use `//` not `/* */` per project style

View file

@ -55,6 +55,8 @@ sudo journalctl -u aasd -f
### Configuration ### Configuration
### config.yaml
Edit `/opt/aasd/config.yaml`: Edit `/opt/aasd/config.yaml`:
```yaml ```yaml
@ -67,6 +69,19 @@ admin:
password: "Git3x2o26" # Admin dashboard password 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 ### Service Management
```bash ```bash
@ -95,8 +110,9 @@ sudo journalctl -u aasd -f # Follow logs
| `GET /scan-status/:token` | Public | Poll status (JSON) | | `GET /scan-status/:token` | Public | Poll status (JSON) |
| `GET /admin-dashboard` | Basic Auth | Consultant dashboard | | `GET /admin-dashboard` | Basic Auth | Consultant dashboard |
| `GET /api/scans` | Public | Scan list (JSON) | | `GET /api/scans` | Public | Scan list (JSON) |
| `GET /reports/visitor_*.html` | Public | Visitor-facing report | | `POST /email-report` | Public | Send report via email (`{"token":"...","email":"..."}`) |
| `GET /reports/consultant_*.html` | Public | GoTestWAF consultant report | | `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 | | `GET /qrcode?text=` | Public | QR code generator |
## Report Types ## Report Types

47
aasd/src/Makefile Normal file
View file

@ -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

View file

@ -27,6 +27,7 @@ import (
"aasd/internal/ai" "aasd/internal/ai"
"aasd/internal/mailer" "aasd/internal/mailer"
"aasd/internal/scanner" "aasd/internal/scanner"
"aasd/pkg/netutil"
) )
// AppConfig holds application-level configuration from config.yaml. // AppConfig holds application-level configuration from config.yaml.
@ -136,7 +137,7 @@ func main() {
} }
// Redirect: IPs skip subdomain selection and go straight to scanning // 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) c.Redirect(http.StatusFound, "/analysing?token="+result.ReportToken)
} else { } else {
c.Redirect(http.StatusFound, "/select-subdomain?token="+result.ReportToken) c.Redirect(http.StatusFound, "/select-subdomain?token="+result.ReportToken)

View file

@ -70,11 +70,15 @@ func (g *Generator) BuildReport(token, domain, aiNarrativeHTML string) (string,
</head> </head>
<body class="bg-slate-900 text-slate-100 min-h-screen"> <body class="bg-slate-900 text-slate-100 min-h-screen">
<div class="container mx-auto px-4 py-6 max-w-lg"> <div class="container mx-auto px-4 py-6 max-w-lg">
<!-- Header --> <!-- Header + Home -->
<div class="text-center mb-6"> <div class="flex items-center justify-between mb-4">
<a href="/" class="text-xs text-slate-500 hover:text-blue-400 transition-colors">&larr; Home</a>
<div class="text-center">
<h1 class="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">AASD</h1> <h1 class="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">AASD</h1>
<p class="text-sm text-slate-400">API Attack Surface Discovery</p> <p class="text-sm text-slate-400">API Attack Surface Discovery</p>
</div> </div>
<div class="w-10"></div> <!-- spacer -->
</div>
<!-- Report Content --> <!-- Report Content -->
%s %s
@ -97,7 +101,14 @@ func (g *Generator) BuildReport(token, domain, aiNarrativeHTML string) (string,
<p class="text-xs text-slate-500 mt-1">Your consultant will walk you through the findings</p> <p class="text-xs text-slate-500 mt-1">Your consultant will walk you through the findings</p>
</div> </div>
<div class="mt-6 text-center text-xs text-slate-600"> <!-- Start New Scan -->
<div class="mt-6 text-center">
<a href="/" class="inline-block w-full bg-gradient-to-r from-blue-500 to-purple-500 text-white font-bold py-4 px-6 rounded-xl text-lg shadow-lg hover:shadow-blue-500/25 active:scale-[0.97] transition-all">
Start New Scan
</a>
</div>
<div class="mt-4 text-center text-xs text-slate-600">
GITEX 2026 sechpoint.app GITEX 2026 sechpoint.app
</div> </div>
</div> </div>

View file

@ -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 := "<p>AI generated content</p>"
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 := "<p>Security analysis results</p>"
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", "<p>content</p>")
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", "<p>content</p>")
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", "<p>content</p>")
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", "<p>content</p>")
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", "<p>content</p>")
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), "<div") {
t.Error("expected HTML to start with a div")
}
if !strings.Contains(html, "</div>") {
t.Error("expected closing div tag")
}
}

View file

@ -14,13 +14,32 @@ const (
) )
// RunGoTestWAF executes GoTestWAF scan against the given target domain. // 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. // The report is saved to reports/ directory with the given reportName.
// Returns the raw output for AI analysis. // Returns the raw output for AI analysis.
func RunGoTestWAF(ctx context.Context, projectRoot string, reportName string, targetDomain string) (string, error) { 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") binaryPath := filepath.Join(projectRoot, "gotestwaf")
// Verify binary exists
if _, err := os.Stat(binaryPath); os.IsNotExist(err) { if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
return "", fmt.Errorf("GoTestWAF binary not found at %s", binaryPath) 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) return "", fmt.Errorf("failed to create reports directory: %w", err)
} }
// Create context with timeout
scanCtx, cancel := context.WithTimeout(ctx, gotestwafTimeout) scanCtx, cancel := context.WithTimeout(ctx, gotestwafTimeout)
defer cancel() defer cancel()
@ -46,7 +64,7 @@ func RunGoTestWAF(ctx context.Context, projectRoot string, reportName string, ta
"--wafName", "generic", "--wafName", "generic",
"--skipWAFBlockCheck", "--skipWAFBlockCheck",
"--nonBlockedAsPassed", "--nonBlockedAsPassed",
"--tlsVerify", "--tlsVerify=false",
"--noEmailReport", "--noEmailReport",
"--quiet", "--quiet",
) )
@ -65,5 +83,3 @@ func RunGoTestWAF(ctx context.Context, projectRoot string, reportName string, ta
return outputStr, nil return outputStr, nil
} }

View file

@ -28,11 +28,6 @@ var DefaultSubdomains = []string{
"ns1", "ns2", "smtp", "imap", "pop3", "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 // hasWildcardDNS checks whether the domain uses wildcard DNS by resolving
// a random non-existent subdomain. If it resolves, wildcard is active. // a random non-existent subdomain. If it resolves, wildcard is active.
func hasWildcardDNS(ctx context.Context, domain string) bool { func hasWildcardDNS(ctx context.Context, domain string) bool {

View file

@ -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)
}
}
}

View file

@ -14,6 +14,7 @@ import (
"aasd/internal/ai" "aasd/internal/ai"
"aasd/internal/mailer" "aasd/internal/mailer"
"aasd/internal/report" "aasd/internal/report"
"aasd/pkg/netutil"
) )
// ScanStatus enumerates the pipeline phases. // ScanStatus enumerates the pipeline phases.
@ -90,7 +91,7 @@ func (o *Orchestrator) StartPipeline(ctx context.Context, domain string) (*ScanR
o.mu.Unlock() o.mu.Unlock()
// Phase 1: Domain Discovery or direct scan // Phase 1: Domain Discovery or direct scan
if IsIP(result.Domain) { if netutil.IsIP(result.Domain) {
// IP address — no subdomains to discover, scan directly. // IP address — no subdomains to discover, scan directly.
// Set Subdomains to [IP] so GenerateFallbackHTML doesn't panic on empty slice. // Set Subdomains to [IP] so GenerateFallbackHTML doesn't panic on empty slice.
result.Subdomains = []string{result.Domain} result.Subdomains = []string{result.Domain}
@ -121,6 +122,13 @@ func (o *Orchestrator) SelectAndScan(ctx context.Context, token, selectedDomain
} }
result.SelectedDomain = 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) go o.executeScanPhase(ctx, result)
return nil return nil
} }
@ -194,21 +202,62 @@ func (o *Orchestrator) executeScanPhase(ctx context.Context, result *ScanResult)
result.AIReportFile = reportPath result.AIReportFile = reportPath
result.ReportFile = "/reports/visitor_" + result.ReportToken + ".html" 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 { if scanErr == nil {
gtwPath := filepath.Join(o.projectRoot, "reports", scanReportName+".html") gtwPath := filepath.Join(o.projectRoot, "reports", scanReportName+".html")
if data, err := os.ReadFile(gtwPath); err == nil { if data, err := os.ReadFile(gtwPath); err == nil {
qrURL := fmt.Sprintf("%s/reports/visitor_%s.html", o.baseURL, result.ReportToken) emailBlock := fmt.Sprintf(`
qrBlock := fmt.Sprintf(` <!-- Email Report Form -->
<!-- Booth QR Code --> <div style="text-align:center; margin-top:32px; padding:24px; background:#f8fafc; border-radius:12px; border:1px solid #e2e8f0; max-width:480px; margin-left:auto; margin-right:auto;">
<div style="text-align:center; margin-top:32px; page-break-before:always;"> <h2 style="font-size:18px; font-weight:700; color:#1e293b; margin-bottom:4px;">Send this report</h2>
<h2 style="font-size:18px; font-weight:700; margin-bottom:12px;">Scan to access your report</h2> <p style="font-size:13px; color:#64748b; margin-bottom:16px;">Enter your email and we'll send you the full report</p>
<img src="/qrcode?text=%s" alt="QR Code" style="width:200px; height:200px; border:2px solid #ddd; border-radius:8px; padding:8px; background:#fff;" loading="lazy"> <input type="email" id="emailInput" placeholder="your@email.com" style="width:100%%; padding:12px 16px; border:2px solid #cbd5e1; border-radius:8px; font-size:15px; box-sizing:border-box; margin-bottom:12px; outline:none;" onfocus="this.style.borderColor='#3b82f6'" onblur="this.style.borderColor='#cbd5e1'">
<p style="font-size:12px; color:#666; margin-top:8px;">Show this code to <strong>Sechpoint Aftica Team</strong> at GITEX 2026</p> <button onclick="sendEmail()" style="width:100%%; padding:12px; background:linear-gradient(90deg, #3b82f6, #8b5cf6); color:white; border:none; border-radius:8px; font-size:15px; font-weight:600; cursor:pointer;">Send Report</button>
</div>`, qrURL) <div id="emailStatus" style="margin-top:10px; font-size:13px; color:#64748b;"></div>
modified := strings.Replace(string(data), "</main>", qrBlock+"\n </main>", 1) </div>
<script>
function sendEmail() {
var btn = event.target;
var email = document.getElementById('emailInput').value.trim();
var status = document.getElementById('emailStatus');
if (!email || !email.includes('@')) {
status.style.color = '#dc2626';
status.textContent = 'Please enter a valid email address.';
return;
}
btn.disabled = true;
btn.textContent = 'Sending...';
status.style.color = '#64748b';
status.textContent = 'Sending...';
fetch('/email-report', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token: '%s', email: email})
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) {
status.style.color = '#dc2626';
status.textContent = 'Error: ' + d.error;
} else {
status.style.color = '#16a34a';
status.textContent = 'Report sent! Check your inbox.';
document.getElementById('emailInput').value = '';
}
})
.catch(function() {
status.style.color = '#dc2626';
status.textContent = 'Network error. Please try again.';
})
.finally(function() {
btn.disabled = false;
btn.textContent = 'Send Report';
});
}
</script>`, result.ReportToken)
modified := strings.Replace(string(data), "</main>", emailBlock+"\n </main>", 1)
if err := os.WriteFile(gtwPath, []byte(modified), 0644); err != nil { 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) return fmt.Errorf("report not ready (status: %s)", result.Status)
} }
reportURL := "/reports/" + token + ".html" reportURL := "/reports/visitor_" + token + ".html"
if o.baseURL != "" { if o.baseURL != "" {
reportURL = o.baseURL + reportURL reportURL = o.baseURL + reportURL
} }

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -22,6 +22,8 @@
.step-indicator { transition: all 0.5s ease; } .step-indicator { transition: all 0.5s ease; }
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } @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; } .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; }
</style> </style>
</head> </head>
<body class="bg-slate-900 text-slate-100 min-h-screen"> <body class="bg-slate-900 text-slate-100 min-h-screen">
@ -145,6 +147,8 @@
const subdomainList = document.getElementById('subdomainList'); const subdomainList = document.getElementById('subdomainList');
let pollingInterval; let pollingInterval;
let scanStartTime = null;
let scanTimerInterval = null;
// Map API statuses to step indices (for the 3-step progress view) // Map API statuses to step indices (for the 3-step progress view)
const statusStepMap = { const statusStepMap = {
@ -229,7 +233,17 @@
} }
// ── Results UI ── // ── Results UI ──
function clearScanTimer() {
if (scanTimerInterval) {
clearInterval(scanTimerInterval);
scanTimerInterval = null;
scanStartTime = null;
}
progressFill.classList.remove('progress-indeterminate');
}
function showResults(data) { function showResults(data) {
clearScanTimer();
clearInterval(pollingInterval); clearInterval(pollingInterval);
stepSequencer.classList.remove('hidden'); stepSequencer.classList.remove('hidden');
selectionPanel.classList.add('hidden'); selectionPanel.classList.add('hidden');
@ -247,6 +261,7 @@
} }
function showFailed() { function showFailed() {
clearScanTimer();
clearInterval(pollingInterval); clearInterval(pollingInterval);
setStatus('Failed', 'bg-red-500'); setStatus('Failed', 'bg-red-500');
statusDisplay.className = 'bg-slate-800 rounded-2xl p-5 border border-red-500/50 text-center mb-4'; statusDisplay.className = 'bg-slate-800 rounded-2xl p-5 border border-red-500/50 text-center mb-4';
@ -301,9 +316,27 @@
} }
break; break;
case 'scanning': 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; break;
case 'generating': 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'); setStatus('Generating AI Report', 'bg-yellow-500');
break; break;
default: default: