Implements the full site health auditor with four check groups: - SSL/TLS (certificate validity, expiry, chain, TLS version, ciphers) - HTTP (redirect chain, security headers, response time) - DNS (A/AAAA, MX, SPF, DKIM, DMARC, DNSSEC) - Infrastructure (CDN detection, common port probes) Includes CLI with --checks filter, --json and --out flags, cross-compile Makefile, and GitLab CI pipeline. Signed-off-by: Blake Ridgway <blake@blakeridgway.com>
193 lines
5.0 KiB
Go
193 lines
5.0 KiB
Go
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)}}
|
|
}
|
|
|