mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user