[object Object]

← back to Wallco Ai

scripts/security-monitor.sh — 10-min cron detector for UID-0 / sudoers / authkeys / timer / useradd drift (dtd verdict A, deployed prod 2026-05-30)

044da518ae4fcddf3e8518c8f31b0e1b088ade96 · 2026-05-30 09:40:07 -0700 · Steve Abrams

Files touched

Diff

commit 044da518ae4fcddf3e8518c8f31b0e1b088ade96
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 30 09:40:07 2026 -0700

    scripts/security-monitor.sh — 10-min cron detector for UID-0 / sudoers / authkeys / timer / useradd drift (dtd verdict A, deployed prod 2026-05-30)
---
 scripts/security-monitor.sh | 192 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 192 insertions(+)

diff --git a/scripts/security-monitor.sh b/scripts/security-monitor.sh
new file mode 100755
index 0000000..b930cf0
--- /dev/null
+++ b/scripts/security-monitor.sh
@@ -0,0 +1,192 @@
+#!/bin/bash
+# security-monitor.sh — early-warning detector for persistence-class compromise
+#
+# Born 2026-05-30 after Pakchoi UID-0 backdoor created May 28 wasn't detected
+# until May 29 (~36-hour lag). This closes the gap to ~10 minutes.
+#
+# Runs every 10 min via cron. Read-only on all system files. On any drift vs
+# the snapshots created on first run, appends to /root/security-alerts.log and
+# emails info@designerwallcoverings.com via the existing george-gmail relay.
+#
+# Mac2 source: ~/Projects/wallco-ai/scripts/security-monitor.sh
+# Prod path:   /root/scripts/security-monitor.sh
+# DTD verdict: A (commit 2026-05-30, Claude + Codex panel, Qwen non-vote)
+
+set -uo pipefail
+
+SCRIPT_DIR="/root/scripts"
+ALERT_LOG="/root/security-alerts.log"
+GEORGE_URL="http://127.0.0.1:9850/api/send"
+ALERT_TO="info@designerwallcoverings.com"
+ALERT_FROM="info@designerwallcoverings.com"
+HOSTNAME_SHORT="$(hostname -s 2>/dev/null || echo prod)"
+DRY_RUN="${DRY_RUN:-0}"
+
+# Load George auth — lives in gmail-agent .env, not /root/.env (verified 2026-05-30)
+for envf in /root/DW-Agents/gmail-agent/.env /root/.env; do
+    if [ -f "$envf" ]; then
+        val="$(grep -E '^GEORGE_BASIC_AUTH=' "$envf" | head -1 | cut -d= -f2-)"
+        if [ -n "${val:-}" ]; then GEORGE_BASIC_AUTH="$val"; break; fi
+    fi
+done
+GEORGE_BASIC_AUTH="${GEORGE_BASIC_AUTH:-}"
+
+PASSWD_SNAP="${SCRIPT_DIR}/known-good-passwd.snapshot"
+SUDOERSD_SNAP="${SCRIPT_DIR}/known-good-sudoersd.snapshot"
+AUTHKEYS_SNAP="${SCRIPT_DIR}/known-good-authkeys.snapshot"
+TIMERS_SNAP="${SCRIPT_DIR}/known-good-timers.snapshot"
+
+ts() { date '+%Y-%m-%d %H:%M:%S %Z'; }
+
+log_alert() {
+    # $1 = severity (CRITICAL|WARN|INFO), $2 = short subject, $3 = body
+    local sev="$1"; local subj="$2"; local body="$3"
+    echo "[$(ts)] [$sev] $subj" >> "$ALERT_LOG"
+    echo "$body" | sed 's/^/    /' >> "$ALERT_LOG"
+
+    if [ "$DRY_RUN" = "1" ]; then
+        echo "[DRY] would email: [$sev] $subj"
+        echo "[DRY] body: $body"
+        return
+    fi
+
+    # Best-effort email via George — never block on failure (matches db-backup.sh)
+    if [ -n "$GEORGE_BASIC_AUTH" ]; then
+        local full_subj="[security-monitor:${HOSTNAME_SHORT}] $sev — $subj"
+        local payload
+        if command -v jq >/dev/null 2>&1; then
+            payload=$(jq -n --arg s "$full_subj" --arg b "$body" \
+                     --arg from "$ALERT_FROM" --arg to "$ALERT_TO" \
+                     '{ from: $from, to: $to, subject: $s, body: $b }')
+        else
+            local esc_b
+            esc_b=$(printf '%s' "$body" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS="\\n"} {print}')
+            payload="{\"from\":\"${ALERT_FROM}\",\"to\":\"${ALERT_TO}\",\"subject\":\"${full_subj}\",\"body\":\"${esc_b}\"}"
+        fi
+        curl -s -m 6 -u "$GEORGE_BASIC_AUTH" -H "Content-Type: application/json" \
+            -X POST "$GEORGE_URL" -d "$payload" >/dev/null 2>&1 || true
+    fi
+}
+
+# ---- snapshot helpers: create on first run, never overwrite without consent ----
+ensure_snapshot() {
+    # $1 = snapshot path, $2 = command that produces the canonical content
+    local snap="$1"; local gen_cmd="$2"
+    if [ ! -f "$snap" ]; then
+        eval "$gen_cmd" > "$snap" 2>/dev/null
+        chmod 600 "$snap"
+        log_alert "INFO" "snapshot bootstrapped: $(basename "$snap")" \
+                  "First run — wrote baseline to $snap. No alert on this entry."
+    fi
+}
+
+# ---- Check 1: UID-0 entries in /etc/passwd ----
+check_uid0() {
+    local current
+    current=$(awk -F: '$3==0 {print $1}' /etc/passwd | sort)
+    ensure_snapshot "$PASSWD_SNAP" "echo \"$current\""
+    local baseline
+    baseline=$(cat "$PASSWD_SNAP" | sort)
+    local new
+    new=$(comm -23 <(echo "$current") <(echo "$baseline"))
+    if [ -n "$new" ]; then
+        log_alert "CRITICAL" "new UID-0 account(s): $(echo "$new" | tr '\n' ' ')" \
+                  "/etc/passwd snapshot drift on ${HOSTNAME_SHORT}.\n\nNEW UID-0 users not in baseline:\n${new}\n\nBaseline file: ${PASSWD_SNAP}\nFull current UID-0 list:\n${current}\n\nIf this is intentional, update the snapshot:\n  awk -F: '\$3==0 {print \$1}' /etc/passwd | sort > ${PASSWD_SNAP}"
+    fi
+}
+
+# ---- Check 2: /etc/sudoers.d/ — new files ----
+check_sudoersd() {
+    local current
+    current=$(ls -1 /etc/sudoers.d/ 2>/dev/null | sort)
+    ensure_snapshot "$SUDOERSD_SNAP" "echo \"$current\""
+    local baseline
+    baseline=$(cat "$SUDOERSD_SNAP" | sort)
+    local new
+    new=$(comm -23 <(echo "$current") <(echo "$baseline"))
+    if [ -n "$new" ]; then
+        local detail=""
+        for f in $new; do
+            detail+="--- /etc/sudoers.d/${f} ---\n"
+            detail+="$(cat "/etc/sudoers.d/${f}" 2>/dev/null | head -50)\n\n"
+        done
+        log_alert "CRITICAL" "new sudoers.d file(s): $(echo "$new" | tr '\n' ' ')" \
+                  "New /etc/sudoers.d/ entries on ${HOSTNAME_SHORT}:\n${new}\n\nContents:\n${detail}\n\nIf intentional, update snapshot:\n  ls -1 /etc/sudoers.d/ | sort > ${SUDOERSD_SNAP}"
+    fi
+}
+
+# ---- Check 3: /root/.ssh/authorized_keys mtime + content hash ----
+check_authkeys() {
+    local f="/root/.ssh/authorized_keys"
+    if [ ! -f "$f" ]; then return; fi
+    local current
+    current=$(sha256sum "$f" | awk '{print $1}')
+    ensure_snapshot "$AUTHKEYS_SNAP" "echo \"$current\""
+    local baseline
+    baseline=$(cat "$AUTHKEYS_SNAP")
+    if [ "$current" != "$baseline" ]; then
+        local mtime
+        mtime=$(stat -c '%y' "$f")
+        local diff_summary
+        diff_summary=$(awk '{print substr($0,1,80)"..."}' "$f" | head -20)
+        log_alert "CRITICAL" "/root/.ssh/authorized_keys changed" \
+                  "authorized_keys content changed on ${HOSTNAME_SHORT}.\n\nBaseline sha256: ${baseline}\nCurrent  sha256: ${current}\nFile mtime:      ${mtime}\n\nCurrent key fingerprints (first 80c each):\n${diff_summary}\n\nIf intentional, update snapshot:\n  sha256sum ${f} | awk '{print \$1}' > ${AUTHKEYS_SNAP}"
+    fi
+}
+
+# ---- Check 4: new systemd timer units ----
+check_timers() {
+    local current
+    current=$(ls -1 /etc/systemd/system/*.timer 2>/dev/null | sort)
+    ensure_snapshot "$TIMERS_SNAP" "echo \"$current\""
+    local baseline
+    baseline=$(cat "$TIMERS_SNAP" | sort)
+    local new
+    new=$(comm -23 <(echo "$current") <(echo "$baseline"))
+    if [ -n "$new" ]; then
+        local detail=""
+        for t in $new; do
+            detail+="--- ${t} ---\n"
+            detail+="$(cat "$t" 2>/dev/null | head -30)\n\n"
+        done
+        log_alert "CRITICAL" "new systemd timer(s): $(echo "$new" | tr '\n' ' ')" \
+                  "New /etc/systemd/system/*.timer units on ${HOSTNAME_SHORT}:\n${new}\n\nContents:\n${detail}\n\nIf intentional, update snapshot:\n  ls -1 /etc/systemd/system/*.timer | sort > ${TIMERS_SNAP}"
+    fi
+}
+
+# ---- Check 5: useradd events in journalctl over last 15 min ----
+check_useradd_journal() {
+    if ! command -v journalctl >/dev/null 2>&1; then return; fi
+    local events
+    events=$(journalctl --since "15 min ago" --no-pager 2>/dev/null \
+             | grep -E "useradd|new user:|new group:|usermod" \
+             | grep -v "security-monitor" \
+             | head -20)
+    if [ -n "$events" ]; then
+        log_alert "WARN" "useradd/usermod events in last 15min" \
+                  "journalctl shows account-mutation events on ${HOSTNAME_SHORT} in the last 15 minutes:\n\n${events}\n\nCross-reference with the UID-0 check above. If a legitimate admin added a user, ignore."
+    fi
+}
+
+# ---- main ----
+main() {
+    mkdir -p "$SCRIPT_DIR"
+    touch "$ALERT_LOG"
+    chmod 600 "$ALERT_LOG"
+
+    if [ "$DRY_RUN" = "1" ]; then
+        echo "=== security-monitor.sh DRY RUN @ $(ts) ==="
+    fi
+
+    check_uid0
+    check_sudoersd
+    check_authkeys
+    check_timers
+    check_useradd_journal
+
+    if [ "$DRY_RUN" = "1" ]; then
+        echo "=== DRY RUN complete ==="
+    fi
+}
+
+main "$@"

← 2d3f9dd security: strip hardcoded dw_admin DSN password -> env-first  ·  back to Wallco Ai  ·  Add colorway sub-category room-mockup driver (1395 living_ro e70294d →