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>
This commit is contained in:
192
internal/http/checker.go
Normal file
192
internal/http/checker.go
Normal file
@@ -0,0 +1,192 @@
|
||||
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)}}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user