From 7efd62c804fdf5a3377e8b112a477b4c12810354 Mon Sep 17 00:00:00 2001 From: vmfunc Date: Mon, 8 Jun 2026 18:15:55 -0700 Subject: [PATCH] feat: add security header analysis scan adds a -sh/--security-headers scan that flags missing or weak response headers (hsts, csp, x-frame-options, x-content-type-options, referrer-policy, permissions-policy, coop) and headers that leak server internals (server, x-powered-by, ...). hsts is only graded over https where it actually applies. wired into App.Run and the module results. --- internal/config/config.go | 2 + internal/scan/result.go | 3 + internal/scan/securityheaders.go | 162 ++++++++++++++++++++++++++ internal/scan/securityheaders_test.go | 154 ++++++++++++++++++++++++ sif.go | 10 ++ 5 files changed, 331 insertions(+) create mode 100644 internal/scan/securityheaders.go create mode 100644 internal/scan/securityheaders_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 9325bdf..13a2a3b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,7 @@ type Settings struct { Template string CMS bool Headers bool + SecurityHeaders bool CloudStorage bool SubdomainTakeover bool Shodan bool @@ -90,6 +91,7 @@ func Parse() *Settings { flagSet.BoolVar(&settings.JavaScript, "js", false, "Enable JavaScript scans"), flagSet.BoolVar(&settings.CMS, "cms", false, "Enable CMS detection"), flagSet.BoolVar(&settings.Headers, "headers", false, "Enable HTTP Header Analysis"), + flagSet.BoolVarP(&settings.SecurityHeaders, "security-headers", "sh", false, "Enable security header analysis (missing/weak headers)"), flagSet.BoolVar(&settings.CloudStorage, "c3", false, "Enable C3 Misconfiguration Scan"), flagSet.BoolVar(&settings.SubdomainTakeover, "st", false, "Enable Subdomain Takeover Check"), flagSet.BoolVar(&settings.Shodan, "shodan", false, "Enable Shodan lookup (requires SHODAN_API_KEY env var)"), diff --git a/internal/scan/result.go b/internal/scan/result.go index 8006024..74e67a5 100644 --- a/internal/scan/result.go +++ b/internal/scan/result.go @@ -16,6 +16,7 @@ package scan // These provide better type safety and allow method implementations. type ( HeaderResults []HeaderResult + SecurityHeaderResults []SecurityHeaderResult DirectoryResults []DirectoryResult CloudStorageResults []CloudStorageResult DorkResults []DorkResult @@ -40,6 +41,7 @@ func (r *SecurityTrailsResult) ResultType() string { return "securitytrails" } // ResultType implementations for slice result types. func (r HeaderResults) ResultType() string { return "headers" } +func (r SecurityHeaderResults) ResultType() string { return "security_headers" } func (r DirectoryResults) ResultType() string { return "dirlist" } func (r CloudStorageResults) ResultType() string { return "cloudstorage" } func (r DorkResults) ResultType() string { return "dork" } @@ -53,6 +55,7 @@ var ( _ ScanResult = (*CMSResult)(nil) _ ScanResult = (*SecurityTrailsResult)(nil) _ ScanResult = HeaderResults(nil) + _ ScanResult = SecurityHeaderResults(nil) _ ScanResult = DirectoryResults(nil) _ ScanResult = CloudStorageResults(nil) _ ScanResult = DorkResults(nil) diff --git a/internal/scan/securityheaders.go b/internal/scan/securityheaders.go new file mode 100644 index 0000000..d66eb7e --- /dev/null +++ b/internal/scan/securityheaders.go @@ -0,0 +1,162 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "github.com/dropalldatabases/sif/internal/logger" + "github.com/dropalldatabases/sif/internal/output" +) + +type SecurityHeaderResult struct { + Header string `json:"header"` + Present bool `json:"present"` + Value string `json:"value,omitempty"` + Severity string `json:"severity"` + Note string `json:"note"` +} + +type recommendedHeader struct { + name string + severity string +} + +var recommendedHeaders = []recommendedHeader{ + {"Strict-Transport-Security", "high"}, + {"Content-Security-Policy", "medium"}, + {"X-Frame-Options", "medium"}, + {"X-Content-Type-Options", "low"}, + {"Referrer-Policy", "low"}, + {"Permissions-Policy", "low"}, + {"Cross-Origin-Opener-Policy", "low"}, +} + +// headers that leak server/framework details when present. +var disclosureHeaders = []string{"Server", "X-Powered-By", "X-AspNet-Version", "X-AspNetMvc-Version"} + +const hstsMinMaxAge = 31536000 // a year, in seconds + +func SecurityHeaders(url string, timeout time.Duration, logdir string) (SecurityHeaderResults, error) { + log := output.Module("SECHEADERS") + log.Start() + + sanitizedURL := strings.Split(url, "://")[1] + + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "Security Header Analysis"); err != nil { + log.Error("Error creating log file: %v", err) + return nil, err + } + } + + client := &http.Client{ + Timeout: timeout, + } + + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + results := gradeSecurityHeaders(resp.Header, strings.HasPrefix(url, "https://")) + + for _, r := range results { + line := r.Header + " " + r.Note + log.Warn("%s [%s]", line, r.Severity) + if logdir != "" { + _ = logger.Write(sanitizedURL, logdir, line+" ["+r.Severity+"]\n") + } + } + + if len(results) == 0 { + log.Success("all recommended security headers present") + } + + log.Complete(len(results), "issues") + return results, nil +} + +func gradeSecurityHeaders(header http.Header, https bool) SecurityHeaderResults { + var results SecurityHeaderResults + + for _, h := range recommendedHeaders { + // hsts does nothing over plain http, so don't flag its absence there + if h.name == "Strict-Transport-Security" && !https { + continue + } + + value := header.Get(h.name) + switch { + case value == "": + results = append(results, SecurityHeaderResult{ + Header: h.name, + Severity: h.severity, + Note: "missing", + }) + case h.name == "Strict-Transport-Security" && hstsMaxAge(value) < hstsMinMaxAge: + results = append(results, SecurityHeaderResult{ + Header: h.name, + Present: true, + Value: value, + Severity: h.severity, + Note: "max-age too short", + }) + case h.name == "X-Content-Type-Options" && !strings.EqualFold(value, "nosniff"): + results = append(results, SecurityHeaderResult{ + Header: h.name, + Present: true, + Value: value, + Severity: "low", + Note: "should be nosniff", + }) + } + } + + for _, name := range disclosureHeaders { + if value := header.Get(name); value != "" { + results = append(results, SecurityHeaderResult{ + Header: name, + Present: true, + Value: value, + Severity: "low", + Note: "discloses " + value, + }) + } + } + + return results +} + +// hstsMaxAge returns the max-age seconds from an hsts value, or 0 if absent. +func hstsMaxAge(value string) int { + for _, part := range strings.Split(value, ";") { + if age, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(part)), "max-age="); ok { + n, err := strconv.Atoi(strings.TrimSpace(age)) + if err != nil { + return 0 + } + return n + } + } + return 0 +} diff --git a/internal/scan/securityheaders_test.go b/internal/scan/securityheaders_test.go new file mode 100644 index 0000000..60298a9 --- /dev/null +++ b/internal/scan/securityheaders_test.go @@ -0,0 +1,154 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · 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" +) + +func buildHeader(kv map[string]string) http.Header { + h := http.Header{} + for k, v := range kv { + h.Set(k, v) + } + return h +} + +func findFinding(results SecurityHeaderResults, name string) (SecurityHeaderResult, bool) { + for _, r := range results { + if r.Header == name { + return r, true + } + } + return SecurityHeaderResult{}, false +} + +func TestGradeSecurityHeaders_MissingOverHTTPS(t *testing.T) { + results := gradeSecurityHeaders(http.Header{}, true) + + for _, h := range recommendedHeaders { + f, ok := findFinding(results, h.name) + if !ok { + t.Errorf("expected %s to be flagged", h.name) + continue + } + if f.Present { + t.Errorf("%s should not be marked present", h.name) + } + if f.Severity != h.severity { + t.Errorf("%s severity = %q, want %q", h.name, f.Severity, h.severity) + } + } +} + +func TestGradeSecurityHeaders_HSTSSkippedOverHTTP(t *testing.T) { + results := gradeSecurityHeaders(http.Header{}, false) + if _, ok := findFinding(results, "Strict-Transport-Security"); ok { + t.Error("HSTS should only be graded for https targets") + } +} + +func TestGradeSecurityHeaders_AllPresent(t *testing.T) { + h := buildHeader(map[string]string{ + "Strict-Transport-Security": "max-age=63072000; includeSubDomains", + "Content-Security-Policy": "default-src 'self'", + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "geolocation=()", + "Cross-Origin-Opener-Policy": "same-origin", + }) + + if results := gradeSecurityHeaders(h, true); len(results) != 0 { + t.Errorf("expected no findings, got %d: %+v", len(results), results) + } +} + +func TestGradeSecurityHeaders_ContentTypeNotNosniff(t *testing.T) { + h := buildHeader(map[string]string{ + "Strict-Transport-Security": "max-age=63072000", + "Content-Security-Policy": "default-src 'self'", + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "sniff", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "geolocation=()", + "Cross-Origin-Opener-Policy": "same-origin", + }) + + f, ok := findFinding(gradeSecurityHeaders(h, true), "X-Content-Type-Options") + if !ok { + t.Fatal("expected X-Content-Type-Options to be flagged when not nosniff") + } + if !f.Present || f.Value != "sniff" { + t.Errorf("finding = %+v, want present with value sniff", f) + } +} + +func TestGradeSecurityHeaders_WeakHSTS(t *testing.T) { + // max-age=0 actively disables hsts, so a present header still has to be flagged + h := buildHeader(map[string]string{"Strict-Transport-Security": "max-age=0"}) + + f, ok := findFinding(gradeSecurityHeaders(h, true), "Strict-Transport-Security") + if !ok { + t.Fatal("expected a short-lived hsts header to be flagged") + } + if !f.Present || f.Severity != "high" { + t.Errorf("finding = %+v, want present high", f) + } +} + +func TestGradeSecurityHeaders_Disclosure(t *testing.T) { + h := buildHeader(map[string]string{ + "Server": "Apache/2.4.1 (Ubuntu)", + "X-Powered-By": "PHP/8.1.2", + }) + + results := gradeSecurityHeaders(h, false) + for _, name := range []string{"Server", "X-Powered-By"} { + f, ok := findFinding(results, name) + if !ok { + t.Errorf("expected disclosure finding for %s", name) + continue + } + if !f.Present || f.Severity != "low" { + t.Errorf("%s finding = %+v, want present low", name, f) + } + } +} + +func TestSecurityHeaders_LiveResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + w.Header().Set("Server", "nginx/1.25.3") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + results, err := SecurityHeaders(server.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("SecurityHeaders returned error: %v", err) + } + + if _, ok := findFinding(results, "X-Frame-Options"); ok { + t.Error("X-Frame-Options was set, should not be flagged") + } + if _, ok := findFinding(results, "Content-Security-Policy"); !ok { + t.Error("expected missing Content-Security-Policy to be flagged") + } + if _, ok := findFinding(results, "Server"); !ok { + t.Error("expected Server disclosure to be flagged") + } +} diff --git a/sif.go b/sif.go index 0d2bea5..e16a58f 100644 --- a/sif.go +++ b/sif.go @@ -321,6 +321,16 @@ func (app *App) Run() error { } } + if app.settings.SecurityHeaders { + result, err := scan.SecurityHeaders(url, app.settings.Timeout, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running Security Header Analysis: %s", err) + } else { + moduleResults = append(moduleResults, NewModuleResult(result)) + scansRun = append(scansRun, "Security Headers") + } + } + if app.settings.CloudStorage { result, err := scan.CloudStorage(url, app.settings.Timeout, app.settings.LogDir) if err != nil {