package nucleitpl import ( "context" "net/http" "net/http/httptest" "strings" "testing" "time" ) // mkTemplate builds a minimal Template in-memory for tests. func mkTemplate(id string, path string, matchers []Matcher, condition string) *Template { return &Template{ ID: id, Info: Info{ Name: "Test " + id, Severity: "high", }, Requests: []HTTPRequest{{ Method: "GET", Path: []string{path}, Matchers: matchers, MatchersCondition: condition, }}, } } func TestExecutor_WordMatch(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = w.Write([]byte("PHP Version 7.4.3 loaded")) })) defer srv.Close() tpl := mkTemplate("test-phpinfo", "{{BaseURL}}/info.php", []Matcher{{Type: "word", Part: "body", Words: []string{"PHP Version"}}}, "") e := NewExecutor(nil, 5*time.Second) matches := e.Run(context.Background(), tpl, srv.URL) if len(matches) != 1 { t.Fatalf("expected 1 match, got %d", len(matches)) } if matches[0].TemplateID != "test-phpinfo" { t.Errorf("wrong template: %s", matches[0].TemplateID) } if !strings.Contains(matches[0].Evidence, "PHP Version") { t.Errorf("evidence missing snippet: %q", matches[0].Evidence) } } func TestExecutor_StatusMatch(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(403) })) defer srv.Close() tpl := mkTemplate("test-403", "{{BaseURL}}/admin", []Matcher{{Type: "status", Status: []int{403, 401}}}, "") e := NewExecutor(nil, 5*time.Second) matches := e.Run(context.Background(), tpl, srv.URL) if len(matches) != 1 { t.Fatalf("expected match, got %d", len(matches)) } } func TestExecutor_ANDCondition(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = w.Write([]byte("admin panel access")) })) defer srv.Close() // Both matchers must fire. tpl := mkTemplate("test-and", "{{BaseURL}}/", []Matcher{ {Type: "word", Part: "body", Words: []string{"admin"}}, {Type: "status", Status: []int{200}}, }, "and") e := NewExecutor(nil, 5*time.Second) matches := e.Run(context.Background(), tpl, srv.URL) if len(matches) != 1 { t.Errorf("expected AND match to fire, got %d", len(matches)) } // If we flip status to something the server doesn't return, AND fails. tpl.Requests[0].Matchers[1].Status = []int{500} matches = e.Run(context.Background(), tpl, srv.URL) if len(matches) != 0 { t.Errorf("AND should fail when one matcher doesn't, got %d", len(matches)) } } func TestExecutor_NegativeMatcher(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = w.Write([]byte("welcome")) })) defer srv.Close() tpl := mkTemplate("test-neg", "{{BaseURL}}/", []Matcher{{Type: "word", Part: "body", Words: []string{"error"}, Negative: true}}, "") e := NewExecutor(nil, 5*time.Second) matches := e.Run(context.Background(), tpl, srv.URL) if len(matches) != 1 { t.Errorf("negative should fire (body doesn't contain 'error'), got %d", len(matches)) } } func TestExecutor_RegexMatch(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = w.Write([]byte("Server: Apache/2.4.52 (Ubuntu)")) })) defer srv.Close() tpl := mkTemplate("test-re", "{{BaseURL}}/", []Matcher{{Type: "regex", Part: "body", Regex: []string{`Apache/\d+\.\d+\.\d+`}}}, "") e := NewExecutor(nil, 5*time.Second) matches := e.Run(context.Background(), tpl, srv.URL) if len(matches) != 1 { t.Errorf("regex match should fire, got %d", len(matches)) } } func TestExecutor_HeaderPart(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Powered-By", "Express") w.WriteHeader(200) })) defer srv.Close() tpl := mkTemplate("test-header", "{{BaseURL}}/", []Matcher{{Type: "word", Part: "header", Words: []string{"X-Powered-By"}}}, "") e := NewExecutor(nil, 5*time.Second) matches := e.Run(context.Background(), tpl, srv.URL) if len(matches) != 1 { t.Errorf("header matcher should fire, got %d", len(matches)) } } func TestExecutor_NoMatchReturnsEmpty(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = w.Write([]byte("nothing interesting")) })) defer srv.Close() tpl := mkTemplate("test-nomatch", "{{BaseURL}}/", []Matcher{{Type: "word", Part: "body", Words: []string{"definitely_not_here"}}}, "") e := NewExecutor(nil, 5*time.Second) matches := e.Run(context.Background(), tpl, srv.URL) if len(matches) != 0 { t.Errorf("non-match should return empty, got %d", len(matches)) } } func TestExpandPath(t *testing.T) { cases := []struct { path, base, want string }{ {"{{BaseURL}}/admin", "https://example.com", "https://example.com/admin"}, {"{{Hostname}}/x", "https://api.example.com/v1", "api.example.com/x"}, {"{{RootURL}}/r", "http://sub.example.com/deep/path", "http://sub.example.com/r"}, {"/static/admin", "https://x.com", "/static/admin"}, } for _, c := range cases { if got := expandPath(c.path, c.base); got != c.want { t.Errorf("expandPath(%q, %q) = %q, want %q", c.path, c.base, got, c.want) } } } func TestExtractCVEs(t *testing.T) { cves := extractCVEs("cve-2021-23017-nginx", []string{ "https://nvd.nist.gov/vuln/detail/CVE-2021-23017", // dup of ID after upper-casing "https://example.com/adv/CVE-2020-15168", }) if len(cves) != 2 { t.Errorf("expected 2 unique CVE IDs, got %d: %v", len(cves), cves) } if cves[0] != "CVE-2021-23017" || cves[1] != "CVE-2020-15168" { t.Errorf("unexpected order: %v", cves) } } func TestExecutor_UnsupportedTemplateNoop(t *testing.T) { tpl := &Template{ ID: "dns-tpl", DNS: []string{"placeholder"}, } e := NewExecutor(nil, 5*time.Second) matches := e.Run(context.Background(), tpl, "https://example.com") if len(matches) != 0 { t.Errorf("unsupported template should return no matches, got %d", len(matches)) } }