diff --git a/internal/fingerprint/favicon.go b/internal/fingerprint/favicon.go
index 052351ae..eb37f6a4 100644
--- a/internal/fingerprint/favicon.go
+++ b/internal/fingerprint/favicon.go
@@ -34,6 +34,29 @@ func FaviconHash(data []byte) int32 {
return int32(murmur3.Sum32(encoded)) //nolint:gosec // shodan stores the signed reinterpretation on purpose
}
+// faviconTech maps a known shodan favicon hash to the tech that ships it.
+// these are stable default icons for panels/frameworks/c2; a hit is a strong
+// fingerprint. kept small on purpose - high-signal defaults, not an exhaustive db.
+var faviconTech = map[int32]string{
+ 116323821: "Apache Tomcat",
+ 81586312: "Spring Boot (default whitelabel)",
+ -235701012: "Jenkins",
+ -1255347784: "GitLab",
+ 1278322581: "Grafana",
+ 743365239: "Kibana",
+ -1462443472: "phpMyAdmin",
+ 999357577: "Cobalt Strike (default beacon)",
+ -1521704893: "Metasploit",
+ -1893514588: "Gitea",
+}
+
+// LookupFaviconTech returns the tech that ships the given shodan favicon hash and
+// whether the hash is known.
+func LookupFaviconTech(hash int32) (string, bool) {
+ tech, ok := faviconTech[hash]
+ return tech, ok
+}
+
// encodeFaviconBase64 mirrors python's base64.encodebytes: standard base64 with
// a newline inserted every 76 output characters and a trailing newline. this is
// the exact byte stream shodan feeds to mmh3, so it must match byte-for-byte.
diff --git a/internal/fingerprint/favicon_test.go b/internal/fingerprint/favicon_test.go
index a86b974d..7d988ff6 100644
--- a/internal/fingerprint/favicon_test.go
+++ b/internal/fingerprint/favicon_test.go
@@ -48,6 +48,41 @@ func TestFaviconHashGolden(t *testing.T) {
}
}
+// TestLookupFaviconTech proves the SSOT table is the single place all ten
+// facts are resolved: every entry round-trips, an unknown hash misses, and the
+// count is pinned so an accidental addition/removal is caught.
+func TestLookupFaviconTech(t *testing.T) {
+ if len(faviconTech) != 10 {
+ t.Fatalf("faviconTech has %d entries, want 10", len(faviconTech))
+ }
+ for hash, want := range faviconTech {
+ got, ok := LookupFaviconTech(hash)
+ if !ok {
+ t.Errorf("LookupFaviconTech(%d) ok = false, want true", hash)
+ }
+ if got != want {
+ t.Errorf("LookupFaviconTech(%d) = %q, want %q", hash, got, want)
+ }
+ }
+
+ if tech, ok := LookupFaviconTech(0); ok {
+ t.Errorf("LookupFaviconTech(0) = (%q, true), want (\"\", false)", tech)
+ }
+
+ tests := []struct {
+ hash int32
+ want string
+ }{
+ {hash: -1255347784, want: "GitLab"},
+ {hash: 116323821, want: "Apache Tomcat"},
+ }
+ for _, tt := range tests {
+ if got, ok := LookupFaviconTech(tt.hash); !ok || got != tt.want {
+ t.Errorf("LookupFaviconTech(%d) = (%q, %v), want (%q, true)", tt.hash, got, ok, tt.want)
+ }
+ }
+}
+
// TestFaviconBase64Chunking pins the encode step against python's
// base64.encodebytes: a 60-byte input encodes to 80 base64 chars, so it must
// wrap into two newline-terminated lines.
diff --git a/internal/modules/favicon.go b/internal/modules/favicon.go
index efdb973a..0f6d9119 100644
--- a/internal/modules/favicon.go
+++ b/internal/modules/favicon.go
@@ -59,7 +59,11 @@ func faviconEvidence(matchers []Matcher, body string) (string, bool) {
if !favicon {
return "", false
}
- return fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash([]byte(body))), true
+ hash := fingerprint.FaviconHash([]byte(body))
+ if tech, ok := fingerprint.LookupFaviconTech(hash); ok {
+ return fmt.Sprintf("favicon mmh3=%d tech=%s", hash, tech), true
+ }
+ return fmt.Sprintf("favicon mmh3=%d", hash), true
}
// validateMatchers fails favicon matchers that would silently never fire (no
diff --git a/internal/modules/favicon_test.go b/internal/modules/favicon_test.go
index 6a8910a6..9f23c9a6 100644
--- a/internal/modules/favicon_test.go
+++ b/internal/modules/favicon_test.go
@@ -18,6 +18,7 @@ import (
"math"
"net/http"
"net/http/httptest"
+ "path/filepath"
"strings"
"testing"
@@ -116,6 +117,75 @@ func TestFaviconEvidence(t *testing.T) {
}
}
+// TestFaviconEvidenceNamesCanonicalTech proves faviconEvidence consults the same
+// SSOT table as fingerprint.LookupFaviconTech rather than a private copy: the
+// evidence line must always match the format built directly from the shared
+// hash + lookup functions, whether or not the fixture hash is canonical.
+func TestFaviconEvidenceNamesCanonicalTech(t *testing.T) {
+ body := string(faviconFixture)
+ hash := fingerprint.FaviconHash(faviconFixture)
+ want := fmt.Sprintf("favicon mmh3=%d", hash)
+ if tech, ok := fingerprint.LookupFaviconTech(hash); ok {
+ want = fmt.Sprintf("favicon mmh3=%d tech=%s", hash, tech)
+ }
+
+ got, ok := faviconEvidence([]Matcher{{Type: "favicon"}}, body)
+ if !ok {
+ t.Fatal("faviconEvidence ok = false, want true")
+ }
+ if got != want {
+ t.Errorf("evidence = %q, want %q", got, want)
+ }
+}
+
+// favicon demo modules must reference a hash from fingerprint.LookupFaviconTech
+// that names the service in their filename, so a demo cannot drift from the
+// canonical hash->tech table.
+func TestFaviconDemoModulesMatchCanonicalMap(t *testing.T) {
+ matches, err := filepath.Glob("../../modules/info/favicon-*.yaml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(matches) == 0 {
+ t.Skip("no favicon demo modules present")
+ }
+
+ for _, path := range matches {
+ t.Run(filepath.Base(path), func(t *testing.T) {
+ def, err := ParseYAMLModule(path)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if def.HTTP == nil {
+ t.Fatal("favicon demo is not an http module")
+ }
+
+ var hashes []int64
+ for _, m := range def.HTTP.Matchers {
+ if m.Type == "favicon" {
+ hashes = append(hashes, m.Hash...)
+ }
+ }
+ if len(hashes) == 0 {
+ t.Fatal("no favicon hash in module")
+ }
+
+ service := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(path), "favicon-"), ".yaml")
+ for _, h := range hashes {
+ // hashes are range-checked at parse, so int32(h) is the canonical fold.
+ tech, ok := fingerprint.LookupFaviconTech(int32(h))
+ if !ok {
+ t.Errorf("hash %d is absent from the canonical table; demo references a hash the scanner does not know", h)
+ continue
+ }
+ if !strings.Contains(strings.ToLower(tech), service) {
+ t.Errorf("hash %d maps to %q, but the file names service %q", h, tech, service)
+ }
+ }
+ })
+ }
+}
+
func TestValidateMatchers(t *testing.T) {
tests := []struct {
name string
diff --git a/internal/scan/favicon.go b/internal/scan/favicon.go
index 71af16fd..81ddcb92 100644
--- a/internal/scan/favicon.go
+++ b/internal/scan/favicon.go
@@ -48,22 +48,6 @@ var faviconLinkRegex = regexp.MustCompile(`(?i)]+rel=["'][^"']*icon[^"']
// faviconHrefRegex extracts the href attribute value from a matched link tag.
var faviconHrefRegex = regexp.MustCompile(`(?i)href=["']([^"']+)["']`)
-// faviconHashes maps a known shodan favicon hash to the tech that ships it.
-// these are stable default icons for panels/frameworks/c2; a hit is a strong
-// fingerprint. kept small on purpose - high-signal defaults, not an exhaustive db.
-var faviconHashes = map[int32]string{
- 116323821: "Apache Tomcat",
- 81586312: "Spring Boot (default whitelabel)",
- -235701012: "Jenkins",
- -1255347784: "GitLab",
- 1278322581: "Grafana",
- 743365239: "Kibana",
- -1462443472: "phpMyAdmin",
- 999357577: "Cobalt Strike (default beacon)",
- -1521704893: "Metasploit",
- -1893514588: "Gitea",
-}
-
// Favicon fetches the target's favicon, computes the shodan mmh3 hash and matches
// it against the bundled fingerprint map.
func Favicon(targetURL string, timeout time.Duration, logdir string) (*FaviconResult, error) {
@@ -90,10 +74,11 @@ func Favicon(targetURL string, timeout time.Duration, logdir string) (*FaviconRe
}
hash := fingerprint.FaviconHash(data)
+ tech, _ := fingerprint.LookupFaviconTech(hash)
result := &FaviconResult{
FaviconURL: iconURL,
Hash: hash,
- Tech: faviconHashes[hash],
+ Tech: tech,
ShodanQ: fmt.Sprintf("http.favicon.hash:%d", hash),
}
diff --git a/internal/scan/favicon_demo_sync_test.go b/internal/scan/favicon_demo_sync_test.go
deleted file mode 100644
index 350b71f5..00000000
--- a/internal/scan/favicon_demo_sync_test.go
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
-·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
-: :
-: █▀ █ █▀▀ · Blazing-fast pentesting suite :
-: ▄█ █ █▀ · BSD 3-Clause License :
-: :
-: (c) 2022-2026 vmfunc, xyzeva, :
-: lunchcat alumni & contributors :
-: :
-·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
-*/
-
-package scan
-
-import (
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/vmfunc/sif/internal/modules"
-)
-
-// favicon demo modules must reference a hash from faviconHashes that names the
-// service in their filename, so a demo cannot drift from the scanner's map.
-func TestFaviconDemoModulesMatchCanonicalMap(t *testing.T) {
- matches, err := filepath.Glob("../../modules/info/favicon-*.yaml")
- if err != nil {
- t.Fatal(err)
- }
- if len(matches) == 0 {
- t.Skip("no favicon demo modules present")
- }
-
- for _, path := range matches {
- t.Run(filepath.Base(path), func(t *testing.T) {
- def, err := modules.ParseYAMLModule(path)
- if err != nil {
- t.Fatalf("parse: %v", err)
- }
- if def.HTTP == nil {
- t.Fatal("favicon demo is not an http module")
- }
-
- var hashes []int64
- for _, m := range def.HTTP.Matchers {
- if m.Type == "favicon" {
- hashes = append(hashes, m.Hash...)
- }
- }
- if len(hashes) == 0 {
- t.Fatal("no favicon hash in module")
- }
-
- service := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(path), "favicon-"), ".yaml")
- for _, h := range hashes {
- // hashes are range-checked at parse, so int32(h) is the canonical fold.
- tech, ok := faviconHashes[int32(h)]
- if !ok {
- t.Errorf("hash %d is absent from faviconHashes; demo references a hash the scanner does not know", h)
- continue
- }
- if !strings.Contains(strings.ToLower(tech), service) {
- t.Errorf("hash %d maps to %q, but the file names service %q", h, tech, service)
- }
- }
- })
- }
-}
diff --git a/internal/scan/favicon_test.go b/internal/scan/favicon_test.go
index a06f78fc..b7686672 100644
--- a/internal/scan/favicon_test.go
+++ b/internal/scan/favicon_test.go
@@ -18,6 +18,8 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/vmfunc/sif/internal/fingerprint"
)
// goldenFaviconBytes is a fixed payload long enough to span multiple base64
@@ -61,6 +63,11 @@ func TestFavicon_FetchAndHash(t *testing.T) {
if result.ShodanQ != wantQ {
t.Errorf("ShodanQ = %q, want %q", result.ShodanQ, wantQ)
}
+
+ wantTech, _ := fingerprint.LookupFaviconTech(fingerprint.FaviconHash(goldenFaviconBytes))
+ if result.Tech != wantTech {
+ t.Errorf("Tech = %q, want %q", result.Tech, wantTech)
+ }
}
// TestFavicon_LinkFallback covers the path when /favicon.ico is