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.
This commit is contained in:
vmfunc
2026-06-08 18:15:55 -07:00
parent 1a1ff446d8
commit 7efd62c804
5 changed files with 331 additions and 0 deletions
+2
View File
@@ -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)"),
+3
View File
@@ -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)
+162
View File
@@ -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
}
+154
View File
@@ -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")
}
}
+10
View File
@@ -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 {