package http import ( "crypto/tls" "fmt" "net" nethttp "net/http" "time" "arcline-audit/internal/types" ) const maxRedirects = 20 // securityHeaders maps header names to labels for reporting. var securityHeaders = map[string]string{ "Strict-Transport-Security": "HSTS", "Content-Security-Policy": "CSP", "X-Frame-Options": "X-Frame-Options", "X-Content-Type-Options": "X-Content-Type-Options", "Referrer-Policy": "Referrer-Policy", } // Run performs all HTTP checks for the given domain. func Run(domain string) types.HTTPResult { client := &nethttp.Client{ Timeout: 15 * time.Second, CheckRedirect: func(req *nethttp.Request, via []*nethttp.Request) error { if len(via) >= maxRedirects { return fmt.Errorf("too many redirects") } return nil }, Transport: &nethttp.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, }, } // Start from HTTP to detect http→https redirect startURL := "http://" + domain var checks []types.CheckResult start := time.Now() resp, redirectHops, finalURL, redirectChecks := followRedirects(client, startURL) elapsed := time.Since(start) checks = append(checks, redirectChecks...) result := types.HTTPResult{ Checks: checks, ResponseTime: elapsed, FinalURL: finalURL, RedirectHops: redirectHops, } if resp == nil { result.Checks = result.Checks[:len(result.Checks):len(result.Checks)] return result } defer resp.Body.Close() // Check security headers result.Checks = append(result.Checks, checkSecurityHeaders(resp.Header)...) // Check server header disclosure result.Checks = append(result.Checks, checkServerHeader(resp.Header)...) // Response time result.Checks = append(result.Checks, types.CheckResult{ Status: types.StatusOK, Message: fmt.Sprintf("response time %dms", elapsed.Milliseconds()), }) return result } func followRedirects(client *nethttp.Client, startURL string) (*nethttp.Response, int, string, []types.CheckResult) { var checks []types.CheckResult visited := make(map[string]bool) hops := 0 current := startURL for hops <= maxRedirects { visited[current] = true req, err := nethttp.NewRequest("GET", current, nil) if err != nil { checks = append(checks, types.CheckResult{ Status: types.StatusFail, Message: fmt.Sprintf("request error: %v", err), }) return nil, hops, current, checks } resp, err := client.Do(req) if err != nil { checks = append(checks, types.CheckResult{ Status: types.StatusFail, Message: fmt.Sprintf("connection error: %v", err), }) return nil, hops, current, checks } // If non-redirect response if resp.StatusCode < 300 || resp.StatusCode >= 400 { if hops > 0 { checks = append(checks, types.CheckResult{ Status: types.StatusOK, Message: fmt.Sprintf("redirects http → https (%d hop(s))", hops), }) } return resp, hops, current, checks } // Follow redirect hops++ loc, err := resp.Location() resp.Body.Close() if err != nil { checks = append(checks, types.CheckResult{ Status: types.StatusOK, Message: fmt.Sprintf("redirects followed: %d hop(s)", hops), }) return resp, hops, current, checks } nextURL := loc.String() // Detect redirect loop if visited[nextURL] { checks = append(checks, types.CheckResult{ Status: types.StatusFail, Message: "redirect loop detected", }) return resp, hops, nextURL, checks } current = nextURL } checks = append(checks, types.CheckResult{ Status: types.StatusFail, Message: fmt.Sprintf("too many redirects (%d)", hops), }) return nil, hops, current, checks } func checkSecurityHeaders(headers nethttp.Header) []types.CheckResult { var checks []types.CheckResult for header, label := range securityHeaders { val := headers.Get(header) if val != "" { checks = append(checks, types.CheckResult{ Status: types.StatusOK, Message: fmt.Sprintf("%s header present", label), }) } else { switch header { case "Strict-Transport-Security": checks = append(checks, types.CheckResult{ Status: types.StatusWarn, Message: "no HSTS header", }) case "Content-Security-Policy": checks = append(checks, types.CheckResult{ Status: types.StatusInfo, Message: "no CSP header", }) case "X-Frame-Options": checks = append(checks, types.CheckResult{ Status: types.StatusInfo, Message: "no X-Frame-Options header", }) default: checks = append(checks, types.CheckResult{ Status: types.StatusInfo, Message: fmt.Sprintf("no %s header", label), }) } } } return checks } func checkServerHeader(headers nethttp.Header) []types.CheckResult { server := headers.Get("Server") if server == "" { return []types.CheckResult{{Status: types.StatusOK, Message: "no Server header disclosed"}} } // Trim long server strings if len(server) > 60 { server = server[:57] + "..." } return []types.CheckResult{{Status: types.StatusWarn, Message: fmt.Sprintf("Server header disclosed: %s", server)}} }