mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 11:01:24 -07:00
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.
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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 `<link rel=icon>`), compute the shodan-style mmh3 hash, match it against a bundled tech map and print the `http.favicon.hash:<n>` pivot query
|
||||
|
||||
```bash
|
||||
./sif -u https://example.com -favicon
|
||||
```
|
||||
|
||||
### framework detection
|
||||
|
||||
`-framework` - detect web frameworks with version and cve lookup
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <link rel="...icon..."> tag so we can
|
||||
// fall back to a declared icon when /favicon.ico is absent.
|
||||
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) {
|
||||
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 <link rel=icon> 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
|
||||
// <link rel="...icon..."> 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)
|
||||
@@ -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 <link rel=icon> 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(`<html><head><link rel="icon" href="/static/icon.png"></head></html>`))
|
||||
}
|
||||
}))
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user