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:
Blake Ridgway
2026-06-23 05:08:34 -05:00
parent 088bb7e138
commit fce90f458c
13 changed files with 1167 additions and 11 deletions

155
internal/dns/checker.go Normal file
View File

@@ -0,0 +1,155 @@
package dns
import (
"fmt"
"net"
"strings"
"arcline-audit/internal/types"
)
// Run performs all DNS checks for the given domain.
func Run(domain string) types.DNSResult {
var checks []types.CheckResult
// A records
checks = append(checks, checkARecords(domain)...)
// AAAA records
checks = append(checks, checkAAAARecords(domain)...)
// MX records
checks = append(checks, checkMXRecords(domain)...)
// TXT records (SPF, DKIM, DMARC)
checks = append(checks, checkTXTRecords(domain)...)
// DNSSEC
checks = append(checks, checkDNSSEC(domain)...)
return types.DNSResult{Checks: checks}
}
func checkARecords(domain string) []types.CheckResult {
ips, err := net.LookupHost(domain)
if err != nil {
return []types.CheckResult{{Status: types.StatusFail, Message: fmt.Sprintf("no A record: %v", err)}}
}
var ipv4 []string
for _, ip := range ips {
if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() != nil {
ipv4 = append(ipv4, ip)
}
}
if len(ipv4) == 0 {
return []types.CheckResult{{Status: types.StatusFail, Message: "no A record found"}}
}
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("A record: %s", strings.Join(ipv4, ", "))}}
}
func checkAAAARecords(domain string) []types.CheckResult {
ips, err := net.LookupHost(domain)
if err != nil {
return nil
}
var ipv6 []string
for _, ip := range ips {
if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() == nil {
ipv6 = append(ipv6, ip)
}
}
if len(ipv6) == 0 {
return nil // Not a warning; many sites don't have IPv6
}
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("AAAA record: %s", strings.Join(ipv6, ", "))}}
}
func checkMXRecords(domain string) []types.CheckResult {
mxs, err := net.LookupMX(domain)
if err != nil || len(mxs) == 0 {
return []types.CheckResult{{Status: types.StatusWarn, Message: "no MX records found"}}
}
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("MX records present (%d)", len(mxs))}}
}
func checkTXTRecords(domain string) []types.CheckResult {
txts, err := net.LookupTXT(domain)
if err != nil {
return []types.CheckResult{
{Status: types.StatusWarn, Message: "no TXT records found"},
}
}
var checks []types.CheckResult
hasSPF := false
hasDMARC := false
for _, txt := range txts {
if strings.HasPrefix(txt, "v=spf1") {
hasSPF = true
}
}
if hasSPF {
checks = append(checks, types.CheckResult{Status: types.StatusOK, Message: "SPF record found"})
} else {
checks = append(checks, types.CheckResult{Status: types.StatusWarn, Message: "no SPF record"})
}
// DKIM is checked via selector lookup
dkimFound := checkDKIM(domain)
if dkimFound {
checks = append(checks, types.CheckResult{Status: types.StatusOK, Message: "DKIM record found (default._domainkey)"})
} else {
checks = append(checks, types.CheckResult{Status: types.StatusInfo, Message: "no DKIM record (default._domainkey)"})
}
// DMARC is on _dmarc subdomain
dmarcTxts, err := net.LookupTXT("_dmarc." + domain)
if err == nil {
for _, txt := range dmarcTxts {
if strings.HasPrefix(txt, "v=DMARC1") {
hasDMARC = true
break
}
}
}
if hasDMARC {
checks = append(checks, types.CheckResult{Status: types.StatusOK, Message: "DMARC record found"})
} else {
checks = append(checks, types.CheckResult{Status: types.StatusWarn, Message: "no DMARC record"})
}
return checks
}
func checkDKIM(domain string) bool {
txts, err := net.LookupTXT("default._domainkey." + domain)
if err != nil {
return false
}
for _, txt := range txts {
if strings.Contains(txt, "v=DKIM1") {
return true
}
}
return false
}
func checkDNSSEC(domain string) []types.CheckResult {
// DNSSEC is checked via looking up the DS record on the parent zone.
// For simplicity, we check if the domain has RRSIG records by looking up
// the NS records and checking for authenticated data.
// A true DNSSEC check requires a validating resolver. We do a best-effort
// check by seeing if the resolver returns authenticated data headers.
// As a simple heuristic, we check for DNSKEY records.
_, err := net.LookupTXT("_dnssec." + domain)
if err == nil {
return []types.CheckResult{{Status: types.StatusOK, Message: "DNSSEC appears enabled"}}
}
// Try to retrieve DNSKEY records as a secondary heuristic
// Note: Go's net package doesn't expose DNSKEY record types directly.
// A full DNSSEC check would require a custom DNS resolver.
return []types.CheckResult{{Status: types.StatusInfo, Message: "DNSSEC check requires custom resolver (not verified)"}}
}

192
internal/http/checker.go Normal file
View 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)}}
}

141
internal/infra/checker.go Normal file
View File

@@ -0,0 +1,141 @@
package infra
import (
"fmt"
"net"
"strings"
"sync"
"time"
"arcline-audit/internal/types"
)
// CDN ranges for common CDN providers (simplified detection based on IP prefixes).
var cdnRanges = map[string][]string{
"Cloudflare": {
"104.16.", "104.17.", "104.18.", "104.19.", "104.20.", "104.21.",
"104.22.", "104.23.", "104.24.", "104.25.", "104.26.", "104.27.",
"104.28.", "104.29.", "104.30.", "104.31.",
"172.64.", "172.65.", "172.66.", "172.67.", "172.68.", "172.69.",
"172.70.", "172.71.",
},
"Fastly": {
"151.101.", "199.232.", "23.235.", "146.75.",
},
"Amazon CloudFront": {
"13.32.", "13.33.", "13.224.", "13.225.", "13.226.", "13.227.",
"13.249.", "54.192.", "54.230.", "54.239.",
},
}
// commonPorts are the ports to probe.
var commonPorts = map[int]string{
80: "HTTP",
443: "HTTPS",
22: "SSH",
3306: "MySQL",
5432: "PostgreSQL",
}
// Run performs all infrastructure checks for the given domain.
// It accepts pre-resolved IPs to avoid redundant lookups; if empty, it resolves the domain itself.
func Run(domain string, resolvedIPs []string) types.InfraResult {
var result types.InfraResult
ips := resolvedIPs
if len(ips) == 0 {
var err error
ips, err = net.LookupHost(domain)
if err != nil || len(ips) == 0 {
result.Checks = append(result.Checks, types.CheckResult{
Status: types.StatusFail, Message: fmt.Sprintf("cannot resolve domain: %v", err),
})
return result
}
}
// Select the first IPv4 address for port scanning, but check all IPs for CDN.
targetIP := selectIPv4(ips)
// CDN detection (check all IPs)
cdn := detectCDNAny(ips)
if cdn != "" {
result.Checks = append(result.Checks, types.CheckResult{
Status: types.StatusOK,
Message: fmt.Sprintf("CDN detected: %s", cdn),
})
} else {
result.Checks = append(result.Checks, types.CheckResult{
Status: types.StatusOK,
Message: "not behind a CDN",
})
}
result.CDN = cdn
// Common ports check
result.Checks = append(result.Checks, checkPorts(targetIP)...)
return result
}
func selectIPv4(ips []string) string {
for _, ip := range ips {
if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() != nil {
return ip
}
}
return ips[0]
}
func detectCDNAny(ips []string) string {
for _, ip := range ips {
if cdn := detectCDN(ip); cdn != "" {
return cdn
}
}
return ""
}
func detectCDN(ip string) string {
for provider, prefixes := range cdnRanges {
for _, prefix := range prefixes {
if strings.HasPrefix(ip, prefix) {
return provider
}
}
}
return ""
}
func checkPorts(ip string) []types.CheckResult {
var checks []types.CheckResult
var mu sync.Mutex
var wg sync.WaitGroup
host := ip
if strings.Contains(ip, ":") {
host = "[" + ip + "]"
}
for port, name := range commonPorts {
wg.Add(1)
go func(port int, name string) {
defer wg.Done()
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err == nil {
conn.Close()
mu.Lock()
checks = append(checks, types.CheckResult{
Status: types.StatusInfo,
Message: fmt.Sprintf("port %d (%s) open", port, name),
})
mu.Unlock()
}
}(port, name)
}
wg.Wait()
return checks
}

110
internal/report/renderer.go Normal file
View File

@@ -0,0 +1,110 @@
package report
import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
"arcline-audit/internal/types"
)
const sectionWidth = 55
// Terminal renders the audit result to the given writer in a human-readable terminal format.
func Terminal(w io.Writer, result types.AuditResult) {
fmt.Fprintf(w, "\n")
printSection(w, "SSL", result.SSL.Checks, func() {
if result.SSL.Expiry != (time.Time{}) {
fmt.Fprintf(w, " Issuer %s\n", result.SSL.Issuer)
fmt.Fprintf(w, " Expires %s (%d days)\n",
result.SSL.Expiry.Format("2006-01-02"), result.SSL.DaysLeft)
if result.SSL.TLSVersion != "" {
fmt.Fprintf(w, " TLS %s\n", result.SSL.TLSVersion)
}
}
})
printSection(w, "HTTP", result.HTTP.Checks, func() {
if result.HTTP.FinalURL != "" {
fmt.Fprintf(w, " Final URL %s\n", result.HTTP.FinalURL)
}
fmt.Fprintf(w, " Response %dms\n", result.HTTP.ResponseTime.Milliseconds())
})
printSection(w, "DNS", result.DNS.Checks, nil)
printSection(w, "Infrastructure", result.Infra.Checks, func() {
if result.Infra.CDN != "" {
fmt.Fprintf(w, " CDN %s\n", result.Infra.CDN)
}
if result.Infra.ASN != "" {
fmt.Fprintf(w, " ASN %s\n", result.Infra.ASN)
}
if result.Infra.Org != "" {
fmt.Fprintf(w, " Org %s\n", result.Infra.Org)
}
})
fmt.Fprintf(w, "\n")
}
// JSON renders the audit result as indented JSON.
func JSON(w io.Writer, result types.AuditResult) {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.Encode(result)
}
// PlainText renders the audit result as plain text (no ANSI codes).
func PlainText(w io.Writer, result types.AuditResult) {
sections := []struct {
name string
checks []types.CheckResult
}{
{"SSL", result.SSL.Checks},
{"HTTP", result.HTTP.Checks},
{"DNS", result.DNS.Checks},
{"Infrastructure", result.Infra.Checks},
}
fmt.Fprintf(w, "Audit for %s — %s\n\n", result.Domain, result.Time.Format(time.RFC3339))
for _, sec := range sections {
fmt.Fprintf(w, "── %s %s\n", sec.name, strings.Repeat("─", sectionWidth-5-len(sec.name)))
for _, c := range sec.checks {
fmt.Fprintf(w, "[%s] %s\n", c.Status, c.Message)
}
fmt.Fprintf(w, "\n")
}
}
func printSection(w io.Writer, name string, checks []types.CheckResult, extra func()) {
header := fmt.Sprintf("── %s ", name)
fmt.Fprintf(w, " %s%s\n", header, strings.Repeat("─", sectionWidth-len(header)-2))
for _, c := range checks {
prefix := statusPrefix(c.Status)
fmt.Fprintf(w, " %s %s\n", prefix, c.Message)
}
if extra != nil {
extra()
}
}
func statusPrefix(s types.Status) string {
switch s {
case types.StatusOK:
return "[OK] "
case types.StatusWarn:
return "[WARN]"
case types.StatusFail:
return "[FAIL]"
case types.StatusInfo:
return "[INFO]"
default:
return "[????]"
}
}

169
internal/ssl/ssl.go Normal file
View File

@@ -0,0 +1,169 @@
package ssl
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"time"
"arcline-audit/internal/types"
)
// insecureCipherSuites is a set of cipher suite IDs considered weak.
var insecureCipherSuites = map[uint16]bool{
tls.TLS_RSA_WITH_RC4_128_SHA: true,
tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA: true,
tls.TLS_RSA_WITH_AES_128_CBC_SHA: true,
tls.TLS_RSA_WITH_AES_256_CBC_SHA: true,
tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA: true,
tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA: true,
tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA: true,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA: true,
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: true,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA: true,
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: true,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256: true,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384: true,
}
// Run performs all SSL/TLS checks for the given domain.
func Run(domain string) types.SSLResult {
addr := net.JoinHostPort(domain, "443")
dialer := &net.Dialer{Timeout: 10 * time.Second}
conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
InsecureSkipVerify: true,
})
if err != nil {
return types.SSLResult{
Checks: []types.CheckResult{
{Status: types.StatusFail, Message: fmt.Sprintf("failed to connect: %v", err)},
},
}
}
defer conn.Close()
state := conn.ConnectionState()
var checks []types.CheckResult
// Certificate validity and details
checks = append(checks, checkCertificates(state.PeerCertificates)...)
// TLS version
checks = append(checks, checkTLSVersion(state.Version)...)
// Cipher suite
checks = append(checks, checkCipherSuite(state.CipherSuite)...)
result := types.SSLResult{Checks: checks}
if len(state.PeerCertificates) > 0 {
cert := state.PeerCertificates[0]
result.Issuer = cert.Issuer.String()
result.Expiry = cert.NotAfter
result.DaysLeft = int(time.Until(cert.NotAfter).Hours() / 24)
}
result.TLSVersion = tlsVersionName(state.Version)
return result
}
func checkCertificates(certs []*x509.Certificate) []types.CheckResult {
var checks []types.CheckResult
if len(certs) == 0 {
checks = append(checks, types.CheckResult{
Status: types.StatusFail, Message: "no certificates presented",
})
return checks
}
leaf := certs[0]
now := time.Now()
// Check expiry
if now.After(leaf.NotAfter) {
checks = append(checks, types.CheckResult{
Status: types.StatusFail,
Message: fmt.Sprintf("certificate expired on %s", leaf.NotAfter.Format("2006-01-02")),
})
} else {
daysLeft := int(time.Until(leaf.NotAfter).Hours() / 24)
if daysLeft < 30 {
checks = append(checks, types.CheckResult{
Status: types.StatusWarn,
Message: fmt.Sprintf("certificate expires in %d days (%s)", daysLeft, leaf.NotAfter.Format("2006-01-02")),
})
} else {
checks = append(checks, types.CheckResult{
Status: types.StatusOK,
Message: fmt.Sprintf("valid certificate (%s)", leaf.NotAfter.Format("2006-01-02")),
})
}
}
// Check self-signed
if leaf.Issuer.String() == leaf.Subject.String() {
checks = append(checks, types.CheckResult{
Status: types.StatusWarn, Message: "self-signed certificate",
})
} else {
checks = append(checks, types.CheckResult{
Status: types.StatusOK, Message: "not self-signed",
})
}
// Check chain completeness
if len(certs) >= 2 {
checks = append(checks, types.CheckResult{
Status: types.StatusOK, Message: "certificate chain is complete",
})
} else {
checks = append(checks, types.CheckResult{
Status: types.StatusWarn, Message: "certificate chain may be incomplete",
})
}
return checks
}
func checkTLSVersion(vers uint16) []types.CheckResult {
name := tlsVersionName(vers)
switch vers {
case tls.VersionTLS13:
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("TLS %s", name)}}
case tls.VersionTLS12:
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("TLS %s", name)}}
case tls.VersionTLS11, tls.VersionTLS10:
return []types.CheckResult{{Status: types.StatusWarn, Message: fmt.Sprintf("TLS %s is insecure", name)}}
case 0:
return []types.CheckResult{{Status: types.StatusFail, Message: "unknown TLS version"}}
default:
return []types.CheckResult{{Status: types.StatusInfo, Message: fmt.Sprintf("TLS %s", name)}}
}
}
func checkCipherSuite(id uint16) []types.CheckResult {
name := tls.CipherSuiteName(id)
if name == "" {
name = fmt.Sprintf("unknown (0x%04X)", id)
}
if insecureCipherSuites[id] {
return []types.CheckResult{{Status: types.StatusWarn, Message: fmt.Sprintf("weak cipher suite: %s", name)}}
}
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("cipher suite: %s", name)}}
}
func tlsVersionName(v uint16) string {
switch v {
case tls.VersionTLS10:
return "1.0"
case tls.VersionTLS11:
return "1.1"
case tls.VersionTLS12:
return "1.2"
case tls.VersionTLS13:
return "1.3"
default:
return fmt.Sprintf("0x%04X", v)
}
}

111
internal/types/types.go Normal file
View File

@@ -0,0 +1,111 @@
package types
import "time"
// Status represents the result status of a single check.
type Status string
const (
StatusOK Status = "OK"
StatusWarn Status = "WARN"
StatusFail Status = "FAIL"
StatusInfo Status = "INFO"
)
// CheckResult represents the outcome of a single audit check.
type CheckResult struct {
Status Status `json:"status"`
Message string `json:"message"`
}
// SSLResult holds all SSL/TLS check results.
type SSLResult struct {
Checks []CheckResult `json:"checks"`
Issuer string `json:"issuer,omitempty"`
Expiry time.Time `json:"expiry,omitempty"`
DaysLeft int `json:"days_left,omitempty"`
TLSVersion string `json:"tls_version,omitempty"`
}
// HTTPResult holds all HTTP check results.
type HTTPResult struct {
Checks []CheckResult `json:"checks"`
ResponseTime time.Duration `json:"response_time_ms"`
FinalURL string `json:"final_url,omitempty"`
RedirectHops int `json:"redirect_hops,omitempty"`
}
// DNSResult holds all DNS check results.
type DNSResult struct {
Checks []CheckResult `json:"checks"`
}
// InfraResult holds all infrastructure check results.
type InfraResult struct {
Checks []CheckResult `json:"checks"`
ASN string `json:"asn,omitempty"`
Org string `json:"org,omitempty"`
CDN string `json:"cdn,omitempty"`
}
// AuditResult is the top-level result containing all check sections.
type AuditResult struct {
Domain string `json:"domain"`
Time time.Time `json:"time"`
SSL SSLResult `json:"ssl"`
HTTP HTTPResult `json:"http"`
DNS DNSResult `json:"dns"`
Infra InfraResult `json:"infra"`
}
// CheckSet is a bitmask of check categories for filtering.
type CheckSet int
const (
CheckSSL CheckSet = 1 << iota
CheckHTTP
CheckDNS
CheckInfra
CheckAll = CheckSSL | CheckHTTP | CheckDNS | CheckInfra
)
// ParseChecks parses a comma-separated list of check names into a CheckSet.
func ParseChecks(s string) CheckSet {
if s == "" || s == "all" {
return CheckAll
}
var cs CheckSet
m := map[string]CheckSet{
"ssl": CheckSSL,
"http": CheckHTTP,
"dns": CheckDNS,
"infra": CheckInfra,
}
for _, name := range splitCSV(s) {
if v, ok := m[name]; ok {
cs |= v
}
}
if cs == 0 {
return CheckAll
}
return cs
}
func splitCSV(s string) []string {
var parts []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ',' {
if i > start {
parts = append(parts, s[start:i])
}
start = i + 1
}
}
if start < len(s) {
parts = append(parts, s[start:])
}
return parts
}