From cbb3c0a19d52543438f3e86dbec3ddb5abaaa81d Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:42:31 -0700 Subject: [PATCH 1/2] refactor(fingerprint): make the favicon tech table the single source move the hash->tech map from internal/scan into internal/fingerprint beside the hash function, exposed as LookupFaviconTech. the scan Favicon path now resolves tech through it instead of a private map. drop the demo-sync guard test: it existed only to keep scan's map in sync with the yaml demo module, and that map no longer exists. --- internal/fingerprint/favicon.go | 23 +++++++++ internal/fingerprint/favicon_test.go | 35 +++++++++++++ internal/scan/favicon.go | 19 +------ internal/scan/favicon_demo_sync_test.go | 68 ------------------------- internal/scan/favicon_test.go | 7 +++ 5 files changed, 67 insertions(+), 85 deletions(-) delete mode 100644 internal/scan/favicon_demo_sync_test.go 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/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 From e9c07096269f0dda54db94a85561a0b8f770d3c5 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:44:41 -0700 Subject: [PATCH 2/2] feat(modules): name the matched tech in favicon evidence favicon module findings now read the tech straight from the shared fingerprint table, so a canonical hit reports "favicon mmh3= tech=" instead of a bare hash. unknown hashes are unaffected. --- internal/modules/favicon.go | 6 ++- internal/modules/favicon_test.go | 70 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) 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