From ab731d05626900fbc32e0c89b6b668dc7928705d Mon Sep 17 00:00:00 2001 From: vmfunc Date: Wed, 10 Jun 2026 15:46:59 -0700 Subject: [PATCH] feat(scan): add jwt, openapi and favicon-hash scanners jwt fetches the target once then analyzes every harvested token offline: flags alg:none, the rs256->hs256 confusion surface, missing/expired exp and plaintext sensitive claims, and cracks a small bundled weak-hmac list. openapi probes the conventional spec paths, parses json/yaml and enumerates paths plus unauthenticated operations. favicon computes the shodan-style mmh3 hash (python base64.encodebytes chunking, signed int32) for tech fingerprinting and the http.favicon.hash pivot, pinned by a golden test. --- README.md | 3 + docs/usage.md | 26 ++ go.mod | 2 +- internal/config/config.go | 6 + internal/finding/finding.go | 66 ++++++ internal/finding/finding_test.go | 31 ++- internal/scan/favicon.go | 254 ++++++++++++++++++++ internal/scan/favicon_test.go | 160 +++++++++++++ internal/scan/jwt.go | 396 +++++++++++++++++++++++++++++++ internal/scan/jwt_test.go | 172 ++++++++++++++ internal/scan/openapi.go | 322 +++++++++++++++++++++++++ internal/scan/openapi_test.go | 210 ++++++++++++++++ man/sif.1 | 9 + sif.go | 30 +++ 14 files changed, 1685 insertions(+), 2 deletions(-) create mode 100644 internal/scan/favicon.go create mode 100644 internal/scan/favicon_test.go create mode 100644 internal/scan/jwt.go create mode 100644 internal/scan/jwt_test.go create mode 100644 internal/scan/openapi.go create mode 100644 internal/scan/openapi_test.go diff --git a/README.md b/README.md index 4bf881a..a493d38 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,9 @@ sif has a modular architecture. modules are defined in yaml and can be extended | `-securitytrails` | domain discovery + target expansion (requires SECURITYTRAILS_API_KEY) | | `-sql` | sql recon | | `-lfi` | local file inclusion | +| `-jwt` | jwt discovery + offline weakness analysis (alg:none, weak hmac, exp, sensitive claims) | +| `-openapi` | openapi/swagger spec exposure probe (enumerates paths + unauth endpoints) | +| `-favicon` | favicon hash fingerprinting (shodan-style mmh3, tech match + pivot query) | | `-cors` | cors misconfiguration probe | | `-redirect` | open redirect probe | | `-xss` | reflected xss probe | diff --git a/docs/usage.md b/docs/usage.md index 3810cd8..8325e75 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -231,6 +231,32 @@ export SHODAN_API_KEY=your-api-key ./sif -u https://example.com/search?q=test -xss ``` +### jwt analysis + +`-jwt` - fetch the target once, harvest jwts from response headers, cookies and body, then analyze each one entirely offline + +flags alg:none, the rs256->hs256 confusion surface, missing/expired exp, plaintext sensitive claims, and cracks a small bundled weak-hmac wordlist. no token is ever sent off-box. + +```bash +./sif -u https://example.com -jwt +``` + +### openapi/swagger exposure + +`-openapi` - probe the conventional spec paths (`/swagger.json`, `/openapi.json`, `/v3/api-docs`, ...), parse the first hit (json or yaml) and enumerate every path+method, flagging operations with no security requirement + +```bash +./sif -u https://example.com -openapi +``` + +### favicon fingerprint + +`-favicon` - fetch `/favicon.ico` (or the declared ``), compute the shodan-style mmh3 hash, match it against a bundled tech map and print the `http.favicon.hash:` pivot query + +```bash +./sif -u https://example.com -favicon +``` + ### framework detection `-framework` - detect web frameworks with version and cve lookup diff --git a/go.mod b/go.mod index 14b15d1..33e86ea 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/projectdiscovery/retryabledns v1.0.114 github.com/projectdiscovery/utils v0.10.1 github.com/rocketlaunchr/google-search v1.1.6 + github.com/spaolacci/murmur3 v1.1.0 golang.org/x/net v0.53.0 golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 @@ -312,7 +313,6 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect - github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.9.2 // indirect github.com/syndtr/goleveldb v1.0.0 // indirect diff --git a/internal/config/config.go b/internal/config/config.go index e1a67cd..e0f7583 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -55,6 +55,9 @@ type Settings struct { SecurityTrails bool SQL bool LFI bool + JWT bool + OpenAPI bool + Favicon bool CORS bool Redirect bool XSS bool @@ -139,6 +142,9 @@ func Parse() *Settings { flagSet.BoolVar(&settings.SecurityTrails, "securitytrails", false, "Enable SecurityTrails domain discovery (requires SECURITYTRAILS_API_KEY env var)"), flagSet.BoolVar(&settings.SQL, "sql", false, "Enable SQL reconnaissance (admin panels, error disclosure)"), flagSet.BoolVar(&settings.LFI, "lfi", false, "Enable LFI (Local File Inclusion) reconnaissance"), + flagSet.BoolVar(&settings.JWT, "jwt", false, "Enable JWT discovery + offline weakness analysis"), + flagSet.BoolVar(&settings.OpenAPI, "openapi", false, "Enable OpenAPI/Swagger spec exposure probe"), + flagSet.BoolVar(&settings.Favicon, "favicon", false, "Enable favicon hash fingerprinting (shodan-style)"), flagSet.BoolVar(&settings.CORS, "cors", false, "Enable CORS misconfiguration probe"), flagSet.BoolVar(&settings.Redirect, "redirect", false, "Enable open redirect probe"), flagSet.BoolVar(&settings.XSS, "xss", false, "Enable reflected XSS probe"), diff --git a/internal/finding/finding.go b/internal/finding/finding.go index b917829..882ef42 100644 --- a/internal/finding/finding.go +++ b/internal/finding/finding.go @@ -89,6 +89,12 @@ func Flatten(target, module string, result any) []Finding { return flattenSQL(target, r) case *scan.LFIResult: return flattenLFI(target, r) + case *scan.JWTResult: + return flattenJWT(target, r) + case *scan.OpenAPIResult: + return flattenOpenAPI(target, r) + case *scan.FaviconResult: + return flattenFavicon(target, r) case *scan.CMSResult: return flattenCMS(target, r) case *scan.SecurityTrailsResult: @@ -242,6 +248,66 @@ func flattenLFI(target string, r *scan.LFIResult) []Finding { return out } +func flattenJWT(target string, r *scan.JWTResult) []Finding { + if r == nil { + return nil + } + out := make([]Finding, 0, len(r.Tokens)) + for i := 0; i < len(r.Tokens); i++ { + t := r.Tokens[i] + // one finding per weakness, not per token: a token with alg:none and a + // weak key is two distinct issues a consumer wants to diff separately. + for j := 0; j < len(t.Issues); j++ { + iss := t.Issues[j] + out = append(out, Finding{ + Target: target, + Module: "jwt", + Severity: ParseSeverity(iss.Severity), + Key: key("jwt", t.Source+":"+iss.Kind), + Title: "jwt " + iss.Kind, + Raw: iss.Detail, + }) + } + } + return out +} + +func flattenOpenAPI(target string, r *scan.OpenAPIResult) []Finding { + if r == nil { + return nil + } + return []Finding{{ + Target: target, + Module: "openapi", + Severity: ParseSeverity(r.Severity), + Key: key("openapi", r.SpecURL), + Title: "openapi spec exposed", + Raw: fmt.Sprintf("%s (%d endpoints)", r.SpecURL, len(r.Endpoints)), + }} +} + +func flattenFavicon(target string, r *scan.FaviconResult) []Finding { + if r == nil { + return nil + } + // a matched fingerprint is a real signal; an unmatched hash is just inventory + // (still useful as a shodan pivot, so we keep it at recon). + sev := sevRecon + title := fmt.Sprintf("favicon hash %d", r.Hash) + if r.Tech != "" { + sev = SeverityLow + title = r.Tech + " (favicon)" + } + return []Finding{{ + Target: target, + Module: "favicon", + Severity: sev, + Key: key("favicon", fmt.Sprintf("%d", r.Hash)), + Title: title, + Raw: r.ShodanQ, + }} +} + func flattenCMS(target string, r *scan.CMSResult) []Finding { if r == nil || r.Name == "" { return nil diff --git a/internal/finding/finding_test.go b/internal/finding/finding_test.go index 596fcf5..68b3e80 100644 --- a/internal/finding/finding_test.go +++ b/internal/finding/finding_test.go @@ -72,6 +72,35 @@ func coverageCases() []coverageCase { module: "lfi", wantItems: 1, }, + { + value: &scan.JWTResult{Tokens: []scan.JWTToken{{ + Source: "header:Authorization", + Alg: "none", + Issues: []scan.JWTIssue{ + {Kind: "alg:none", Severity: "critical", Detail: "no signature"}, + {Kind: "missing exp", Severity: "medium", Detail: "no expiry"}, + }, + }}}, + typed: &scan.JWTResult{}, + module: "jwt", + wantItems: 2, + }, + { + value: &scan.OpenAPIResult{ + SpecURL: "http://x/openapi.json", + Severity: "high", + Endpoints: []scan.OpenAPIEndpoint{{Path: "/users", Method: "GET", Unauth: true}}, + }, + typed: &scan.OpenAPIResult{}, + module: "openapi", + wantItems: 1, + }, + { + value: &scan.FaviconResult{Hash: 116323821, Tech: "Apache Tomcat", ShodanQ: "http.favicon.hash:116323821"}, + typed: &scan.FaviconResult{}, + module: "favicon", + wantItems: 1, + }, { value: &scan.CMSResult{Name: "WordPress", Version: "6.1"}, typed: &scan.CMSResult{}, @@ -245,7 +274,7 @@ func TestEveryResultTypeIsInCoverageTable(t *testing.T) { // lockstep with the ScanResult implementers; a missing entry means the table // (and very likely Flatten) skipped a scanner. want := []string{ - "shodan", "sql", "lfi", "cms", "securitytrails", + "shodan", "sql", "lfi", "jwt", "openapi", "favicon", "cms", "securitytrails", "cors", "redirect", "xss", "crawl", "passive", "probe", "headers", "security_headers", "dirlist", "cloudstorage", "dork", "subdomain_takeover", "framework", "js", "custom-mod", diff --git a/internal/scan/favicon.go b/internal/scan/favicon.go new file mode 100644 index 0000000..7e80f19 --- /dev/null +++ b/internal/scan/favicon.go @@ -0,0 +1,254 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/dropalldatabases/sif/internal/httpx" + "github.com/dropalldatabases/sif/internal/logger" + "github.com/dropalldatabases/sif/internal/output" + "github.com/spaolacci/murmur3" +) + +// FaviconResult is the computed shodan-style favicon hash plus the pivot query +// and any matched tech. +type FaviconResult struct { + FaviconURL string `json:"favicon_url"` // where the icon was fetched + Hash int32 `json:"hash"` // shodan mmh3 hash (signed int32) + Tech string `json:"tech"` // matched technology, empty when unknown + ShodanQ string `json:"shodan_query"` +} + +// faviconBodyReadCap bounds the icon read. real favicons are tens of kilobytes; +// a megabyte ceiling covers oversized ones without letting a hostile endpoint +// stream forever. +const faviconBodyReadCap = 1 << 20 + +// b64LineLen is python's base64.encodebytes line width. mmh3/shodan hash the +// chunked base64 (newline every 76 chars, trailing newline), so we must wrap at +// exactly this width to land on the same hash. +const b64LineLen = 76 + +// faviconLinkRegex pulls the href off a tag so we can +// fall back to a declared icon when /favicon.ico is absent. +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) { + log := output.Module("FAVICON") + log.Start() + + sanitizedURL := stripScheme(targetURL) + + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "Favicon hash fingerprint"); err != nil { + log.Error("error creating log file: %v", err) + return nil, fmt.Errorf("create favicon log: %w", err) + } + } + + client := httpx.Client(timeout) + base := strings.TrimRight(targetURL, "/") + + iconURL, data, err := fetchFavicon(client, base) + if err != nil { + log.Info("no favicon found: %v", err) + log.Complete(0, "found") + return nil, nil //nolint:nilnil // a missing favicon is not an error + } + + hash := FaviconHash(data) + result := &FaviconResult{ + FaviconURL: iconURL, + Hash: hash, + Tech: faviconHashes[hash], + ShodanQ: fmt.Sprintf("http.favicon.hash:%d", hash), + } + + if result.Tech != "" { + log.Warn("favicon hash %d matches %s", hash, output.Highlight.Render(result.Tech)) + } else { + log.Info("favicon hash %d (no fingerprint match)", hash) + } + log.Info("shodan pivot: %s", output.Highlight.Render(result.ShodanQ)) + + if logdir != "" { + _ = logger.Write(sanitizedURL, logdir, + fmt.Sprintf("Favicon %s hash=%d tech=%q query=%s\n", iconURL, hash, result.Tech, result.ShodanQ)) + } + + log.Complete(1, "hashed") + return result, nil +} + +// fetchFavicon tries /favicon.ico first, then the declared in the +// homepage html. it returns the url it pulled the bytes from so the report shows +// exactly which icon was hashed. +func fetchFavicon(client *http.Client, base string) (string, []byte, error) { + iconURL := base + "/favicon.ico" + if data, err := getFaviconBytes(client, iconURL); err == nil { + return iconURL, data, nil + } + + // no /favicon.ico; parse the homepage for a declared icon link. + href, err := declaredFaviconHref(client, base) + if err != nil { + return "", nil, err + } + iconURL = resolveFaviconURL(base, href) + data, err := getFaviconBytes(client, iconURL) + if err != nil { + return "", nil, err + } + return iconURL, data, nil +} + +// getFaviconBytes GETs an icon url and returns the body, erroring on a non-200 or +// an empty body so a soft-404 html page isn't hashed as if it were an icon. +func getFaviconBytes(client *http.Client, iconURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, iconURL, http.NoBody) + if err != nil { + return nil, fmt.Errorf("build favicon request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch favicon: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("favicon status %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, faviconBodyReadCap)) + if err != nil { + return nil, fmt.Errorf("read favicon: %w", err) + } + if len(data) == 0 { + return nil, fmt.Errorf("empty favicon body") + } + return data, nil +} + +// declaredFaviconHref fetches the homepage and extracts the href of the first +// tag. +func declaredFaviconHref(client *http.Client, base string) (string, error) { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, base, http.NoBody) + if err != nil { + return "", fmt.Errorf("build homepage request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fetch homepage: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, faviconBodyReadCap)) + if err != nil { + return "", fmt.Errorf("read homepage: %w", err) + } + + link := faviconLinkRegex.Find(body) + if link == nil { + return "", fmt.Errorf("no favicon link in homepage") + } + href := faviconHrefRegex.FindSubmatch(link) + if href == nil { + return "", fmt.Errorf("favicon link has no href") + } + return string(href[1]), nil +} + +// resolveFaviconURL turns a possibly-relative href into an absolute url against +// the target base. an absolute href is returned as-is. +func resolveFaviconURL(base, href string) string { + if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { + return href + } + if strings.HasPrefix(href, "//") { + // scheme-relative; inherit the base scheme. + scheme := "https:" + if strings.HasPrefix(base, "http://") { + scheme = "http:" + } + return scheme + href + } + if strings.HasPrefix(href, "/") { + return base + href + } + return base + "/" + href +} + +// FaviconHash computes shodan's favicon hash: murmur3 32-bit over the python +// base64.encodebytes encoding of the raw icon (newline every 76 chars plus a +// trailing newline), reinterpreted as a signed int32. the chunking and the sign +// are both load-bearing - shodan stores the value python's mmh3.hash() returns, +// which is signed, over the wrapped base64, not the raw bytes. the golden test +// pins this exactly. +func FaviconHash(data []byte) int32 { + encoded := encodeFaviconBase64(data) + return int32(murmur3.Sum32(encoded)) //nolint:gosec // shodan stores the signed reinterpretation on purpose +} + +// 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. +func encodeFaviconBase64(data []byte) []byte { + raw := base64.StdEncoding.EncodeToString(data) + + var b strings.Builder + // final size: the base64 body plus one '\n' per (full or partial) 76-char + // line. preallocate so the builder never regrows mid-loop. + b.Grow(len(raw) + len(raw)/b64LineLen + 1) + for i := 0; i < len(raw); i += b64LineLen { + end := i + b64LineLen + if end > len(raw) { + end = len(raw) + } + b.WriteString(raw[i:end]) + b.WriteByte('\n') + } + return []byte(b.String()) +} + +// ResultType identifies favicon findings for the result registry. +func (r *FaviconResult) ResultType() string { return "favicon" } + +var _ ScanResult = (*FaviconResult)(nil) diff --git a/internal/scan/favicon_test.go b/internal/scan/favicon_test.go new file mode 100644 index 0000000..d5d3bd3 --- /dev/null +++ b/internal/scan/favicon_test.go @@ -0,0 +1,160 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// goldenFaviconBytes is a fixed payload long enough to span multiple base64 +// lines, so the python-style 76-char chunking is actually exercised by the hash. +var goldenFaviconBytes = []byte(strings.Repeat("sif-favicon-golden-test-bytes-", 8)) + +// goldenFaviconHash is the shodan mmh3 hash of goldenFaviconBytes. it is pinned: +// the value comes from feeding the python base64.encodebytes byte stream (newline +// every 76 chars + trailing newline) through murmur3-32 and reinterpreting the +// result as a signed int32 - exactly what shodan stores. if the chunking or the +// signedness regress, this number changes and the test fails. +const goldenFaviconHash int32 = -1554620260 + +// goldenHelloHash pins a short single-line case so a regression in the trailing +// newline (which the small case still has) is caught independently. +const goldenHelloHash int32 = 1155597304 + +func TestFaviconHash_Golden(t *testing.T) { + tests := []struct { + name string + in []byte + want int32 + }{ + {name: "multi-line fixture", in: goldenFaviconBytes, want: goldenFaviconHash}, + {name: "single-line hello", in: []byte("hello"), want: goldenHelloHash}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FaviconHash(tt.in) + if got != tt.want { + t.Errorf("FaviconHash = %d, want %d", got, tt.want) + } + }) + } +} + +// TestFaviconBase64Chunking pins the encode step against python's +// base64.encodebytes: a 50-byte input encodes to >76 base64 chars, so it must +// wrap into two newline-terminated lines. +func TestFaviconBase64Chunking(t *testing.T) { + in := []byte(strings.Repeat("A", 60)) // 60 bytes -> 80 base64 chars -> two lines + got := string(encodeFaviconBase64(in)) + + lines := strings.Split(strings.TrimRight(got, "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 wrapped lines, got %d: %q", len(lines), got) + } + if len(lines[0]) != b64LineLen { + t.Errorf("first line = %d chars, want %d", len(lines[0]), b64LineLen) + } + if !strings.HasSuffix(got, "\n") { + t.Errorf("encoding must end in a trailing newline, got %q", got) + } +} + +// fixtureFaviconServer serves the golden bytes at /favicon.ico. +func fixtureFaviconServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/favicon.ico" { + w.Header().Set("Content-Type", "image/x-icon") + _, _ = w.Write(goldenFaviconBytes) + return + } + w.WriteHeader(http.StatusNotFound) + })) +} + +func TestFavicon_FetchAndHash(t *testing.T) { + srv := fixtureFaviconServer() + defer srv.Close() + + result, err := Favicon(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("Favicon: %v", err) + } + if result == nil { + t.Fatal("expected a favicon result, got nil") + } + if result.Hash != goldenFaviconHash { + t.Errorf("Hash = %d, want %d", result.Hash, goldenFaviconHash) + } + wantQ := "http.favicon.hash:-1554620260" + if result.ShodanQ != wantQ { + t.Errorf("ShodanQ = %q, want %q", result.ShodanQ, wantQ) + } +} + +// TestFavicon_LinkFallback covers the path when /favicon.ico is +// absent: the homepage points at /static/icon.png and that's what gets hashed. +func TestFavicon_LinkFallback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/favicon.ico": + w.WriteHeader(http.StatusNotFound) + case "/static/icon.png": + _, _ = w.Write(goldenFaviconBytes) + default: + _, _ = w.Write([]byte(``)) + } + })) + defer srv.Close() + + result, err := Favicon(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("Favicon: %v", err) + } + if result == nil { + t.Fatal("expected a favicon result via link fallback, got nil") + } + if !strings.HasSuffix(result.FaviconURL, "/static/icon.png") { + t.Errorf("FaviconURL = %q, want it to end in /static/icon.png", result.FaviconURL) + } + if result.Hash != goldenFaviconHash { + t.Errorf("Hash = %d, want %d", result.Hash, goldenFaviconHash) + } +} + +// TestFavicon_NoIcon confirms a target with no favicon at all yields no result +// and no error. +func TestFavicon_NoIcon(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + result, err := Favicon(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("Favicon: %v", err) + } + if result != nil { + t.Errorf("expected nil result for missing favicon, got %+v", result) + } +} + +func TestFaviconResult_ResultType(t *testing.T) { + r := &FaviconResult{} + if r.ResultType() != "favicon" { + t.Errorf("expected result type 'favicon', got %q", r.ResultType()) + } +} diff --git a/internal/scan/jwt.go b/internal/scan/jwt.go new file mode 100644 index 0000000..371052f --- /dev/null +++ b/internal/scan/jwt.go @@ -0,0 +1,396 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/dropalldatabases/sif/internal/httpx" + "github.com/dropalldatabases/sif/internal/logger" + "github.com/dropalldatabases/sif/internal/output" +) + +// JWTResult collects every token discovered on the target plus the offline +// analysis of each one. +type JWTResult struct { + Tokens []JWTToken `json:"tokens,omitempty"` +} + +// JWTToken is one decoded jwt and the weaknesses found in it. Token is trimmed +// to a short prefix so we never log a full credential. +type JWTToken struct { + Source string `json:"source"` // where we found it (header name / cookie / body) + Preview string `json:"preview"` // first chars of the raw token, never the whole thing + Alg string `json:"alg"` // header alg claim + Issues []JWTIssue `json:"issues"` // the weaknesses, ranked + Claims map[string]any `json:"claims"` // decoded payload (for reporting) + WeakKey string `json:"weak_key"` // cracked hmac secret, empty when none +} + +// JWTIssue is a single weakness with a severity so the report layer can rank it. +type JWTIssue struct { + Kind string `json:"kind"` + Severity string `json:"severity"` + Detail string `json:"detail"` +} + +// jwtBodyReadCap bounds how much of the response body we slurp looking for +// tokens; a jwt riding in the body is near the top, so a megabyte is plenty +// without letting a huge response exhaust memory. +const jwtBodyReadCap = 1 << 20 + +// jwtPreviewLen is how many leading characters of a token we keep for evidence. +// enough to identify the token in a report, short enough to never be the whole +// credential. +const jwtPreviewLen = 16 + +// the three structural jwt severities. +const ( + jwtSevCritical = "critical" + jwtSevHigh = "high" + jwtSevMedium = "medium" + jwtSevLow = "low" +) + +// jwtRegex matches a compact-serialization jwt: three base64url segments split +// by dots. the header always starts "eyJ" (base64url of `{"`), which anchors the +// match and keeps it from firing on arbitrary dotted tokens. +var jwtRegex = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*`) + +// jwtWeakSecrets is a tiny offline wordlist of hmac secrets seen in tutorials, +// boilerplate and leaked configs. cracking one means anyone can forge tokens, so +// a hit is critical. kept short on purpose - this is a smoke test, not john. +var jwtWeakSecrets = []string{ + "secret", "secretkey", "secret_key", "your-256-bit-secret", + "changeme", "password", "jwt", "jwtsecret", "key", "test", + "admin", "supersecret", "s3cr3t", "qwerty", "123456", +} + +// sensitiveClaimKeys are payload fields that should never travel in a readable +// jwt body (the payload is only base64, not encrypted). a match is a disclosure. +var sensitiveClaimKeys = []string{ + "password", "passwd", "secret", "api_key", "apikey", "ssn", + "credit_card", "card_number", "private_key", "access_key", +} + +// JWT fetches the target once, harvests every jwt from the response headers, +// cookies and body, then analyzes each one entirely offline. +func JWT(targetURL string, timeout time.Duration, logdir string) (*JWTResult, error) { + log := output.Module("JWT") + log.Start() + + sanitizedURL := stripScheme(targetURL) + + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "JWT discovery + offline analysis"); err != nil { + log.Error("error creating log file: %v", err) + return nil, fmt.Errorf("create jwt log: %w", err) + } + } + + client := httpx.Client(timeout) + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, targetURL, http.NoBody) + if err != nil { + return nil, fmt.Errorf("build jwt request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch jwt target: %w", err) + } + defer resp.Body.Close() + + // one read, capped; everything past this point is offline. + body, err := io.ReadAll(io.LimitReader(resp.Body, jwtBodyReadCap)) + if err != nil { + return nil, fmt.Errorf("read jwt body: %w", err) + } + + raws := harvestJWTs(resp, string(body)) + if len(raws) == 0 { + log.Info("no jwts found on target") + log.Complete(0, "found") + return nil, nil //nolint:nilnil // absence of a token is not an error + } + + result := &JWTResult{Tokens: make([]JWTToken, 0, len(raws))} + for _, hit := range raws { + token, ok := analyzeJWT(hit.source, hit.raw) + if !ok { + continue + } + result.Tokens = append(result.Tokens, token) + + for i := 0; i < len(token.Issues); i++ { + iss := token.Issues[i] + log.Warn("jwt %s: %s (%s)", renderJWTSeverity(iss.Severity), iss.Kind, hit.source) + if logdir != "" { + _ = logger.Write(sanitizedURL, logdir, + fmt.Sprintf("JWT %s: %s - %s [%s]\n", iss.Severity, iss.Kind, iss.Detail, hit.source)) + } + } + } + + if len(result.Tokens) == 0 { + log.Complete(0, "found") + return nil, nil //nolint:nilnil // tokens were malformed, nothing to report + } + + log.Complete(len(result.Tokens), "analyzed") + return result, nil +} + +// jwtHit ties a raw token to where it came from so the report can attribute it. +type jwtHit struct { + source string + raw string +} + +// harvestJWTs pulls every jwt out of the response: Authorization-style headers, +// Set-Cookie values and the body. dedup keys on the raw token so the same value +// echoed in two places is reported once. +func harvestJWTs(resp *http.Response, body string) []jwtHit { + seen := make(map[string]struct{}) + var hits []jwtHit + + add := func(source, raw string) { + if _, ok := seen[raw]; ok { + return + } + seen[raw] = struct{}{} + hits = append(hits, jwtHit{source: source, raw: raw}) + } + + for name, values := range resp.Header { + for i := 0; i < len(values); i++ { + for _, m := range jwtRegex.FindAllString(values[i], -1) { + add("header:"+name, m) + } + } + } + for _, c := range resp.Cookies() { + for _, m := range jwtRegex.FindAllString(c.Value, -1) { + add("cookie:"+c.Name, m) + } + } + for _, m := range jwtRegex.FindAllString(body, -1) { + add("body", m) + } + + return hits +} + +// analyzeJWT decodes the header and payload (offline base64url, never verifying a +// signature against the network) and runs every weakness check. ok is false when +// the token doesn't decode into a real header+payload, so junk that matched the +// regex is dropped rather than reported. +func analyzeJWT(source, raw string) (JWTToken, bool) { + parts := strings.Split(raw, ".") + if len(parts) != 3 { + return JWTToken{}, false + } + + header, err := decodeJWTSegment(parts[0]) + if err != nil { + return JWTToken{}, false + } + payload, err := decodeJWTSegment(parts[1]) + if err != nil { + return JWTToken{}, false + } + + alg, _ := header["alg"].(string) + + token := JWTToken{ + Source: source, + Preview: previewToken(raw), + Alg: alg, + Claims: payload, + } + + token.Issues = append(token.Issues, jwtAlgIssues(alg)...) + token.Issues = append(token.Issues, jwtClaimIssues(payload)...) + + // only bother cracking when the alg is actually hmac; an asymmetric token + // has no shared secret to guess. + if isHMACAlg(alg) { + if secret, ok := crackHMAC(raw); ok { + token.WeakKey = secret + token.Issues = append(token.Issues, JWTIssue{ + Kind: "weak hmac secret", + Severity: jwtSevCritical, + Detail: "signature verifies against bundled weak secret " + secret, + }) + } + } + + return token, true +} + +// jwtAlgIssues flags the algorithm-level weaknesses: alg:none (no signature at +// all) and the RS256->HS256 confusion surface (an asymmetric-looking token whose +// header says HS*, meaning a server that loads the public key as an hmac secret +// can be forged). +func jwtAlgIssues(alg string) []JWTIssue { + var issues []JWTIssue + lower := strings.ToLower(alg) + + if lower == "none" || alg == "" { + issues = append(issues, JWTIssue{ + Kind: "alg:none", + Severity: jwtSevCritical, + Detail: "token declares no signature algorithm; forgeable", + }) + return issues + } + + if isHMACAlg(alg) { + issues = append(issues, JWTIssue{ + Kind: "rs256->hs256 confusion surface", + Severity: jwtSevMedium, + Detail: "token is HMAC-signed; if the server also accepts asymmetric algs " + + "with the same verifier, a public key can be used as the HMAC secret", + }) + } + return issues +} + +// jwtClaimIssues inspects the decoded payload for missing/expired expiry and any +// plaintext sensitive claims (the payload is base64, not encrypted). +func jwtClaimIssues(payload map[string]any) []JWTIssue { + var issues []JWTIssue + + exp, hasExp := numericClaim(payload, "exp") + switch { + case !hasExp: + issues = append(issues, JWTIssue{ + Kind: "missing exp", + Severity: jwtSevMedium, + Detail: "no expiry claim; token never ages out", + }) + case time.Now().After(time.Unix(int64(exp), 0)): + issues = append(issues, JWTIssue{ + Kind: "expired token", + Severity: jwtSevLow, + Detail: "exp is in the past; a server still honoring it is a bug", + }) + } + + for i := 0; i < len(sensitiveClaimKeys); i++ { + key := sensitiveClaimKeys[i] + if _, ok := payload[key]; ok { + issues = append(issues, JWTIssue{ + Kind: "sensitive plaintext claim", + Severity: jwtSevHigh, + Detail: "payload carries readable claim " + key + "; jwt bodies are not encrypted", + }) + } + } + + return issues +} + +// crackHMAC tries every bundled weak secret against the token's HS256 signature +// offline. a verifying secret means the token is forgeable by anyone who knows +// it. only HS256 is attempted; the wordlist exists to catch lazy defaults, not +// to be a real cracker. +func crackHMAC(raw string) (string, bool) { + parts := strings.Split(raw, ".") + if len(parts) != 3 { + return "", false + } + signingInput := parts[0] + "." + parts[1] + want, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return "", false + } + + for i := 0; i < len(jwtWeakSecrets); i++ { + secret := jwtWeakSecrets[i] + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingInput)) + if hmac.Equal(mac.Sum(nil), want) { + return secret, true + } + } + return "", false +} + +// decodeJWTSegment base64url-decodes one jwt segment into a claims map. jwt uses +// unpadded base64url, but some emitters pad anyway, so try raw first then padded. +func decodeJWTSegment(seg string) (map[string]any, error) { + data, err := base64.RawURLEncoding.DecodeString(seg) + if err != nil { + data, err = base64.URLEncoding.DecodeString(seg) + if err != nil { + return nil, fmt.Errorf("base64url decode segment: %w", err) + } + } + var claims map[string]any + if err := json.Unmarshal(data, &claims); err != nil { + return nil, fmt.Errorf("unmarshal jwt segment: %w", err) + } + return claims, nil +} + +// numericClaim pulls a numeric claim out of the payload. json numbers decode to +// float64, so that's the only shape we accept. +func numericClaim(payload map[string]any, key string) (float64, bool) { + v, ok := payload[key] + if !ok { + return 0, false + } + f, ok := v.(float64) + return f, ok +} + +// isHMACAlg reports whether alg is one of the HMAC family (HS256/HS384/HS512). +func isHMACAlg(alg string) bool { + return strings.HasPrefix(strings.ToUpper(alg), "HS") +} + +// previewToken trims a raw token to a short prefix so evidence never carries the +// whole credential. +func previewToken(raw string) string { + if len(raw) <= jwtPreviewLen { + return raw + } + return raw[:jwtPreviewLen] + "..." +} + +func renderJWTSeverity(severity string) string { + switch severity { + case jwtSevCritical: + return output.SeverityCritical.Render(severity) + case jwtSevHigh: + return output.SeverityHigh.Render(severity) + case jwtSevMedium: + return output.SeverityMedium.Render(severity) + default: + return output.SeverityLow.Render(severity) + } +} + +// ResultType identifies jwt findings for the result registry. +func (r *JWTResult) ResultType() string { return "jwt" } + +var _ ScanResult = (*JWTResult)(nil) diff --git a/internal/scan/jwt_test.go b/internal/scan/jwt_test.go new file mode 100644 index 0000000..ba143d1 --- /dev/null +++ b/internal/scan/jwt_test.go @@ -0,0 +1,172 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// fixed jwt fixtures, generated offline. each exercises a distinct weakness. +const ( + // header {alg:none}, payload {sub:admin}, empty signature - forgeable. + jwtNone = "eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0." + + "eyJzdWIiOiAiYWRtaW4iLCAicm9sZSI6ICJ1c2VyIn0." + + // HS256, no exp claim, signed with the bundled weak secret "secret". + jwtWeakHS256 = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." + + "eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJuYW1lIjogInRlc3RlciJ9." + + "JOjVfLa8gp3cvFkNVgOnmdrI1MCHZRA_ChBmCPF-Z8w" + + // HS256, exp in 2001 (long past), signed with a secret not in the wordlist. + jwtExpired = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." + + "eyJzdWIiOiAieCIsICJleHAiOiAxMDAwMDAwMDAwfQ." + + "gr28Ffm4wJkonHGSKmMD5Rj7e1pTt2o_EwG6lMWQeSc" + + // HS256 carrying a plaintext password claim (jwt bodies are not encrypted). + jwtSensitive = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." + + "eyJzdWIiOiAieCIsICJwYXNzd29yZCI6ICJodW50ZXIyIiwgImV4cCI6IDk5OTk5OTk5OTl9." + + "rjEf0CUa7_qppuINi6zL9vupJIX0rzSBhul7kKM9uSA" +) + +// hasIssue reports whether the analyzed token carries an issue of the given kind. +func hasIssue(token *JWTToken, kind string) bool { + for i := 0; i < len(token.Issues); i++ { + if token.Issues[i].Kind == kind { + return true + } + } + return false +} + +func TestJWT_AlgNoneAndMissingExpFlagged(t *testing.T) { + // serve the alg:none token in the Authorization header echo. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Authorization", "Bearer "+jwtNone) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + result, err := JWT(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("JWT: %v", err) + } + if result == nil || len(result.Tokens) != 1 { + t.Fatalf("expected exactly one analyzed token, got %+v", result) + } + + token := &result.Tokens[0] + if !hasIssue(token, "alg:none") { + t.Errorf("expected alg:none to be flagged, got issues %+v", token.Issues) + } + if !hasIssue(token, "missing exp") { + t.Errorf("expected missing exp to be flagged, got issues %+v", token.Issues) + } + // the preview must never carry the whole token. + if len(token.Preview) >= len(jwtNone) { + t.Errorf("preview should be trimmed, got full token %q", token.Preview) + } +} + +func TestJWT_WeakSecretCracked(t *testing.T) { + // token rides in a Set-Cookie this time. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "session", Value: jwtWeakHS256}) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + result, err := JWT(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("JWT: %v", err) + } + if result == nil || len(result.Tokens) != 1 { + t.Fatalf("expected one token, got %+v", result) + } + + token := &result.Tokens[0] + if token.WeakKey != "secret" { + t.Errorf("expected weak secret 'secret' to be cracked, got %q", token.WeakKey) + } + if !hasIssue(token, "weak hmac secret") { + t.Errorf("expected weak hmac secret issue, got %+v", token.Issues) + } + if !hasIssue(token, "rs256->hs256 confusion surface") { + t.Errorf("expected hmac confusion surface to be flagged, got %+v", token.Issues) + } +} + +func TestJWT_ExpiredFlagged(t *testing.T) { + // token in the response body. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"token":"` + jwtExpired + `"}`)) + })) + defer srv.Close() + + result, err := JWT(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("JWT: %v", err) + } + if result == nil || len(result.Tokens) != 1 { + t.Fatalf("expected one token, got %+v", result) + } + if !hasIssue(&result.Tokens[0], "expired token") { + t.Errorf("expected expired token to be flagged, got %+v", result.Tokens[0].Issues) + } + // a strong, unguessed secret must not be cracked. + if result.Tokens[0].WeakKey != "" { + t.Errorf("did not expect a cracked key on the strong-secret token, got %q", result.Tokens[0].WeakKey) + } +} + +func TestJWT_SensitiveClaimFlagged(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(jwtSensitive)) + })) + defer srv.Close() + + result, err := JWT(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("JWT: %v", err) + } + if result == nil || len(result.Tokens) != 1 { + t.Fatalf("expected one token, got %+v", result) + } + if !hasIssue(&result.Tokens[0], "sensitive plaintext claim") { + t.Errorf("expected sensitive claim to be flagged, got %+v", result.Tokens[0].Issues) + } +} + +func TestJWT_NoTokens(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("nothing to see here")) + })) + defer srv.Close() + + result, err := JWT(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("JWT: %v", err) + } + if result != nil { + t.Errorf("expected nil result when no tokens present, got %+v", result) + } +} + +func TestJWTResult_ResultType(t *testing.T) { + r := &JWTResult{} + if r.ResultType() != "jwt" { + t.Errorf("expected result type 'jwt', got %q", r.ResultType()) + } +} diff --git a/internal/scan/openapi.go b/internal/scan/openapi.go new file mode 100644 index 0000000..88c7013 --- /dev/null +++ b/internal/scan/openapi.go @@ -0,0 +1,322 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "sync" + "time" + + charmlog "github.com/charmbracelet/log" + "github.com/dropalldatabases/sif/internal/httpx" + "github.com/dropalldatabases/sif/internal/logger" + "github.com/dropalldatabases/sif/internal/output" + "gopkg.in/yaml.v3" +) + +// OpenAPIResult is the parsed spec exposure plus the endpoints enumerated from +// it. +type OpenAPIResult struct { + SpecURL string `json:"spec_url"` // the path the spec was served at + Title string `json:"title"` // info.title from the spec + Version string `json:"version"` // openapi/swagger version string + Endpoints []OpenAPIEndpoint `json:"endpoints"` // every path+method pair + Severity string `json:"severity"` // exposure severity +} + +// OpenAPIEndpoint is one path+method, flagged when nothing in the spec gates it. +type OpenAPIEndpoint struct { + Path string `json:"path"` + Method string `json:"method"` + Unauth bool `json:"unauth"` // no security requirement on this operation +} + +// openapiSpecPaths are the conventional locations a spec is served from. ordered +// most-common first so the typical hit is found early. +var openapiSpecPaths = []string{ + "/swagger.json", + "/openapi.json", + "/v3/api-docs", + "/api-docs", + "/swagger/v1/swagger.json", + "/swagger-ui/", +} + +// openapiBodyReadCap bounds spec body reads. specs are text and rarely huge, but +// an attacker-controlled endpoint could stream forever, so cap it. +const openapiBodyReadCap = 8 << 20 + +// the http methods an openapi path item can declare. anything outside this set +// is metadata (parameters, summary), not an operation. +var openapiHTTPMethods = []string{ + http.MethodGet, http.MethodPut, http.MethodPost, http.MethodDelete, + http.MethodOptions, http.MethodHead, http.MethodPatch, http.MethodTrace, +} + +// exposure severities. an enumerable spec is medium on its own; unauthenticated +// operations bump it to high. +const ( + openapiSevMedium = "medium" + openapiSevHigh = "high" +) + +// openapiSpec is the minimal slice of an openapi/swagger document we care about: +// the version banner, info block, top-level security and the path map. unknown +// fields are ignored by both json and yaml decoders. +type openapiSpec struct { + OpenAPI string `json:"openapi" yaml:"openapi"` + Swagger string `json:"swagger" yaml:"swagger"` + Info openapiInfo `json:"info" yaml:"info"` + Security []map[string][]string `json:"security" yaml:"security"` + Paths map[string]map[string]rawOps `json:"paths" yaml:"paths"` +} + +type openapiInfo struct { + Title string `json:"title" yaml:"title"` + Version string `json:"version" yaml:"version"` +} + +// rawOps captures just the per-operation security block so we can tell whether +// an operation requires auth. the rest of the operation object is irrelevant. +type rawOps struct { + Security []map[string][]string `json:"security" yaml:"security"` +} + +// OpenAPI probes the candidate spec paths concurrently and, on the first hit, +// parses the spec and enumerates its endpoints. +func OpenAPI(targetURL string, timeout time.Duration, threads int, logdir string) (*OpenAPIResult, error) { + log := output.Module("OPENAPI") + log.Start() + + spin := output.NewSpinner("Probing for exposed openapi/swagger specs") + spin.Start() + + sanitizedURL := stripScheme(targetURL) + + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "OpenAPI/Swagger spec exposure"); err != nil { + spin.Stop() + log.Error("error creating log file: %v", err) + return nil, fmt.Errorf("create openapi log: %w", err) + } + } + + client := httpx.Client(timeout) + base := strings.TrimRight(targetURL, "/") + + result := probeOpenAPIPaths(client, base, threads) + + spin.Stop() + + if result == nil { + log.Info("no openapi/swagger spec exposed") + log.Complete(0, "found") + return nil, nil //nolint:nilnil // no exposed spec is not an error + } + + unauth := 0 + for i := 0; i < len(result.Endpoints); i++ { + if result.Endpoints[i].Unauth { + unauth++ + } + } + + log.Warn("openapi %s: spec at %s exposes %d endpoints (%d unauthenticated)", + renderOpenAPISeverity(result.Severity), + output.Highlight.Render(result.SpecURL), + len(result.Endpoints), unauth) + + if logdir != "" { + _ = logger.Write(sanitizedURL, logdir, + fmt.Sprintf("OpenAPI spec exposed at %s: %d endpoints, %d unauthenticated\n", + result.SpecURL, len(result.Endpoints), unauth)) + } + + log.Complete(len(result.Endpoints), "endpoints") + return result, nil +} + +// probeOpenAPIPaths fans the candidate paths across a worker pool and returns the +// first parseable spec. the first hit wins, so once one worker fills the result +// the rest of the channel drains without re-parsing. +func probeOpenAPIPaths(client *http.Client, base string, threads int) *OpenAPIResult { + var ( + mu sync.Mutex + wg sync.WaitGroup + result *OpenAPIResult + ) + + pathChan := make(chan string, len(openapiSpecPaths)) + for i := 0; i < len(openapiSpecPaths); i++ { + pathChan <- openapiSpecPaths[i] + } + close(pathChan) + + wg.Add(threads) + for t := 0; t < threads; t++ { + go func() { + defer wg.Done() + for path := range pathChan { + // a spec already landed; stop spending requests. + mu.Lock() + done := result != nil + mu.Unlock() + if done { + return + } + + hit := fetchOpenAPISpec(client, base+path) + if hit == nil { + continue + } + hit.SpecURL = base + path + + mu.Lock() + if result == nil { + result = hit + } + mu.Unlock() + } + }() + } + wg.Wait() + + return result +} + +// fetchOpenAPISpec GETs one candidate path and parses the body as a spec. it +// returns nil on any failure (non-200, unparseable, zero paths) so a swagger-ui +// html page or a 404 doesn't masquerade as a finding. +func fetchOpenAPISpec(client *http.Client, specURL string) *OpenAPIResult { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, specURL, http.NoBody) + if err != nil { + charmlog.Debugf("openapi: build request for %s: %v", specURL, err) + return nil + } + resp, err := client.Do(req) + if err != nil { + charmlog.Debugf("openapi: request %s: %v", specURL, err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, openapiBodyReadCap)) + if err != nil { + charmlog.Debugf("openapi: read %s: %v", specURL, err) + return nil + } + + spec, ok := parseOpenAPISpec(body) + if !ok { + return nil + } + + return specToResult(spec) +} + +// parseOpenAPISpec decodes the body as json first, then yaml. it only accepts a +// document that actually declares an openapi/swagger version and at least one +// path, so an unrelated json/yaml file served at the candidate path is rejected. +func parseOpenAPISpec(body []byte) (*openapiSpec, bool) { + var spec openapiSpec + if err := json.Unmarshal(body, &spec); err != nil { + if err := yaml.Unmarshal(body, &spec); err != nil { + return nil, false + } + } + + versioned := spec.OpenAPI != "" || spec.Swagger != "" + if !versioned || len(spec.Paths) == 0 { + return nil, false + } + return &spec, true +} + +// specToResult flattens the parsed spec into enumerated endpoints and ranks the +// exposure. an operation with no security requirement (and no top-level default) +// is flagged unauthenticated, which bumps the overall severity to high. +func specToResult(spec *openapiSpec) *OpenAPIResult { + hasGlobalSecurity := len(spec.Security) > 0 + + endpoints := make([]OpenAPIEndpoint, 0, len(spec.Paths)) + anyUnauth := false + + // stable order: sort paths so the report is deterministic across runs. + paths := make([]string, 0, len(spec.Paths)) + for p := range spec.Paths { + paths = append(paths, p) + } + sort.Strings(paths) + + for i := 0; i < len(paths); i++ { + path := paths[i] + ops := spec.Paths[path] + for j := 0; j < len(openapiHTTPMethods); j++ { + method := openapiHTTPMethods[j] + op, ok := ops[strings.ToLower(method)] + if !ok { + continue + } + // an operation is unauth when neither it nor the global default + // declares a security requirement. + unauth := len(op.Security) == 0 && !hasGlobalSecurity + if unauth { + anyUnauth = true + } + endpoints = append(endpoints, OpenAPIEndpoint{ + Path: path, + Method: method, + Unauth: unauth, + }) + } + } + + severity := openapiSevMedium + if anyUnauth { + severity = openapiSevHigh + } + + version := spec.OpenAPI + if version == "" { + version = spec.Swagger + } + + return &OpenAPIResult{ + Title: spec.Info.Title, + Version: version, + Endpoints: endpoints, + Severity: severity, + } +} + +func renderOpenAPISeverity(severity string) string { + if severity == openapiSevHigh { + return output.SeverityHigh.Render(severity) + } + return output.SeverityMedium.Render(severity) +} + +// ResultType identifies openapi findings for the result registry. +func (r *OpenAPIResult) ResultType() string { return "openapi" } + +var _ ScanResult = (*OpenAPIResult)(nil) diff --git a/internal/scan/openapi_test.go b/internal/scan/openapi_test.go new file mode 100644 index 0000000..ddc119b --- /dev/null +++ b/internal/scan/openapi_test.go @@ -0,0 +1,210 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// a minimal openapi 3 doc with two paths/three operations, no security at all - +// every operation is unauthenticated. +const openapiJSONUnauth = `{ + "openapi": "3.0.1", + "info": {"title": "Test API", "version": "1.0"}, + "paths": { + "/users": { + "get": {"summary": "list"}, + "post": {"summary": "create"} + }, + "/admin": { + "delete": {"summary": "nuke"} + } + } +}` + +// same doc but with a global security requirement, so nothing is flagged unauth. +const openapiJSONSecured = `{ + "openapi": "3.0.1", + "info": {"title": "Secured API", "version": "1.0"}, + "security": [{"bearerAuth": []}], + "paths": { + "/users": {"get": {"summary": "list"}} + } +}` + +// a yaml swagger 2.0 doc, to exercise the yaml parse fallback. +const openapiYAML = `swagger: "2.0" +info: + title: YAML API + version: "2.0" +paths: + /ping: + get: + summary: health +` + +// hasEndpoint reports whether the result enumerated the given path+method. +func hasEndpoint(r *OpenAPIResult, path, method string) (OpenAPIEndpoint, bool) { + for i := 0; i < len(r.Endpoints); i++ { + if r.Endpoints[i].Path == path && r.Endpoints[i].Method == method { + return r.Endpoints[i], true + } + } + return OpenAPIEndpoint{}, false +} + +func TestOpenAPI_EnumeratesEndpoints(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/openapi.json" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(openapiJSONUnauth)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + result, err := OpenAPI(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + if result == nil { + t.Fatal("expected an openapi result, got nil") + } + if len(result.Endpoints) != 3 { + t.Fatalf("expected 3 enumerated endpoints, got %d: %+v", len(result.Endpoints), result.Endpoints) + } + + for _, want := range []struct{ path, method string }{ + {"/users", http.MethodGet}, + {"/users", http.MethodPost}, + {"/admin", http.MethodDelete}, + } { + ep, ok := hasEndpoint(result, want.path, want.method) + if !ok { + t.Errorf("missing endpoint %s %s", want.method, want.path) + continue + } + if !ep.Unauth { + t.Errorf("expected %s %s to be flagged unauthenticated", want.method, want.path) + } + } + + // no security anywhere -> high exposure. + if result.Severity != openapiSevHigh { + t.Errorf("expected high severity for fully-unauth spec, got %q", result.Severity) + } + if result.Title != "Test API" { + t.Errorf("expected title 'Test API', got %q", result.Title) + } +} + +func TestOpenAPI_SecuredSpecIsMedium(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/swagger.json" { + _, _ = w.Write([]byte(openapiJSONSecured)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + result, err := OpenAPI(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + if result == nil { + t.Fatal("expected a result, got nil") + } + ep, ok := hasEndpoint(result, "/users", http.MethodGet) + if !ok { + t.Fatal("expected /users GET to be enumerated") + } + if ep.Unauth { + t.Errorf("global security should mark the operation authenticated, got unauth") + } + if result.Severity != openapiSevMedium { + t.Errorf("expected medium severity for a secured spec, got %q", result.Severity) + } +} + +func TestOpenAPI_YAMLSpec(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v3/api-docs" { + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write([]byte(openapiYAML)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + result, err := OpenAPI(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + if result == nil { + t.Fatal("expected a yaml-parsed result, got nil") + } + if _, ok := hasEndpoint(result, "/ping", http.MethodGet); !ok { + t.Errorf("expected /ping GET from yaml spec, got %+v", result.Endpoints) + } +} + +// TestOpenAPI_NoSpecExposed confirms a server with no spec at any candidate path +// produces no result. +func TestOpenAPI_NoSpecExposed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + result, err := OpenAPI(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + if result != nil { + t.Errorf("expected nil result when no spec exposed, got %+v", result) + } +} + +// TestOpenAPI_RejectsUnrelatedJSON makes sure a plain json document served at a +// candidate path (no openapi/swagger version) is not treated as a spec. +func TestOpenAPI_RejectsUnrelatedJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/openapi.json" { + _, _ = w.Write([]byte(`{"hello":"world"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + result, err := OpenAPI(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("OpenAPI: %v", err) + } + if result != nil { + t.Errorf("unrelated json should not be parsed as a spec, got %+v", result) + } +} + +func TestOpenAPIResult_ResultType(t *testing.T) { + r := &OpenAPIResult{} + if r.ResultType() != "openapi" { + t.Errorf("expected result type 'openapi', got %q", r.ResultType()) + } +} diff --git a/man/sif.1 b/man/sif.1 index 145af78..9799fbf 100644 --- a/man/sif.1 +++ b/man/sif.1 @@ -125,6 +125,15 @@ sql reconnaissance (admin panels, error disclosure). .B \-lfi local file inclusion reconnaissance. .TP +.B \-jwt +jwt discovery plus offline weakness analysis (alg:none, weak hmac secret, missing/expired exp, sensitive plaintext claims). +.TP +.B \-openapi +openapi/swagger spec exposure probe; enumerates paths, methods and unauthenticated operations. +.TP +.B \-favicon +favicon hash fingerprinting (shodan\-style mmh3); matches bundled tech and prints the http.favicon.hash pivot query. +.TP .B \-cors cors misconfiguration probe (reflected/permissive origins). .TP diff --git a/sif.go b/sif.go index f87e468..5044f89 100644 --- a/sif.go +++ b/sif.go @@ -503,6 +503,36 @@ func (app *App) Run() error { } } + if app.settings.JWT { + result, err := scan.JWT(url, app.settings.Timeout, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running JWT analysis: %s", err) + } else if result != nil { + moduleResults = append(moduleResults, NewModuleResult(result)) + scansRun = append(scansRun, "JWT") + } + } + + if app.settings.OpenAPI { + result, err := scan.OpenAPI(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running OpenAPI probe: %s", err) + } else if result != nil { + moduleResults = append(moduleResults, NewModuleResult(result)) + scansRun = append(scansRun, "OpenAPI") + } + } + + if app.settings.Favicon { + result, err := scan.Favicon(url, app.settings.Timeout, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running favicon fingerprint: %s", err) + } else if result != nil { + moduleResults = append(moduleResults, NewModuleResult(result)) + scansRun = append(scansRun, "Favicon") + } + } + if app.settings.CORS { result, err := scan.CORS(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) if err != nil {