feat: T1016 dns_config probe + server-side drift detection + Crucible DNS DRIFT badge

This commit is contained in:
AetherForge
2026-05-30 23:26:50 -07:00
parent 6704933568
commit d005d5d07c
48 changed files with 4621 additions and 22 deletions

View File

@@ -270,7 +270,11 @@ func jobPayloadErrorMessage(payload json.RawMessage) (string, bool) {
if !ok {
return "", false
}
return strings.Trim(string(errMsg), `"`), true
msg := strings.Trim(string(errMsg), `"`)
if msg == "" {
return "", false
}
return msg, true
}
func (c *AgentClient) handleMessage(msg Message) {
@@ -590,6 +594,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
var lastSSH *bool
var lastPosture *PostureReport
var lastPressure *ResourcePressure
var lastDNS *DNSConfig
var postureReady bool
for {
select {
@@ -641,6 +646,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
}
}
lastPressure = collectResourcePressure()
lastDNS = probeDNS()
}
probeTick++
@@ -655,6 +661,10 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
SSHAvailable: lastSSH,
}
if lastDNS != nil {
stats.DNSServers = lastDNS.Servers
stats.DNSSearchDomains = lastDNS.SearchDomains
}
if lastPressure != nil {
stats.CPUFreqMHz = lastPressure.CPUFreqMHz
stats.CPUMaxMHz = lastPressure.CPUMaxMHz

View File

@@ -0,0 +1,42 @@
package client
import (
"encoding/json"
"strings"
)
// DNSConfig is a T1016 System Network Configuration Discovery snapshot.
// It captures the resolvers that are actually in use at probe time so the
// C2 server can detect drift between heartbeats (e.g. DHCP rogue resolver,
// or a post-compromise /etc/resolv.conf rewrite).
type DNSConfig struct {
Servers []string `json:"servers"`
SearchDomains []string `json:"search_domains,omitempty"`
}
// parseDNSJSON parses the compact JSON emitted by the Windows PS probe.
// Expected shape: {"servers":"1.1.1.1,8.8.8.8","search":"corp.local"}
func parseDNSJSON(raw string) *DNSConfig {
cfg := &DNSConfig{}
var m map[string]interface{}
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return cfg
}
if s, ok := m["servers"].(string); ok && s != "" {
for _, addr := range strings.Split(s, ",") {
addr = strings.TrimSpace(addr)
if addr != "" {
cfg.Servers = append(cfg.Servers, addr)
}
}
}
if s, ok := m["search"].(string); ok && s != "" {
for _, d := range strings.Split(s, ",") {
d = strings.TrimSpace(d)
if d != "" {
cfg.SearchDomains = append(cfg.SearchDomains, d)
}
}
}
return cfg
}

51
agent/client/dns_unix.go Normal file
View File

@@ -0,0 +1,51 @@
//go:build !windows
package client
import (
"os"
"strings"
)
// probeDNS parses /etc/resolv.conf for nameserver and search/domain lines.
// This works on Linux, macOS (without full mDNSResponder), and most BSDs.
func probeDNS() *DNSConfig {
cfg := &DNSConfig{}
data, err := os.ReadFile("/etc/resolv.conf")
if err != nil {
// On macOS, scutil --dns is the authoritative source but resolv.conf
// is usually symlinked to a managed copy — try it anyway.
return cfg
}
seenSrv := map[string]bool{}
seenSch := map[string]bool{}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
switch fields[0] {
case "nameserver":
addr := fields[1]
if addr != "127.0.0.1" && addr != "::1" && !seenSrv[addr] {
seenSrv[addr] = true
cfg.Servers = append(cfg.Servers, addr)
}
case "search", "domain":
for _, d := range fields[1:] {
if !seenSch[d] {
seenSch[d] = true
cfg.SearchDomains = append(cfg.SearchDomains, d)
}
}
}
}
return cfg
}

View File

@@ -0,0 +1,68 @@
//go:build windows
package client
import (
"os/exec"
"strings"
)
// probeDNS returns the DNS servers and search domains currently active on
// this machine's non-loopback network interfaces (Windows).
//
// Uses Get-DnsClientServerAddress (fast, built into Windows 8+/2012+).
// Falls back to ipconfig /all parsing if the CIM call fails (older OSes).
func probeDNS() *DNSConfig {
cfg := &DNSConfig{}
// Primary: CIM-based — deduped, IPv4+IPv6, excludes loopback adapters
const script = `
$addrs = Get-DnsClientServerAddress -ErrorAction SilentlyContinue |
Where-Object { $_.InterfaceAlias -notmatch 'Loopback|Npcap|VirtualBox|VMware' } |
Select-Object -ExpandProperty ServerAddresses |
Where-Object { $_ -ne '' -and $_ -ne '::1' -and $_ -ne '127.0.0.1' } |
Sort-Object -Unique
$search = (Get-DnsClient -ErrorAction SilentlyContinue |
Where-Object { $_.ConnectionSpecificSuffix -ne '' } |
Select-Object -ExpandProperty ConnectionSpecificSuffix |
Sort-Object -Unique) -join ','
[PSCustomObject]@{ servers = ($addrs -join ','); search = $search } | ConvertTo-Json -Compress
`
if out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).Output(); err == nil {
raw := strings.TrimSpace(string(out))
if idx := strings.LastIndex(raw, "{"); idx >= 0 {
raw = raw[idx:]
}
cfg = parseDNSJSON(raw)
}
// Fallback: ipconfig /all if the CIM call returned nothing
if len(cfg.Servers) == 0 {
cfg = parseDNSIpconfig()
}
return cfg
}
// parseDNSIpconfig extracts DNS servers from ipconfig /all output.
func parseDNSIpconfig() *DNSConfig {
cfg := &DNSConfig{}
out, err := exec.Command("ipconfig", "/all").Output()
if err != nil {
return cfg
}
seen := map[string]bool{}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(line), "dns servers") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
addr := strings.TrimSpace(parts[1])
if addr != "" && addr != "127.0.0.1" && addr != "::1" && !seen[addr] {
seen[addr] = true
cfg.Servers = append(cfg.Servers, addr)
}
}
}
}
return cfg
}

View File

@@ -0,0 +1,88 @@
package client
import (
"encoding/json"
"strings"
"testing"
)
func TestComputePostureScoreNil(t *testing.T) {
if computePostureScore(nil) != 0 {
t.Fatal("nil report scores 0")
}
}
func TestComputePostureScorePerfect(t *testing.T) {
r := &PostureReport{
DefenderEnabled: boolPtr(true),
FirewallDomain: boolPtr(true),
SSHListening: boolPtr(true),
PatchRecent: boolPtr(true),
RebootPending: boolPtr(false),
AgentServiceOK: boolPtr(true),
}
if got := computePostureScore(r); got != 100 {
t.Fatalf("expected 100, got %d", got)
}
}
func TestComputePostureScoreAVViaProducts(t *testing.T) {
r := &PostureReport{AVProducts: []string{"ESET"}}
if got := computePostureScore(r); got < 20 {
t.Fatalf("AV products should score pillar 1: %d", got)
}
}
func TestComputePostureScorePartialPatch(t *testing.T) {
r := &PostureReport{
PatchRecent: boolPtr(true),
RebootPending: boolPtr(true),
}
got := computePostureScore(r)
if got != 10 {
t.Fatalf("partial patch pillar expected 10, got %d", got)
}
}
func TestPostureReportJSONSetsScore(t *testing.T) {
r := &PostureReport{
DefenderEnabled: boolPtr(true),
FirewallPublic: boolPtr(true),
SSHListening: boolPtr(true),
PatchRecent: boolPtr(true),
RebootPending: boolPtr(false),
AgentServiceOK: boolPtr(true),
}
raw := r.JSON()
if !strings.Contains(raw, `"posture_score":100`) {
t.Fatalf("JSON should include computed score: %s", raw)
}
var decoded PostureReport
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
t.Fatal(err)
}
if decoded.PostureScore != 100 {
t.Fatalf("decoded score: %d", decoded.PostureScore)
}
}
func TestBoolPoints(t *testing.T) {
if boolPoints(nil) != -1 {
t.Fatal("nil = -1")
}
if boolPoints(boolPtr(true)) != 20 {
t.Fatal("true = 20")
}
if boolPoints(boolPtr(false)) != 0 {
t.Fatal("false = 0")
}
}
func TestBoolTrueHelper(t *testing.T) {
if boolTrue(nil) || boolTrue(boolPtr(false)) {
t.Fatal("boolTrue false cases")
}
if !boolTrue(boolPtr(true)) {
t.Fatal("boolTrue true")
}
}

View File

@@ -76,6 +76,10 @@ type StatsPayload struct {
MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"`
// DNS config (T1016 — drift detected server-side)
DNSServers []string `json:"dns_servers,omitempty"`
DNSSearchDomains []string `json:"dns_search_domains,omitempty"`
// Resource pressure (mining-specific runtime telemetry)
CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"`
CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"`

View File

@@ -0,0 +1,128 @@
package client
import (
"encoding/json"
"testing"
)
func roundTrip(t *testing.T, v any, dst any) {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := json.Unmarshal(b, dst); err != nil {
t.Fatalf("unmarshal: %v\njson: %s", err, string(b))
}
}
func TestMessageJSONRoundTrip(t *testing.T) {
msg := Message{Type: "auth", Payload: json.RawMessage(`{"agent_id":"a1"}`)}
var out Message
roundTrip(t, msg, &out)
if out.Type != "auth" || string(out.Payload) != `{"agent_id":"a1"}` {
t.Fatalf("unexpected: %+v", out)
}
}
func TestAuthPayloadJSONRoundTrip(t *testing.T) {
in := AuthPayload{
AgentID: "a1", FleetSecret: "secret", Wallet: "48x",
BackupPools: []BackupPoolEntry{{Host: "b.pool", Port: 4444, TLS: true, Pass: "x"}},
Version: "1.0", Hostname: "host", CPUCores: 4, MemoryGB: 8,
Worker: "w", PoolHost: "pool", PoolPort: 3333, PoolTLS: false, PoolPass: "x",
AIEnabled: true, AIOllamaEndpoint: "http://localhost:11434", AIModel: "llama",
HolePunch: true, RemoteAggressive: false, MeshP2P: false,
AutoSpread: false, ProcessHollowing: false,
Platform: "windows", Arch: "amd64", OSVersion: "10",
}
var out AuthPayload
roundTrip(t, in, &out)
if out.AgentID != in.AgentID || len(out.BackupPools) != 1 {
t.Fatalf("unexpected: %+v", out)
}
}
func TestAuthResponseJSONRoundTrip(t *testing.T) {
in := AuthResponse{Success: true, AgentID: "a1", Error: ""}
var out AuthResponse
roundTrip(t, in, &out)
if !out.Success || out.AgentID != "a1" {
t.Fatalf("unexpected: %+v", out)
}
}
func TestJobJSONRoundTrip(t *testing.T) {
in := Job{ID: "j1", Height: 100, BlockTemplate: "tpl", Difficulty: 500,
SeedHash: "seed", Target: "tgt", Blob: "blob", Algo: "rx/0"}
var out Job
roundTrip(t, in, &out)
if out.ID != "j1" || out.Blob != "blob" {
t.Fatalf("unexpected: %+v", out)
}
}
func TestSharePayloadJSONRoundTrip(t *testing.T) {
in := SharePayload{JobID: "j", Nonce: "n", Hash: "h", Worker: "w"}
var out SharePayload
roundTrip(t, in, &out)
if out.JobID != "j" {
t.Fatalf("unexpected: %+v", out)
}
}
func TestStatsPayloadJSONRoundTrip(t *testing.T) {
cpuTemp := 70
in := StatsPayload{
Hashrate15s: 100, Hashrate1m: 99, Hashrate15m: 98,
SharesSubmitted: 10, SharesAccepted: 9,
CPUUsagePct: 50, MemoryUsagePct: 40, UptimeSeconds: 3600,
CPUTempC: &cpuTemp,
Services: []ServiceStatus{{Name: "svc", Status: "running", StartType: "auto"}},
}
var out StatsPayload
roundTrip(t, in, &out)
if out.CPUTempC == nil || *out.CPUTempC != 70 {
t.Fatalf("unexpected: %+v", out)
}
}
func TestShareResultJSONRoundTrip(t *testing.T) {
in := ShareResult{JobID: "j", Accepted: false, Error: "low diff"}
var out ShareResult
roundTrip(t, in, &out)
if out.Error != "low diff" {
t.Fatalf("unexpected: %+v", out)
}
}
func TestBackupPoolEntryJSONRoundTrip(t *testing.T) {
in := BackupPoolEntry{Host: "h", Port: 3333, TLS: true, Pass: "x"}
var out BackupPoolEntry
roundTrip(t, in, &out)
if out.Host != "h" || !out.TLS {
t.Fatalf("unexpected: %+v", out)
}
}
func TestServiceStatusJSONRoundTrip(t *testing.T) {
in := ServiceStatus{Name: "ssh", DisplayName: "OpenSSH", Status: "running", StartType: "manual"}
var out ServiceStatus
roundTrip(t, in, &out)
if out.Name != "ssh" {
t.Fatalf("unexpected: %+v", out)
}
}
func TestJobPayloadErrorEdgeCases(t *testing.T) {
if jobPayloadHasError(json.RawMessage(`invalid`)) {
t.Fatal("invalid json should not be treated as error field")
}
if jobPayloadHasError(json.RawMessage(`{"error":""}`)) {
t.Fatal("empty error string should not trigger")
}
msg, ok := jobPayloadErrorMessage(json.RawMessage(`{"error":" fail "}`))
if !ok || msg != " fail " {
t.Fatalf("got %q ok=%v", msg, ok)
}
}

View File

@@ -0,0 +1,55 @@
package client
import "testing"
func TestResourcePressureDiskPressure(t *testing.T) {
if (&ResourcePressure{}).DiskPressure() {
t.Fatal("nil pct should not pressure")
}
low := 5
if !(&ResourcePressure{DiskFreePct: &low}).DiskPressure() {
t.Fatal("5% should be disk pressure")
}
ok := 15
if (&ResourcePressure{DiskFreePct: &ok}).DiskPressure() {
t.Fatal("15% should not pressure")
}
}
func TestResourcePressureThermalPressure(t *testing.T) {
if (&ResourcePressure{}).ThermalPressure() {
t.Fatal("empty should not pressure")
}
cpuHot := 90
if !(&ResourcePressure{CPUTempC: &cpuHot}).ThermalPressure() {
t.Fatal("cpu > 85 should pressure")
}
cpuOK := 80
gpuHot := 85
if !(&ResourcePressure{CPUTempC: &cpuOK, GPUTempC: &gpuHot}).ThermalPressure() {
t.Fatal("gpu > 82 should pressure")
}
}
func TestResourcePressureThrottled(t *testing.T) {
if (&ResourcePressure{}).Throttled() {
t.Fatal("nil throttle false")
}
yes := true
if !(&ResourcePressure{CPUThrottle: &yes}).Throttled() {
t.Fatal("throttle true")
}
no := false
if (&ResourcePressure{CPUThrottle: &no}).Throttled() {
t.Fatal("throttle false")
}
}
func TestResourcePressureJSONOmitempty(t *testing.T) {
freq := 3000
r := ResourcePressure{CPUFreqMHz: &freq}
// smoke: struct tags compile; fields accessible
if r.CPUFreqMHz == nil || *r.CPUFreqMHz != 3000 {
t.Fatal("field set")
}
}