Skip to content

Commit 23a3d21

Browse files
committed
feat: add frontend security auditing (CSP, CORS, cookies, SRI, XSS, open redirect, insecure forms, vulnerable CDN)
1 parent 23ed499 commit 23a3d21

4 files changed

Lines changed: 663 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ The name combines **suri** (Tagalog: "examine" or "analyze") with **SC** for **s
3939
### What It Does
4040

4141
- **Scrapes** — Discovers every `<script>` tag on a page and fetches its payload without writing files to disk
42-
- **Detects**25+ secret types including AWS keys, Stripe secrets, GitHub PATs, Google API keys, Firebase/Supabase configs, and more
42+
- **Detects**30+ secret types including AWS keys, Stripe secrets, GitHub PATs, Google API keys, Firebase/Supabase configs, and more
4343
- **Scans response headers** — Checks every HTTP response header for API keys, bearer tokens, and credentials (x-api-key, Authorization, etc.)
4444
- **Ingests** — Fetches source map (`.map`) files and recursively scans their original source code for secrets
4545
- **Analyzes** — Shannon entropy scoring flags high-density strings that look like real credentials
46-
- **Profiles**Identifies technology stack, hosting provider, CDN, CMS, SPA/PWA status, and security headers
46+
- **Audits frontend security**Detects weak Content-Security-Policy, permissive CORS, missing HttpOnly/Secure/SameSite cookies, scripts without Subresource Integrity, XSS sinks (innerHTML, eval, document.write), open redirects, insecure HTTP forms, and known vulnerable CDN library versions
4747
- **Discovers** — Routes from HTML, JS, JSON, `robots.txt`, and `sitemap.xml`; probes attack-surface paths
4848
- **Probes** — Detects GraphQL introspection endpoints, OpenAPI/Swagger spec files, and API documentation pages
4949
- **Filters** — Built-in false positive suppression ignores compilation artifacts, placeholder values, and standard library internals

internal/models/models.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ const (
2626
LeakTypeRSAPrivate LeakType = "RSA_PRIVATE_KEY"
2727
LeakTypeFirebaseConfig LeakType = "FIREBASE_CONFIG_LEAK"
2828
LeakTypeSupabaseConfig LeakType = "SUPABASE_CONFIG_LEAK"
29+
30+
// Frontend security findings
31+
LeakTypeWeakCSP LeakType = "WEAK_CSP"
32+
LeakTypeCORMisconfig LeakType = "CORS_MISCONFIG"
33+
LeakTypeCookieHardening LeakType = "COOKIE_HARDENING"
34+
LeakTypeMissingSRI LeakType = "MISSING_SRI"
35+
LeakTypeXSSSink LeakType = "XSS_SINK"
36+
LeakTypeOpenRedirect LeakType = "OPEN_REDIRECT"
37+
LeakTypeInsecureForm LeakType = "INSECURE_FORM"
38+
LeakTypeVulnCDN LeakType = "VULNERABLE_CDN_LIBRARY"
2939
)
3040

3141
type Leak struct {

internal/scanner/scanner.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,17 @@ func RunScan(targetURL string, informativeOnly bool) ([]models.Leak, models.Tech
372372
analyzeContent(r.Request.URL.String(), content, &leaks, &leaksMutex)
373373
}()
374374
}
375+
376+
// Frontend security analysis (headers + body)
377+
if !informativeOnly {
378+
bodyCopy := make([]byte, len(r.Body))
379+
copy(bodyCopy, r.Body)
380+
wg.Add(1)
381+
go func() {
382+
defer wg.Done()
383+
analyzeFrontendSecurity(r.Request.URL.String(), *r.Headers, bodyCopy, &leaks, &leaksMutex)
384+
}()
385+
}
375386
})
376387

377388
// Setup error handling
@@ -1223,6 +1234,18 @@ func isCrawlableResourceRef(raw string) bool {
12231234

12241235
var rxAPIKeyHeader = regexp.MustCompile(`(?i)(?:api[_-]?key|api[_-]?token|auth[_-]?token|x[_-]?api[_-]?key|x[_-]?auth[_-]?token)`)
12251236

1237+
var rxScriptTag = regexp.MustCompile(`(?i)<script\s[^>]*src\s*=\s*["']([^"']+)["'][^>]*>`)
1238+
var rxStyleLink = regexp.MustCompile(`(?i)<link\s[^>]*rel\s*=\s*["']stylesheet["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>`)
1239+
var rxXSSSink = regexp.MustCompile(`(?i)(?:\.innerHTML\s*=|\.outerHTML\s*=|document\.write\s*\(|eval\s*\(|document\.open\s*\(|\.insertAdjacentHTML\s*\()`)
1240+
var rxOpenRedirect = regexp.MustCompile(`(?i)(?:window\.)?location(?:\.href)?\s*=\s*.*?(?:params|query|search|url|redirect|next|return)`)
1241+
var rxInsecureFormAction = regexp.MustCompile(`(?i)<form\s[^>]*action\s*=\s*["']http://[^"']+["']`)
1242+
var rxCDNjQuery = regexp.MustCompile(`(?i)(?:cdnjs\.cloudflare|cdn\.jsdelivr|ajax\.googleapis|code\.jquery)\.(?:com|net).*jquery[-.\/@][0-9]+\.[0-9]+\.[0-9]+`)
1243+
var rxCDNAngular = regexp.MustCompile(`(?i)(?:cdnjs\.cloudflare|cdn\.jsdelivr|ajax\.googleapis|unpkg)\.(?:com|net).*angular(?:\.js)?[-.\/@][0-9]+\.[0-9]+\.[0-9]+`)
1244+
var rxCDNReact = regexp.MustCompile(`(?i)(?:cdnjs\.cloudflare|cdn\.jsdelivr|unpkg)\.(?:com|net).*react[-.\/@][0-9]+\.[0-9]+\.[0-9]+`)
1245+
var rxCDNVue = regexp.MustCompile(`(?i)(?:cdnjs\.cloudflare|cdn\.jsdelivr|unpkg)\.(?:com|net).*vue[-.\/@][0-9]+\.[0-9]+\.[0-9]+`)
1246+
var rxCDNBootstrap = regexp.MustCompile(`(?i)(?:cdnjs\.cloudflare|cdn\.jsdelivr|maxcdn|getbootstrap|bootstrapcdn)\.(?:com|net).*bootstrap[-.\/@][0-9]+\.[0-9]+\.[0-9]+`)
1247+
var rxCDNLodash = regexp.MustCompile(`(?i)(?:cdnjs\.cloudflare|cdn\.jsdelivr|unpkg)\.(?:com|net).*lodash(?:\.js)?[-.\/@][0-9]+\.[0-9]+\.[0-9]+`)
1248+
12261249
func analyzeHeaders(sourceURL string, headers *http.Header, leaks *[]models.Leak, mutex *sync.Mutex) {
12271250
var localLeaks []models.Leak
12281251
for k, vals := range *headers {
@@ -2229,6 +2252,219 @@ func mustParseURL(raw string) *url.URL {
22292252
return u
22302253
}
22312254

2255+
func analyzeFrontendSecurity(sourceURL string, headers http.Header, body []byte, leaks *[]models.Leak, mutex *sync.Mutex) {
2256+
var localLeaks []models.Leak
2257+
2258+
ctype := headers.Get("Content-Type")
2259+
bodyStr := string(body)
2260+
2261+
analyzeCSP(headers, sourceURL, &localLeaks)
2262+
analyzeCORS(headers, sourceURL, &localLeaks)
2263+
analyzeCookieSecurity(headers, sourceURL, &localLeaks)
2264+
2265+
if strings.Contains(ctype, "text/html") {
2266+
analyzeMissingSRI(bodyStr, sourceURL, &localLeaks)
2267+
analyzeInsecureForms(bodyStr, sourceURL, &localLeaks)
2268+
analyzeVulnerableCDN(bodyStr, sourceURL, &localLeaks)
2269+
}
2270+
2271+
if strings.Contains(ctype, "text/html") || strings.Contains(ctype, "javascript") {
2272+
analyzeXSSSinks(bodyStr, sourceURL, &localLeaks)
2273+
analyzeOpenRedirect(bodyStr, sourceURL, &localLeaks)
2274+
}
2275+
2276+
if len(localLeaks) > 0 {
2277+
localLeaks = dedupeLeaks(localLeaks)
2278+
mutex.Lock()
2279+
*leaks = append(*leaks, localLeaks...)
2280+
mutex.Unlock()
2281+
}
2282+
}
2283+
2284+
func analyzeCSP(headers http.Header, sourceURL string, localLeaks *[]models.Leak) {
2285+
csp := headers.Get("Content-Security-Policy")
2286+
if csp == "" {
2287+
*localLeaks = append(*localLeaks, models.Leak{
2288+
LeakType: models.LeakTypeWeakCSP,
2289+
SourceURL: sourceURL,
2290+
GravityScore: 5.0,
2291+
Snippet: "Content-Security-Policy header is missing",
2292+
})
2293+
return
2294+
}
2295+
cspLower := strings.ToLower(csp)
2296+
2297+
if strings.Contains(cspLower, "unsafe-inline") {
2298+
*localLeaks = append(*localLeaks, models.Leak{
2299+
LeakType: models.LeakTypeWeakCSP,
2300+
SourceURL: sourceURL,
2301+
GravityScore: 7.0,
2302+
Snippet: "CSP allows unsafe-inline: " + truncate(csp, 100),
2303+
})
2304+
}
2305+
if strings.Contains(cspLower, "unsafe-eval") {
2306+
*localLeaks = append(*localLeaks, models.Leak{
2307+
LeakType: models.LeakTypeWeakCSP,
2308+
SourceURL: sourceURL,
2309+
GravityScore: 6.5,
2310+
Snippet: "CSP allows unsafe-eval: " + truncate(csp, 100),
2311+
})
2312+
}
2313+
if !strings.Contains(cspLower, "object-src") {
2314+
*localLeaks = append(*localLeaks, models.Leak{
2315+
LeakType: models.LeakTypeWeakCSP,
2316+
SourceURL: sourceURL,
2317+
GravityScore: 5.5,
2318+
Snippet: "CSP missing object-src directive: " + truncate(csp, 100),
2319+
})
2320+
}
2321+
}
2322+
2323+
func analyzeCORS(headers http.Header, sourceURL string, localLeaks *[]models.Leak) {
2324+
origin := headers.Get("Access-Control-Allow-Origin")
2325+
creds := headers.Get("Access-Control-Allow-Credentials")
2326+
2327+
if origin == "*" && strings.EqualFold(creds, "true") {
2328+
*localLeaks = append(*localLeaks, models.Leak{
2329+
LeakType: models.LeakTypeCORMisconfig,
2330+
SourceURL: sourceURL,
2331+
GravityScore: 8.0,
2332+
Snippet: "Access-Control-Allow-Origin: * with Allow-Credentials: true",
2333+
})
2334+
} else if origin == "*" {
2335+
*localLeaks = append(*localLeaks, models.Leak{
2336+
LeakType: models.LeakTypeCORMisconfig,
2337+
SourceURL: sourceURL,
2338+
GravityScore: 5.0,
2339+
Snippet: "Access-Control-Allow-Origin: * (wildcard)",
2340+
})
2341+
}
2342+
}
2343+
2344+
func analyzeCookieSecurity(headers http.Header, sourceURL string, localLeaks *[]models.Leak) {
2345+
cookies := cookiesFromHeader(&headers)
2346+
for _, c := range cookies {
2347+
if c == nil {
2348+
continue
2349+
}
2350+
if !c.HttpOnly {
2351+
*localLeaks = append(*localLeaks, models.Leak{
2352+
LeakType: models.LeakTypeCookieHardening,
2353+
SourceURL: sourceURL,
2354+
GravityScore: 6.0,
2355+
Snippet: fmt.Sprintf("Cookie %q missing HttpOnly flag", c.Name),
2356+
})
2357+
}
2358+
if !c.Secure {
2359+
*localLeaks = append(*localLeaks, models.Leak{
2360+
LeakType: models.LeakTypeCookieHardening,
2361+
SourceURL: sourceURL,
2362+
GravityScore: 5.5,
2363+
Snippet: fmt.Sprintf("Cookie %q missing Secure flag", c.Name),
2364+
})
2365+
}
2366+
if c.SameSite == http.SameSiteDefaultMode {
2367+
*localLeaks = append(*localLeaks, models.Leak{
2368+
LeakType: models.LeakTypeCookieHardening,
2369+
SourceURL: sourceURL,
2370+
GravityScore: 4.0,
2371+
Snippet: fmt.Sprintf("Cookie %q missing SameSite attribute", c.Name),
2372+
})
2373+
}
2374+
}
2375+
}
2376+
2377+
func analyzeMissingSRI(body, sourceURL string, localLeaks *[]models.Leak) {
2378+
for _, match := range rxScriptTag.FindAllStringSubmatch(body, -1) {
2379+
if len(match) < 2 {
2380+
continue
2381+
}
2382+
fullTag := match[0]
2383+
src := match[1]
2384+
if !strings.Contains(fullTag, "integrity=") {
2385+
*localLeaks = append(*localLeaks, models.Leak{
2386+
LeakType: models.LeakTypeMissingSRI,
2387+
SourceURL: sourceURL,
2388+
GravityScore: 4.0,
2389+
Snippet: fmt.Sprintf("Script without integrity attribute: %s", truncate(src, 80)),
2390+
})
2391+
}
2392+
}
2393+
for _, match := range rxStyleLink.FindAllStringSubmatch(body, -1) {
2394+
if len(match) < 2 {
2395+
continue
2396+
}
2397+
fullTag := match[0]
2398+
href := match[1]
2399+
if !strings.Contains(fullTag, "integrity=") {
2400+
*localLeaks = append(*localLeaks, models.Leak{
2401+
LeakType: models.LeakTypeMissingSRI,
2402+
SourceURL: sourceURL,
2403+
GravityScore: 3.0,
2404+
Snippet: fmt.Sprintf("Stylesheet without integrity attribute: %s", truncate(href, 80)),
2405+
})
2406+
}
2407+
}
2408+
}
2409+
2410+
func analyzeXSSSinks(body, sourceURL string, localLeaks *[]models.Leak) {
2411+
for _, match := range rxXSSSink.FindAllString(body, -1) {
2412+
*localLeaks = append(*localLeaks, models.Leak{
2413+
LeakType: models.LeakTypeXSSSink,
2414+
SourceURL: sourceURL,
2415+
GravityScore: 8.0,
2416+
Snippet: fmt.Sprintf("XSS sink detected: %s", truncate(strings.TrimSpace(match), 60)),
2417+
})
2418+
}
2419+
}
2420+
2421+
func analyzeOpenRedirect(body, sourceURL string, localLeaks *[]models.Leak) {
2422+
for _, match := range rxOpenRedirect.FindAllString(body, -1) {
2423+
*localLeaks = append(*localLeaks, models.Leak{
2424+
LeakType: models.LeakTypeOpenRedirect,
2425+
SourceURL: sourceURL,
2426+
GravityScore: 7.0,
2427+
Snippet: fmt.Sprintf("Potential open redirect: %s", truncate(strings.TrimSpace(match), 80)),
2428+
})
2429+
}
2430+
}
2431+
2432+
func analyzeInsecureForms(body, sourceURL string, localLeaks *[]models.Leak) {
2433+
for _, match := range rxInsecureFormAction.FindAllString(body, -1) {
2434+
*localLeaks = append(*localLeaks, models.Leak{
2435+
LeakType: models.LeakTypeInsecureForm,
2436+
SourceURL: sourceURL,
2437+
GravityScore: 6.0,
2438+
Snippet: fmt.Sprintf("Insecure form action (HTTP): %s", truncate(strings.TrimSpace(match), 100)),
2439+
})
2440+
}
2441+
}
2442+
2443+
func analyzeVulnerableCDN(body, sourceURL string, localLeaks *[]models.Leak) {
2444+
checks := []struct {
2445+
rx *regexp.Regexp
2446+
lib string
2447+
score float64
2448+
}{
2449+
{rxCDNjQuery, "jQuery", 5.0},
2450+
{rxCDNAngular, "Angular", 4.5},
2451+
{rxCDNReact, "React", 4.0},
2452+
{rxCDNVue, "Vue", 4.0},
2453+
{rxCDNBootstrap, "Bootstrap", 4.0},
2454+
{rxCDNLodash, "Lodash", 4.0},
2455+
}
2456+
for _, check := range checks {
2457+
for _, match := range check.rx.FindAllString(body, -1) {
2458+
*localLeaks = append(*localLeaks, models.Leak{
2459+
LeakType: models.LeakTypeVulnCDN,
2460+
SourceURL: sourceURL,
2461+
GravityScore: check.score,
2462+
Snippet: fmt.Sprintf("Known %s CDN dependency (check version for known CVEs): %s", check.lib, truncate(match, 80)),
2463+
})
2464+
}
2465+
}
2466+
}
2467+
22322468
func applyBrowserHeaders(req *http.Request) {
22332469
// A conservative Chrome-like header set (no cookies by default).
22342470
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36")

0 commit comments

Comments
 (0)