refactor(fingerprint): make the favicon tech table the single source (#358)

* 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.

* 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=<n>
tech=<name>" instead of a bare hash. unknown hashes are unaffected.
This commit is contained in:
Tigah
2026-07-22 12:57:09 -07:00
committed by GitHub
parent dff4de397e
commit 059d7356bf
7 changed files with 142 additions and 86 deletions
+23
View File
@@ -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.
+35
View File
@@ -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.
+5 -1
View File
@@ -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
+70
View File
@@ -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
+2 -17
View File
@@ -49,22 +49,6 @@ var faviconLinkRegex = regexp.MustCompile(`(?i)<link[^>]+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) {
@@ -91,10 +75,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),
}
-68
View File
@@ -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)
}
}
})
}
}
+7
View File
@@ -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 <link rel=icon> path when /favicon.ico is