Files
audit/internal/http/checker.go
Blake Ridgway fce90f458c add initial Go implementation of arcline-audit
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>
2026-06-23 05:08:34 -05:00

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)}}
}