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:
parent
4f533c6c8f
commit
b91406ada4
14 changed files with 1074 additions and 34 deletions
2
AGENT.md
2
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
|
||||
|
||||
|
|
|
|||
20
README.md
20
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
|
||||
|
|
|
|||
47
aasd/src/Makefile
Normal file
47
aasd/src/Makefile
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -70,11 +70,15 @@ func (g *Generator) BuildReport(token, domain, aiNarrativeHTML string) (string,
|
|||
</head>
|
||||
<body class="bg-slate-900 text-slate-100 min-h-screen">
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6">
|
||||
<!-- Header + Home -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<a href="/" class="text-xs text-slate-500 hover:text-blue-400 transition-colors">← 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>
|
||||
<p class="text-sm text-slate-400">API Attack Surface Discovery</p>
|
||||
</div>
|
||||
<div class="w-10"></div> <!-- spacer -->
|
||||
</div>
|
||||
|
||||
<!-- Report Content -->
|
||||
%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>
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
309
aasd/src/internal/report/report_test.go
Normal file
309
aasd/src/internal/report/report_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
101
aasd/src/internal/scanner/probe_test.go
Normal file
101
aasd/src/internal/scanner/probe_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(`
|
||||
<!-- Booth QR Code -->
|
||||
<div style="text-align:center; margin-top:32px; page-break-before:always;">
|
||||
<h2 style="font-size:18px; font-weight:700; margin-bottom:12px;">Scan to access your report</h2>
|
||||
<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">
|
||||
<p style="font-size:12px; color:#666; margin-top:8px;">Show this code to <strong>Sechpoint Aftica Team</strong> at GITEX 2026</p>
|
||||
</div>`, qrURL)
|
||||
modified := strings.Replace(string(data), "</main>", qrBlock+"\n </main>", 1)
|
||||
emailBlock := fmt.Sprintf(`
|
||||
<!-- Email Report Form -->
|
||||
<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;">
|
||||
<h2 style="font-size:18px; font-weight:700; color:#1e293b; margin-bottom:4px;">Send this report</h2>
|
||||
<p style="font-size:13px; color:#64748b; margin-bottom:16px;">Enter your email and we'll send you the full report</p>
|
||||
<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'">
|
||||
<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 id="emailStatus" style="margin-top:10px; font-size:13px; color:#64748b;"></div>
|
||||
</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 {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
405
aasd/src/internal/scanner/scanner_test.go
Normal file
405
aasd/src/internal/scanner/scanner_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
9
aasd/src/pkg/netutil/netutil.go
Normal file
9
aasd/src/pkg/netutil/netutil.go
Normal 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
|
||||
}
|
||||
48
aasd/src/pkg/netutil/netutil_test.go
Normal file
48
aasd/src/pkg/netutil/netutil_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-900 text-slate-100 min-h-screen">
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue