diff --git a/internal/scan/frameworks/detect_test.go b/internal/scan/frameworks/detect_test.go index 339031f..f21b106 100644 --- a/internal/scan/frameworks/detect_test.go +++ b/internal/scan/frameworks/detect_test.go @@ -704,3 +704,55 @@ func TestDetectorRegistry(t *testing.T) { } } } + +func TestExtractVersion_Htmx(t *testing.T) { + tests := []struct { + body string + expected string + }{ + {``, "1.9.10"}, + {`https://cdn.jsdelivr.net/npm/htmx@2.0.3/dist/htmx.min.js`, "2.0.3"}, + {`"htmx.org": "^1.9.12"`, "1.9.12"}, + {"no version", "unknown"}, + } + + for _, tt := range tests { + result := frameworks.ExtractVersionOptimized(tt.body, "htmx").Version + if result != tt.expected { + t.Errorf("ExtractVersionOptimized(%q, 'htmx') = %q, want %q", tt.body, result, tt.expected) + } + } +} + +func TestDetectFramework_Htmx(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + + + +
+
+ + + `)) + })) + defer server.Close() + + result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected result, got nil") + } + if result.Name != "htmx" { + t.Errorf("expected framework 'htmx', got '%s'", result.Name) + } + if result.Version != "1.9.10" { + t.Errorf("expected version '1.9.10', got '%s'", result.Version) + } +} diff --git a/internal/scan/frameworks/detectors/frontend.go b/internal/scan/frameworks/detectors/frontend.go index f95ae4c..c4a523e 100644 --- a/internal/scan/frameworks/detectors/frontend.go +++ b/internal/scan/frameworks/detectors/frontend.go @@ -34,6 +34,7 @@ func init() { fw.Register(&emberDetector{}) fw.Register(&backboneDetector{}) fw.Register(&meteorDetector{}) + fw.Register(&htmxDetector{}) } // reactDetector detects React framework. @@ -195,6 +196,34 @@ func (d *backboneDetector) Detect(body string, headers http.Header) (float32, st return confidence, version } +// htmxDetector detects the htmx library. +type htmxDetector struct{} + +func (d *htmxDetector) Name() string { return "htmx" } + +func (d *htmxDetector) Signatures() []fw.Signature { + return []fw.Signature{ + {Pattern: "hx-get", Weight: 0.5}, + {Pattern: "hx-post", Weight: 0.5}, + {Pattern: "hx-swap", Weight: 0.4}, + {Pattern: "hx-target", Weight: 0.4}, + {Pattern: "hx-boost", Weight: 0.4}, + {Pattern: "htmx.org", Weight: 0.5}, + } +} + +func (d *htmxDetector) Detect(body string, headers http.Header) (float32, string) { + base := fw.NewBaseDetector(d.Name(), d.Signatures()) + score := base.MatchSignatures(body, headers) + confidence := sigmoidConfidence(score) + + var version string + if confidence > 0.5 { + version = fw.ExtractVersionOptimized(body, d.Name()).Version + } + return confidence, version +} + // meteorDetector detects Meteor framework. type meteorDetector struct{} diff --git a/internal/scan/frameworks/version.go b/internal/scan/frameworks/version.go index a9e18a5..3481eec 100644 --- a/internal/scan/frameworks/version.go +++ b/internal/scan/frameworks/version.go @@ -107,6 +107,10 @@ func init() { "SvelteKit": { {`"@sveltejs/kit":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"}, }, + "htmx": { + {`htmx(?:\.org)?@(\d+\.\d+(?:\.\d+)?)`, 0.85, "CDN reference"}, + {`"htmx\.org":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"}, + }, "WordPress": { {`