Skip to content

Commit 059d735

Browse files
authored
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.
1 parent dff4de3 commit 059d735

7 files changed

Lines changed: 142 additions & 86 deletions

File tree

internal/fingerprint/favicon.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,29 @@ func FaviconHash(data []byte) int32 {
3434
return int32(murmur3.Sum32(encoded)) //nolint:gosec // shodan stores the signed reinterpretation on purpose
3535
}
3636

37+
// faviconTech maps a known shodan favicon hash to the tech that ships it.
38+
// these are stable default icons for panels/frameworks/c2; a hit is a strong
39+
// fingerprint. kept small on purpose - high-signal defaults, not an exhaustive db.
40+
var faviconTech = map[int32]string{
41+
116323821: "Apache Tomcat",
42+
81586312: "Spring Boot (default whitelabel)",
43+
-235701012: "Jenkins",
44+
-1255347784: "GitLab",
45+
1278322581: "Grafana",
46+
743365239: "Kibana",
47+
-1462443472: "phpMyAdmin",
48+
999357577: "Cobalt Strike (default beacon)",
49+
-1521704893: "Metasploit",
50+
-1893514588: "Gitea",
51+
}
52+
53+
// LookupFaviconTech returns the tech that ships the given shodan favicon hash and
54+
// whether the hash is known.
55+
func LookupFaviconTech(hash int32) (string, bool) {
56+
tech, ok := faviconTech[hash]
57+
return tech, ok
58+
}
59+
3760
// encodeFaviconBase64 mirrors python's base64.encodebytes: standard base64 with
3861
// a newline inserted every 76 output characters and a trailing newline. this is
3962
// the exact byte stream shodan feeds to mmh3, so it must match byte-for-byte.

internal/fingerprint/favicon_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,41 @@ func TestFaviconHashGolden(t *testing.T) {
4848
}
4949
}
5050

51+
// TestLookupFaviconTech proves the SSOT table is the single place all ten
52+
// facts are resolved: every entry round-trips, an unknown hash misses, and the
53+
// count is pinned so an accidental addition/removal is caught.
54+
func TestLookupFaviconTech(t *testing.T) {
55+
if len(faviconTech) != 10 {
56+
t.Fatalf("faviconTech has %d entries, want 10", len(faviconTech))
57+
}
58+
for hash, want := range faviconTech {
59+
got, ok := LookupFaviconTech(hash)
60+
if !ok {
61+
t.Errorf("LookupFaviconTech(%d) ok = false, want true", hash)
62+
}
63+
if got != want {
64+
t.Errorf("LookupFaviconTech(%d) = %q, want %q", hash, got, want)
65+
}
66+
}
67+
68+
if tech, ok := LookupFaviconTech(0); ok {
69+
t.Errorf("LookupFaviconTech(0) = (%q, true), want (\"\", false)", tech)
70+
}
71+
72+
tests := []struct {
73+
hash int32
74+
want string
75+
}{
76+
{hash: -1255347784, want: "GitLab"},
77+
{hash: 116323821, want: "Apache Tomcat"},
78+
}
79+
for _, tt := range tests {
80+
if got, ok := LookupFaviconTech(tt.hash); !ok || got != tt.want {
81+
t.Errorf("LookupFaviconTech(%d) = (%q, %v), want (%q, true)", tt.hash, got, ok, tt.want)
82+
}
83+
}
84+
}
85+
5186
// TestFaviconBase64Chunking pins the encode step against python's
5287
// base64.encodebytes: a 60-byte input encodes to 80 base64 chars, so it must
5388
// wrap into two newline-terminated lines.

internal/modules/favicon.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ func faviconEvidence(matchers []Matcher, body string) (string, bool) {
5959
if !favicon {
6060
return "", false
6161
}
62-
return fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash([]byte(body))), true
62+
hash := fingerprint.FaviconHash([]byte(body))
63+
if tech, ok := fingerprint.LookupFaviconTech(hash); ok {
64+
return fmt.Sprintf("favicon mmh3=%d tech=%s", hash, tech), true
65+
}
66+
return fmt.Sprintf("favicon mmh3=%d", hash), true
6367
}
6468

6569
// validateMatchers fails favicon matchers that would silently never fire (no

internal/modules/favicon_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"math"
1919
"net/http"
2020
"net/http/httptest"
21+
"path/filepath"
2122
"strings"
2223
"testing"
2324

@@ -116,6 +117,75 @@ func TestFaviconEvidence(t *testing.T) {
116117
}
117118
}
118119

120+
// TestFaviconEvidenceNamesCanonicalTech proves faviconEvidence consults the same
121+
// SSOT table as fingerprint.LookupFaviconTech rather than a private copy: the
122+
// evidence line must always match the format built directly from the shared
123+
// hash + lookup functions, whether or not the fixture hash is canonical.
124+
func TestFaviconEvidenceNamesCanonicalTech(t *testing.T) {
125+
body := string(faviconFixture)
126+
hash := fingerprint.FaviconHash(faviconFixture)
127+
want := fmt.Sprintf("favicon mmh3=%d", hash)
128+
if tech, ok := fingerprint.LookupFaviconTech(hash); ok {
129+
want = fmt.Sprintf("favicon mmh3=%d tech=%s", hash, tech)
130+
}
131+
132+
got, ok := faviconEvidence([]Matcher{{Type: "favicon"}}, body)
133+
if !ok {
134+
t.Fatal("faviconEvidence ok = false, want true")
135+
}
136+
if got != want {
137+
t.Errorf("evidence = %q, want %q", got, want)
138+
}
139+
}
140+
141+
// favicon demo modules must reference a hash from fingerprint.LookupFaviconTech
142+
// that names the service in their filename, so a demo cannot drift from the
143+
// canonical hash->tech table.
144+
func TestFaviconDemoModulesMatchCanonicalMap(t *testing.T) {
145+
matches, err := filepath.Glob("../../modules/info/favicon-*.yaml")
146+
if err != nil {
147+
t.Fatal(err)
148+
}
149+
if len(matches) == 0 {
150+
t.Skip("no favicon demo modules present")
151+
}
152+
153+
for _, path := range matches {
154+
t.Run(filepath.Base(path), func(t *testing.T) {
155+
def, err := ParseYAMLModule(path)
156+
if err != nil {
157+
t.Fatalf("parse: %v", err)
158+
}
159+
if def.HTTP == nil {
160+
t.Fatal("favicon demo is not an http module")
161+
}
162+
163+
var hashes []int64
164+
for _, m := range def.HTTP.Matchers {
165+
if m.Type == "favicon" {
166+
hashes = append(hashes, m.Hash...)
167+
}
168+
}
169+
if len(hashes) == 0 {
170+
t.Fatal("no favicon hash in module")
171+
}
172+
173+
service := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(path), "favicon-"), ".yaml")
174+
for _, h := range hashes {
175+
// hashes are range-checked at parse, so int32(h) is the canonical fold.
176+
tech, ok := fingerprint.LookupFaviconTech(int32(h))
177+
if !ok {
178+
t.Errorf("hash %d is absent from the canonical table; demo references a hash the scanner does not know", h)
179+
continue
180+
}
181+
if !strings.Contains(strings.ToLower(tech), service) {
182+
t.Errorf("hash %d maps to %q, but the file names service %q", h, tech, service)
183+
}
184+
}
185+
})
186+
}
187+
}
188+
119189
func TestValidateMatchers(t *testing.T) {
120190
tests := []struct {
121191
name string

internal/scan/favicon.go

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,6 @@ var faviconLinkRegex = regexp.MustCompile(`(?i)<link[^>]+rel=["'][^"']*icon[^"']
4949
// faviconHrefRegex extracts the href attribute value from a matched link tag.
5050
var faviconHrefRegex = regexp.MustCompile(`(?i)href=["']([^"']+)["']`)
5151

52-
// faviconHashes maps a known shodan favicon hash to the tech that ships it.
53-
// these are stable default icons for panels/frameworks/c2; a hit is a strong
54-
// fingerprint. kept small on purpose - high-signal defaults, not an exhaustive db.
55-
var faviconHashes = map[int32]string{
56-
116323821: "Apache Tomcat",
57-
81586312: "Spring Boot (default whitelabel)",
58-
-235701012: "Jenkins",
59-
-1255347784: "GitLab",
60-
1278322581: "Grafana",
61-
743365239: "Kibana",
62-
-1462443472: "phpMyAdmin",
63-
999357577: "Cobalt Strike (default beacon)",
64-
-1521704893: "Metasploit",
65-
-1893514588: "Gitea",
66-
}
67-
6852
// Favicon fetches the target's favicon, computes the shodan mmh3 hash and matches
6953
// it against the bundled fingerprint map.
7054
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
9175
}
9276

9377
hash := fingerprint.FaviconHash(data)
78+
tech, _ := fingerprint.LookupFaviconTech(hash)
9479
result := &FaviconResult{
9580
FaviconURL: iconURL,
9681
Hash: hash,
97-
Tech: faviconHashes[hash],
82+
Tech: tech,
9883
ShodanQ: fmt.Sprintf("http.favicon.hash:%d", hash),
9984
}
10085

internal/scan/favicon_demo_sync_test.go

Lines changed: 0 additions & 68 deletions
This file was deleted.

internal/scan/favicon_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"strings"
1919
"testing"
2020
"time"
21+
22+
"github.com/vmfunc/sif/internal/fingerprint"
2123
)
2224

2325
// goldenFaviconBytes is a fixed payload long enough to span multiple base64
@@ -61,6 +63,11 @@ func TestFavicon_FetchAndHash(t *testing.T) {
6163
if result.ShodanQ != wantQ {
6264
t.Errorf("ShodanQ = %q, want %q", result.ShodanQ, wantQ)
6365
}
66+
67+
wantTech, _ := fingerprint.LookupFaviconTech(fingerprint.FaviconHash(goldenFaviconBytes))
68+
if result.Tech != wantTech {
69+
t.Errorf("Tech = %q, want %q", result.Tech, wantTech)
70+
}
6471
}
6572

6673
// TestFavicon_LinkFallback covers the <link rel=icon> path when /favicon.ico is

0 commit comments

Comments
 (0)