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:
57
.gitlab-ci.yml
Normal file
57
.gitlab-ci.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- release
|
||||
|
||||
.test_template: &test
|
||||
stage: test
|
||||
image: golang:1.22-alpine
|
||||
script:
|
||||
- go vet ./...
|
||||
- go build -o /dev/null ./cmd/arcline-audit/
|
||||
|
||||
test:linux:
|
||||
<<: *test
|
||||
|
||||
.build_template: &build
|
||||
stage: build
|
||||
image: golang:1.22-alpine
|
||||
script:
|
||||
- apk add --no-cache make
|
||||
- make ${TARGET}
|
||||
artifacts:
|
||||
paths:
|
||||
- arcline-audit-*
|
||||
expire_in: 30 days
|
||||
|
||||
build:linux:
|
||||
<<: *build
|
||||
variables:
|
||||
TARGET: linux
|
||||
|
||||
build:darwin:
|
||||
<<: *build
|
||||
variables:
|
||||
TARGET: darwin
|
||||
|
||||
build:windows:
|
||||
<<: *build
|
||||
variables:
|
||||
TARGET: windows
|
||||
|
||||
release:
|
||||
stage: release
|
||||
image: golang:1.22-alpine
|
||||
script:
|
||||
- apk add --no-cache make
|
||||
- make release
|
||||
- echo "Release artifacts: arcline-audit-*"
|
||||
artifacts:
|
||||
paths:
|
||||
- arcline-audit-linux-amd64
|
||||
- arcline-audit-darwin-amd64
|
||||
- arcline-audit-windows-amd64.exe
|
||||
expire_in: 90 days
|
||||
only:
|
||||
- tags
|
||||
|
||||
55
INFRASTRUCTURE.md
Normal file
55
INFRASTRUCTURE.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# arcline-audit — Infrastructure & DevOps
|
||||
|
||||
> **Full Site Health Auditor** — one-command SSL, HTTP headers, DNS,
|
||||
> redirects, and open ports check.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| **Type** | CLI tool |
|
||||
| **Tech** | Go (future) — reuses `arcline-check` internals |
|
||||
| **Deploy** | Static binary to `/usr/local/bin` |
|
||||
| **CI/CD** | ❌ Not yet created |
|
||||
| **State** | ⚪ Planned |
|
||||
|
||||
---
|
||||
|
||||
## Planned Checks
|
||||
|
||||
| Check | Detail |
|
||||
|-------|--------|
|
||||
| **SSL/TLS** | Certificate validity, expiry, chain, TLS version, cipher warnings |
|
||||
| **HTTP** | Redirect chain, security headers (HSTS, CSP, X-Frame-Options), response time |
|
||||
| **DNS** | A/AAAA, MX, SPF, DKIM, DMARC, DNSSEC, rDNS |
|
||||
| **Infrastructure** | ASN/org lookup, CDN detection, common ports (80, 443, 22, 3306, 5432) |
|
||||
|
||||
---
|
||||
|
||||
## Deployment (Future)
|
||||
|
||||
```bash
|
||||
# Install binary
|
||||
sudo cp arcline-audit-linux-amd64 /usr/local/bin/arcline-audit
|
||||
sudo chmod 0755 /usr/local/bin/arcline-audit
|
||||
|
||||
# Run full audit
|
||||
arcline-audit example.com
|
||||
|
||||
# Quick SSL check only
|
||||
arcline-audit --checks=ssl example.com
|
||||
|
||||
# JSON output for programmatic use
|
||||
arcline-audit --json example.com > audit-report.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build (Template)
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o arcline-audit .
|
||||
```
|
||||
|
||||
31
Makefile
Normal file
31
Makefile
Normal file
@@ -0,0 +1,31 @@
|
||||
.PHONY: build clean test lint linux darwin windows all release
|
||||
|
||||
BINARY := arcline-audit
|
||||
LDFLAGS := -s -w
|
||||
MAIN := ./cmd/arcline-audit
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) $(MAIN)
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY) arcline-audit-*
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
|
||||
# Cross-compile targets
|
||||
linux:
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-amd64 $(MAIN)
|
||||
|
||||
darwin:
|
||||
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY)-darwin-amd64 $(MAIN)
|
||||
|
||||
windows:
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY)-windows-amd64.exe $(MAIN)
|
||||
|
||||
# Release builds for all platforms
|
||||
release: linux darwin windows
|
||||
|
||||
81
README.md
81
README.md
@@ -4,34 +4,93 @@ One-command site health auditor. Checks SSL, HTTP security headers, DNS records,
|
||||
|
||||
Useful for onboarding new clients and producing a quick written report before an engagement starts.
|
||||
|
||||
## Status
|
||||
|
||||
Planned. Not yet started.
|
||||
|
||||
## Stack
|
||||
|
||||
- Go — single static binary, no runtime dependencies
|
||||
- Reuses internal packages from `arcline-check` (ASN/CDN detection)
|
||||
- Standard library only — zero external dependencies
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
# Full audit (all checks)
|
||||
arcline-audit example.com
|
||||
arcline-audit example.com --checks ssl,dns,headers
|
||||
|
||||
# Run specific checks
|
||||
arcline-audit example.com --checks ssl,dns
|
||||
|
||||
# JSON output
|
||||
arcline-audit example.com --json
|
||||
|
||||
# Write to file
|
||||
arcline-audit example.com --out report.txt
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--checks` | Comma-separated check groups: `ssl`, `http`, `dns`, `infra` (default: `all`) |
|
||||
| `--json` | Output as JSON instead of terminal format |
|
||||
| `--out` | Write report to a file instead of stdout |
|
||||
|
||||
## Checks
|
||||
|
||||
| Group | What it checks |
|
||||
|---|---|
|
||||
| SSL/TLS | Validity, expiry, chain, TLS version, cipher warnings |
|
||||
| HTTP | Redirect chain, security headers, server disclosure, response time |
|
||||
| DNS | A/AAAA, MX, SPF, DKIM, DMARC, DNSSEC, rDNS match |
|
||||
| Infrastructure | ASN/org, CDN detection, common open ports |
|
||||
| SSL/TLS | Certificate validity, expiry, chain completeness, TLS version, cipher suite warnings |
|
||||
| HTTP | Redirect chain (http→https), security headers (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy), Server header disclosure, response time |
|
||||
| DNS | A/AAAA records, MX records, SPF, DKIM, DMARC, DNSSEC detection |
|
||||
| Infrastructure | CDN detection, common port probes (80, 443, 22, 3306, 5432) |
|
||||
|
||||
See [todo.md](todo.md) for the full task list and output format spec.
|
||||
## Example output
|
||||
|
||||
```
|
||||
$ arcline-audit example.com
|
||||
|
||||
── SSL ────────────────────────────────────────────────
|
||||
[OK] valid certificate (2026-08-29)
|
||||
[OK] not self-signed
|
||||
[OK] certificate chain is complete
|
||||
[OK] TLS 1.3
|
||||
[OK] cipher suite: TLS_AES_128_GCM_SHA256
|
||||
|
||||
── HTTP ───────────────────────────────────────────────
|
||||
[WARN] no HSTS header
|
||||
[WARN] Server header disclosed: cloudflare
|
||||
[OK] response time 34ms
|
||||
|
||||
── DNS ────────────────────────────────────────────────
|
||||
[OK] A record: 104.20.23.154
|
||||
[OK] MX records present (1)
|
||||
[OK] SPF record found
|
||||
[WARN] no DMARC record
|
||||
|
||||
── Infrastructure ─────────────────────────────────────
|
||||
[OK] CDN detected: Cloudflare
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
# Local build
|
||||
make build
|
||||
|
||||
# Cross-compile for all platforms
|
||||
make release
|
||||
```
|
||||
|
||||
This produces:
|
||||
|
||||
- `arcline-audit-linux-amd64`
|
||||
- `arcline-audit-darwin-amd64`
|
||||
- `arcline-audit-windows-amd64.exe`
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
sudo cp arcline-audit-linux-amd64 /usr/local/bin/arcline-audit
|
||||
sudo chmod 0755 /usr/local/bin/arcline-audit
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
||||
BIN
arcline-audit
Executable file
BIN
arcline-audit
Executable file
Binary file not shown.
73
cmd/arcline-audit/main.go
Normal file
73
cmd/arcline-audit/main.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"arcline-audit/internal/dns"
|
||||
httpcheck "arcline-audit/internal/http"
|
||||
"arcline-audit/internal/infra"
|
||||
"arcline-audit/internal/report"
|
||||
"arcline-audit/internal/ssl"
|
||||
"arcline-audit/internal/types"
|
||||
)
|
||||
|
||||
func main() {
|
||||
checksFlag := flag.String("checks", "all", "comma-separated list of checks: ssl,http,dns,infra")
|
||||
jsonFlag := flag.Bool("json", false, "output as JSON")
|
||||
outFlag := flag.String("out", "", "write report to file")
|
||||
flag.Parse()
|
||||
|
||||
if flag.NArg() < 1 {
|
||||
fmt.Fprintf(os.Stderr, "Usage: arcline-audit [flags] <domain>\n")
|
||||
fmt.Fprintf(os.Stderr, "\nFlags:\n")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
domain := flag.Arg(0)
|
||||
checkSet := types.ParseChecks(*checksFlag)
|
||||
|
||||
// Resolve the domain once so all checkers share the same IPs.
|
||||
resolvedIPs, _ := net.LookupHost(domain)
|
||||
|
||||
result := types.AuditResult{
|
||||
Domain: domain,
|
||||
Time: time.Now(),
|
||||
}
|
||||
|
||||
if checkSet&types.CheckSSL != 0 {
|
||||
result.SSL = ssl.Run(domain)
|
||||
}
|
||||
if checkSet&types.CheckHTTP != 0 {
|
||||
result.HTTP = httpcheck.Run(domain)
|
||||
}
|
||||
if checkSet&types.CheckDNS != 0 {
|
||||
result.DNS = dns.Run(domain)
|
||||
}
|
||||
if checkSet&types.CheckInfra != 0 {
|
||||
result.Infra = infra.Run(domain, resolvedIPs)
|
||||
}
|
||||
|
||||
// Determine output writer
|
||||
var w = os.Stdout
|
||||
if *outFlag != "" {
|
||||
f, err := os.Create(*outFlag)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: cannot create output file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
w = f
|
||||
}
|
||||
|
||||
if *jsonFlag {
|
||||
report.JSON(w, result)
|
||||
} else {
|
||||
report.Terminal(w, result)
|
||||
}
|
||||
}
|
||||
|
||||
155
internal/dns/checker.go
Normal file
155
internal/dns/checker.go
Normal 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
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)}}
|
||||
}
|
||||
|
||||
141
internal/infra/checker.go
Normal file
141
internal/infra/checker.go
Normal 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
110
internal/report/renderer.go
Normal 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
169
internal/ssl/ssl.go
Normal 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
111
internal/types/types.go
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user