Initial commit: custom OpenClaw skills from docker-test
- workspace: capmetro-monitor, github-notifications, model-selector - workspace-security: vt-monitor, monitor-unauthorized - workspace-home: cron-manager, monitor-unauthorized - extensions: vt-sentinel (VT-Sentinel plugin) Includes sync.sh for pull/push, README, AGENTS.md, .gitignore.
This commit is contained in:
182
workspace-security/monitor-unauthorized/SKILL.md
Normal file
182
workspace-security/monitor-unauthorized/SKILL.md
Normal file
@@ -0,0 +1,182 @@
|
||||
---
|
||||
name: monitor-unauthorized
|
||||
description: >-
|
||||
Monitor and report unauthorized WebSocket gateway connections. Parses logs
|
||||
every 30 minutes, detects new and returning IPs, and manages per-IP Discord
|
||||
threads for tracking unauthorized access attempts.
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "🚨"
|
||||
---
|
||||
|
||||
# Monitor Unauthorized Gateway Connections
|
||||
|
||||
Detects and tracks unauthorized WebSocket connection attempts to the OpenClaw gateway. Maintains per-IP Discord threads for ongoing tracking.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Cron tool (every 30 minutes)
|
||||
bash skills/monitor-unauthorized/scripts/cron-wrapper.sh
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Scripts (data layer — no Discord/OpenClaw calls)
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/log-splitter.sh` | Extracts unauthorized connection entries from the gateway log into `/tmp/openclaw/unauthorized-connections.log` (incremental, byte-offset tracked) |
|
||||
| `scripts/check.sh` | Reads new entries from the unauthorized log, categorizes IPs as `new_ips` or `returning_ips`, updates `seen-connections.json` |
|
||||
| `scripts/index-threads.sh` | Manages the thread index — lookup, record, and staleness checks. The index maps IPs to their Discord thread session keys |
|
||||
| `scripts/cron-wrapper.sh` | Orchestrates the above scripts and outputs structured ACTION blocks for the agent |
|
||||
|
||||
### Agent (action layer — thread management via OpenClaw tools)
|
||||
|
||||
The agent parses the cron-wrapper output and handles all Discord thread operations using OpenClaw's built-in session and thread tools.
|
||||
|
||||
## Cron Behavior
|
||||
|
||||
1. `log-splitter.sh` extracts new unauthorized connection log lines (incremental)
|
||||
2. `check.sh` processes new entries, outputs JSON with `new_ips[]` and `returning_ips[]`
|
||||
3. `cron-wrapper.sh` formats ACTION blocks the agent must parse and act on
|
||||
4. **If no new activity**: script produces no output → agent replies `NO_REPLY`
|
||||
|
||||
## Parsing Cron Output
|
||||
|
||||
The cron-wrapper outputs ACTION blocks. Parse them as follows:
|
||||
|
||||
### `ACTION:INDEX_THREADS`
|
||||
|
||||
The thread index is missing or stale. You must refresh it before processing connections.
|
||||
|
||||
**Steps:**
|
||||
1. List all threads in the security Discord channel (`1471181304782389381`)
|
||||
2. For each thread whose name starts with `🚨` and contains `unauthorized gateway access`:
|
||||
- Extract the IP address from the thread name (format: `🚨 <ip> — unauthorized gateway access`)
|
||||
- Record it: `bash scripts/index-threads.sh record "<ip>" "<session_key>" "<thread_name>"`
|
||||
3. The session key format is: `agent:security:discord:channel:1471181304782389381:thread:<thread_name>`
|
||||
|
||||
### `ACTION:NEW_THREAD`
|
||||
|
||||
A new unauthorized IP was detected. Create a thread and report.
|
||||
|
||||
**Format:**
|
||||
```
|
||||
ACTION:NEW_THREAD
|
||||
IP:<ip_address>
|
||||
---
|
||||
<report content>
|
||||
---END_ACTION---
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. Construct the thread name: `🚨 <ip> — unauthorized gateway access`
|
||||
2. Construct the session key: `agent:security:discord:channel:1471181304782389381:thread:<thread_name>`
|
||||
3. Use `sessions_send` to send the report content (between `---` and `---END_ACTION---`) to that session key. This creates the thread if it doesn't exist.
|
||||
4. Record the thread in the index: `bash scripts/index-threads.sh record "<ip>" "<session_key>"`
|
||||
|
||||
### `ACTION:UPDATE_THREAD`
|
||||
|
||||
A previously-seen IP has new connection attempts. Update the existing thread.
|
||||
|
||||
**Format:**
|
||||
```
|
||||
ACTION:UPDATE_THREAD
|
||||
IP:<ip_address>
|
||||
SESSION_KEY:<session_key or empty>
|
||||
---
|
||||
<update content>
|
||||
---END_ACTION---
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. If `SESSION_KEY` is provided and non-empty, use it directly
|
||||
2. If `SESSION_KEY` is empty, construct it: `agent:security:discord:channel:1471181304782389381:thread:🚨 <ip> — unauthorized gateway access`
|
||||
3. Use `sessions_send` to post the update content to that session key
|
||||
4. Update the index: `bash scripts/index-threads.sh record "<ip>" "<session_key>"`
|
||||
|
||||
## Thread Index Management
|
||||
|
||||
The thread index (`memory/thread-index.json`) maps IPs to their Discord thread session keys. This avoids redundant thread creation and enables reliable updates.
|
||||
|
||||
### One-Time Bootstrap
|
||||
|
||||
On first run (or when the index is missing), the agent must:
|
||||
1. List all existing threads in channel `1471181304782389381`
|
||||
2. Identify threads matching the `🚨 <ip> — unauthorized gateway access` pattern
|
||||
3. Record each one via `scripts/index-threads.sh record`
|
||||
|
||||
### Ongoing Maintenance
|
||||
|
||||
- After creating a new thread (`ACTION:NEW_THREAD`), always `record` it in the index
|
||||
- After sending an update (`ACTION:UPDATE_THREAD`), always `record` it to refresh timestamps
|
||||
- The index auto-expires after 24 hours; the cron-wrapper will emit `ACTION:INDEX_THREADS` when a refresh is needed
|
||||
|
||||
### Index Script Commands
|
||||
|
||||
```bash
|
||||
# Check if index needs refresh
|
||||
bash scripts/index-threads.sh needs-refresh
|
||||
# Returns: "fresh" (exit 1), "stale" (exit 0), or "missing" (exit 0)
|
||||
|
||||
# Look up a thread by IP
|
||||
bash scripts/index-threads.sh lookup "1.2.3.4"
|
||||
# Returns: JSON with session_key, thread_name, etc. (exit 0 = found, exit 1 = not found)
|
||||
|
||||
# Record/update a thread entry
|
||||
bash scripts/index-threads.sh record "1.2.3.4" "agent:security:discord:channel:...:thread:..." "🚨 1.2.3.4 — unauthorized gateway access"
|
||||
|
||||
# Check index status
|
||||
bash scripts/index-threads.sh status
|
||||
# Returns: JSON with entry count, age, staleness
|
||||
```
|
||||
|
||||
## Storage Files
|
||||
|
||||
| File | Location | Purpose |
|
||||
|------|----------|---------|
|
||||
| `seen-connections.json` | Skill directory | All IPs ever seen — first_seen, last_seen, total_attempts, metadata |
|
||||
| `authorized-ips.json` | Skill directory | Whitelist — these IPs are silently skipped |
|
||||
| `memory/thread-index.json` | State directory | Maps IPs to Discord thread session keys |
|
||||
| `memory/unauth-splitter-offset` | State directory | Byte offset for log-splitter (gateway log) |
|
||||
| `memory/unauth-check-offset` | State directory | Byte offset for check (unauthorized log) |
|
||||
|
||||
## Authorized IPs (Whitelist)
|
||||
|
||||
Edit `authorized-ips.json` to suppress reporting for known IPs:
|
||||
|
||||
```json
|
||||
{
|
||||
"whitelist": ["127.0.0.1", "::1", "localhost", "192.168.1.100"]
|
||||
}
|
||||
```
|
||||
|
||||
## Log Files
|
||||
|
||||
| Log | Path | Contents |
|
||||
|-----|------|----------|
|
||||
| Gateway log | `/tmp/openclaw/openclaw.log` | Full OpenClaw gateway log (source) |
|
||||
| Unauthorized log | `/tmp/openclaw/unauthorized-connections.log` | Extracted unauthorized connection entries only |
|
||||
|
||||
## What This Monitors
|
||||
|
||||
Gateway WebSocket authorization failures containing `forwardedFor` IP addresses. Specifically:
|
||||
- Entries with `"forwardedFor"` in the JSON log
|
||||
- Entries with cause `"unauthorized"` or `"pairing-required"`
|
||||
|
||||
## Example Agent Flow
|
||||
|
||||
```
|
||||
1. Cron fires → bash scripts/cron-wrapper.sh
|
||||
2. Output contains ACTION:INDEX_THREADS → agent lists threads, records them
|
||||
3. Output contains ACTION:NEW_THREAD for IP 203.0.113.42 →
|
||||
a. agent constructs session key
|
||||
b. agent calls sessions_send with report content
|
||||
c. agent runs: bash scripts/index-threads.sh record "203.0.113.42" "<key>"
|
||||
4. Output contains ACTION:UPDATE_THREAD for IP 198.51.100.7 →
|
||||
a. agent uses SESSION_KEY from output (or constructs it)
|
||||
b. agent calls sessions_send with update content
|
||||
c. agent runs: bash scripts/index-threads.sh record "198.51.100.7" "<key>"
|
||||
5. No output → agent replies NO_REPLY
|
||||
```
|
||||
209
workspace-security/monitor-unauthorized/scripts/check.sh
Executable file
209
workspace-security/monitor-unauthorized/scripts/check.sh
Executable file
@@ -0,0 +1,209 @@
|
||||
#!/bin/bash
|
||||
# Incremental unauthorized connection checker
|
||||
# Reads the dedicated unauthorized-connections.log (populated by log-splitter.sh),
|
||||
# compares against seen-connections.json and authorized-ips.json,
|
||||
# and outputs structured JSON categorizing IPs as new or returning.
|
||||
#
|
||||
# Usage: bash check.sh
|
||||
#
|
||||
# Output JSON:
|
||||
# {
|
||||
# "timestamp": "2026-02-16T12:00:00Z",
|
||||
# "new_ips": [ { "ip": "...", "first_seen": "...", "reason": "...", ... } ],
|
||||
# "returning_ips": [ { "ip": "...", "new_attempts": 3, "latest": "...", ... } ],
|
||||
# "total_events": 12,
|
||||
# "whitelisted_skipped": 2
|
||||
# }
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
UNAUTH_LOG="/tmp/openclaw/unauthorized-connections.log"
|
||||
STATE_DIR="/home/node/.openclaw/workspace-security/memory"
|
||||
OFFSET_FILE="$STATE_DIR/unauth-check-offset"
|
||||
SEEN_FILE="$SKILL_DIR/seen-connections.json"
|
||||
AUTH_FILE="$SKILL_DIR/authorized-ips.json"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# Initialize state files if missing
|
||||
[ ! -s "$SEEN_FILE" ] && echo '{}' > "$SEEN_FILE"
|
||||
[ ! -s "$AUTH_FILE" ] && echo '{"whitelist":["127.0.0.1","::1","localhost"]}' > "$AUTH_FILE"
|
||||
|
||||
# Check log exists
|
||||
if [ ! -f "$UNAUTH_LOG" ]; then
|
||||
echo '{"timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","new_ips":[],"returning_ips":[],"total_events":0,"whitelisted_skipped":0}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FILE_SIZE=$(stat -c%s "$UNAUTH_LOG")
|
||||
|
||||
# Read offset
|
||||
LAST_OFFSET=0
|
||||
if [ -f "$OFFSET_FILE" ]; then
|
||||
LAST_OFFSET=$(cat "$OFFSET_FILE")
|
||||
fi
|
||||
|
||||
# Handle log rotation / truncation
|
||||
if [ "$LAST_OFFSET" -gt "$FILE_SIZE" ]; then
|
||||
LAST_OFFSET=0
|
||||
fi
|
||||
|
||||
BYTES_NEW=$((FILE_SIZE - LAST_OFFSET))
|
||||
|
||||
if [ "$BYTES_NEW" -le 0 ]; then
|
||||
echo "$FILE_SIZE" > "$OFFSET_FILE"
|
||||
echo '{"timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","new_ips":[],"returning_ips":[],"total_events":0,"whitelisted_skipped":0}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Load whitelist
|
||||
WHITELIST=$(jq -r '.whitelist[]' "$AUTH_FILE" 2>/dev/null | tr '\n' '|' | sed 's/|$//')
|
||||
|
||||
is_whitelisted() {
|
||||
local ip="$1"
|
||||
[[ "$ip" == "127.0.0.1" || "$ip" == "::1" || "$ip" == "localhost" ]] && return 0
|
||||
[ -n "$WHITELIST" ] && echo "$ip" | grep -qE "^($WHITELIST)$" && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# Parse new log entries into per-IP aggregated data
|
||||
TMPFILE=$(mktemp)
|
||||
trap "rm -f $TMPFILE" EXIT
|
||||
|
||||
tail -c +"$((LAST_OFFSET + 1))" "$UNAUTH_LOG" > "$TMPFILE"
|
||||
|
||||
TOTAL_EVENTS=0
|
||||
WHITELISTED=0
|
||||
declare -A IP_EVENTS # ip -> JSON array of events
|
||||
declare -A IP_LATEST # ip -> latest timestamp
|
||||
declare -A IP_COUNT # ip -> count of events in this batch
|
||||
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
TOTAL_EVENTS=$((TOTAL_EVENTS + 1))
|
||||
|
||||
# Extract fields from JSON log line
|
||||
PARSED=$(echo "$line" | jq -r '
|
||||
[
|
||||
.time // "",
|
||||
(.["1"].forwardedFor // ""),
|
||||
(.["1"].authReason // .["1"].reason // .["1"].cause // ""),
|
||||
(.["1"].origin // ""),
|
||||
(.["1"].userAgent // ""),
|
||||
((.["2"] // "") | tostring | (capture("remote=(?<r>[0-9.]+)") | .r) // "")
|
||||
] | @tsv
|
||||
' 2>/dev/null) || continue
|
||||
|
||||
IFS=$'\t' read -r ts ip reason origin ua remote <<< "$PARSED"
|
||||
[ -z "$ip" ] && continue
|
||||
|
||||
if is_whitelisted "$ip"; then
|
||||
WHITELISTED=$((WHITELISTED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Build event JSON
|
||||
EVENT=$(jq -cn \
|
||||
--arg ts "$ts" \
|
||||
--arg ip "$ip" \
|
||||
--arg reason "$reason" \
|
||||
--arg origin "$origin" \
|
||||
--arg ua "$ua" \
|
||||
--arg remote "$remote" \
|
||||
'{timestamp:$ts, ip:$ip, reason:$reason, origin:$origin, user_agent:$ua, remote:$remote}')
|
||||
|
||||
# Aggregate by IP
|
||||
if [ -z "${IP_EVENTS[$ip]+x}" ]; then
|
||||
IP_EVENTS[$ip]="$EVENT"
|
||||
IP_COUNT[$ip]=1
|
||||
else
|
||||
IP_EVENTS[$ip]="${IP_EVENTS[$ip]}"$'\n'"$EVENT"
|
||||
IP_COUNT[$ip]=$(( ${IP_COUNT[$ip]} + 1 ))
|
||||
fi
|
||||
IP_LATEST[$ip]="$ts"
|
||||
|
||||
done < "$TMPFILE"
|
||||
|
||||
# Categorize IPs as new or returning
|
||||
NEW_IPS="[]"
|
||||
RETURNING_IPS="[]"
|
||||
|
||||
for ip in "${!IP_EVENTS[@]}"; do
|
||||
COUNT=${IP_COUNT[$ip]}
|
||||
LATEST=${IP_LATEST[$ip]}
|
||||
|
||||
# Get first event for details
|
||||
FIRST_EVENT=$(echo "${IP_EVENTS[$ip]}" | head -1)
|
||||
REASON=$(echo "$FIRST_EVENT" | jq -r '.reason')
|
||||
ORIGIN=$(echo "$FIRST_EVENT" | jq -r '.origin')
|
||||
UA=$(echo "$FIRST_EVENT" | jq -r '.user_agent')
|
||||
REMOTE=$(echo "$FIRST_EVENT" | jq -r '.remote')
|
||||
|
||||
# Check if IP was previously seen
|
||||
if jq -e --arg ip "$ip" 'has($ip)' "$SEEN_FILE" >/dev/null 2>&1; then
|
||||
# Returning IP — update seen record, add to returning list
|
||||
PREV_COUNT=$(jq -r --arg ip "$ip" '.[$ip].total_attempts // 0' "$SEEN_FILE")
|
||||
NEW_TOTAL=$((PREV_COUNT + COUNT))
|
||||
|
||||
STMP=$(mktemp)
|
||||
jq --arg ip "$ip" \
|
||||
--arg latest "$LATEST" \
|
||||
--argjson count "$NEW_TOTAL" \
|
||||
--argjson batch "$COUNT" \
|
||||
'.[$ip].last_seen = $latest | .[$ip].total_attempts = $count | .[$ip].attempts_this_batch = $batch' \
|
||||
"$SEEN_FILE" > "$STMP" && mv "$STMP" "$SEEN_FILE"
|
||||
|
||||
RETURNING_IPS=$(echo "$RETURNING_IPS" | jq \
|
||||
--arg ip "$ip" \
|
||||
--arg latest "$LATEST" \
|
||||
--argjson new_attempts "$COUNT" \
|
||||
--argjson total "$NEW_TOTAL" \
|
||||
--arg reason "$REASON" \
|
||||
--arg origin "$ORIGIN" \
|
||||
--arg ua "$UA" \
|
||||
--arg remote "$REMOTE" \
|
||||
'. += [{ip:$ip, latest:$latest, new_attempts:$new_attempts, total_attempts:$total, reason:$reason, origin:$origin, user_agent:$ua, remote:$remote}]')
|
||||
else
|
||||
# New IP — record it, add to new list
|
||||
STMP=$(mktemp)
|
||||
jq --arg ip "$ip" \
|
||||
--arg ts "$LATEST" \
|
||||
--arg reason "$REASON" \
|
||||
--arg origin "$ORIGIN" \
|
||||
--arg ua "$UA" \
|
||||
--arg remote "$REMOTE" \
|
||||
--argjson count "$COUNT" \
|
||||
'. + {($ip): {first_seen: $ts, last_seen: $ts, reason: $reason, origin: $origin, user_agent: $ua, remote: $remote, total_attempts: $count}}' \
|
||||
"$SEEN_FILE" > "$STMP" && mv "$STMP" "$SEEN_FILE"
|
||||
|
||||
NEW_IPS=$(echo "$NEW_IPS" | jq \
|
||||
--arg ip "$ip" \
|
||||
--arg first_seen "$LATEST" \
|
||||
--argjson attempts "$COUNT" \
|
||||
--arg reason "$REASON" \
|
||||
--arg origin "$ORIGIN" \
|
||||
--arg ua "$UA" \
|
||||
--arg remote "$REMOTE" \
|
||||
'. += [{ip:$ip, first_seen:$first_seen, attempts:$attempts, reason:$reason, origin:$origin, user_agent:$ua, remote:$remote}]')
|
||||
fi
|
||||
done
|
||||
|
||||
# Save new offset
|
||||
echo "$FILE_SIZE" > "$OFFSET_FILE"
|
||||
|
||||
# Output structured result
|
||||
jq -n \
|
||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--argjson new_ips "$NEW_IPS" \
|
||||
--argjson returning_ips "$RETURNING_IPS" \
|
||||
--argjson total "$TOTAL_EVENTS" \
|
||||
--argjson whitelisted "$WHITELISTED" \
|
||||
'{
|
||||
timestamp: $ts,
|
||||
new_ips: $new_ips,
|
||||
returning_ips: $returning_ips,
|
||||
total_events: $total,
|
||||
whitelisted_skipped: $whitelisted
|
||||
}'
|
||||
129
workspace-security/monitor-unauthorized/scripts/cron-wrapper.sh
Executable file
129
workspace-security/monitor-unauthorized/scripts/cron-wrapper.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/bin/bash
|
||||
# Unauthorized connection monitor — cron wrapper
|
||||
# Orchestrates: log-splitter → check → formatted output with agent instructions
|
||||
#
|
||||
# Output is structured for the OpenClaw agent to parse and act on.
|
||||
# The agent reads SKILL.md for detailed instructions on thread management.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Run log-splitter to extract new unauthorized entries from gateway log
|
||||
# 2. Check thread index status (does the agent need to refresh it?)
|
||||
# 3. Run check to categorize IPs as new or returning
|
||||
# 4. Format output with ACTION markers the agent will parse
|
||||
#
|
||||
# Exit: always 0. Empty output = nothing to do (agent replies NO_REPLY)
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# --- Step 1: Split unauthorized entries from gateway log ---
|
||||
SPLIT_RESULT=$("$SCRIPT_DIR/log-splitter.sh" 2>/dev/null) || true
|
||||
|
||||
# --- Step 2: Check thread index ---
|
||||
INDEX_STATUS=$("$SCRIPT_DIR/index-threads.sh" needs-refresh 2>/dev/null) || INDEX_STATUS="missing"
|
||||
|
||||
# --- Step 3: Check for new/returning unauthorized connections ---
|
||||
CHECK_RESULT=$("$SCRIPT_DIR/check.sh" 2>/dev/null) || CHECK_RESULT='{}'
|
||||
|
||||
NEW_COUNT=$(echo "$CHECK_RESULT" | jq '.new_ips | length' 2>/dev/null || echo 0)
|
||||
RETURNING_COUNT=$(echo "$CHECK_RESULT" | jq '.returning_ips | length' 2>/dev/null || echo 0)
|
||||
TOTAL=$(echo "$CHECK_RESULT" | jq '.total_events' 2>/dev/null || echo 0)
|
||||
|
||||
# Nothing to report
|
||||
if [ "$NEW_COUNT" -eq 0 ] && [ "$RETURNING_COUNT" -eq 0 ] && [ "$INDEX_STATUS" = "fresh" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OUTPUT=""
|
||||
|
||||
# --- Thread index refresh needed ---
|
||||
if [ "$INDEX_STATUS" != "fresh" ]; then
|
||||
OUTPUT+="ACTION:INDEX_THREADS
|
||||
The thread index is ${INDEX_STATUS}. Before processing connections, refresh the thread index.
|
||||
See SKILL.md section \"Thread Index Management\" for instructions.
|
||||
---END_ACTION---
|
||||
"
|
||||
fi
|
||||
|
||||
# --- New IPs: agent should create threads ---
|
||||
if [ "$NEW_COUNT" -gt 0 ]; then
|
||||
OUTPUT+="
|
||||
🚨 UNAUTHORIZED CONNECTIONS — ${NEW_COUNT} NEW IP(s) DETECTED
|
||||
============================================================
|
||||
"
|
||||
# Emit each new IP as an ACTION block
|
||||
for i in $(seq 0 $((NEW_COUNT - 1))); do
|
||||
IP=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].ip")
|
||||
FIRST_SEEN=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].first_seen")
|
||||
ATTEMPTS=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].attempts")
|
||||
REASON=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].reason")
|
||||
ORIGIN=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].origin")
|
||||
UA=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].user_agent")
|
||||
REMOTE=$(echo "$CHECK_RESULT" | jq -r ".new_ips[$i].remote")
|
||||
|
||||
OUTPUT+="
|
||||
ACTION:NEW_THREAD
|
||||
IP:${IP}
|
||||
---
|
||||
🚨 **NEW UNAUTHORIZED CONNECTION**
|
||||
|
||||
**IP:** \`${IP}\`
|
||||
**First Seen:** ${FIRST_SEEN}
|
||||
**Attempts:** ${ATTEMPTS}
|
||||
**Reason:** ${REASON}
|
||||
**Origin:** ${ORIGIN}
|
||||
**User Agent:** ${UA}
|
||||
**Remote (proxy):** ${REMOTE}
|
||||
|
||||
_New IP — thread created by security monitor._
|
||||
---END_ACTION---
|
||||
"
|
||||
done
|
||||
fi
|
||||
|
||||
# --- Returning IPs: agent should update existing threads ---
|
||||
if [ "$RETURNING_COUNT" -gt 0 ]; then
|
||||
OUTPUT+="
|
||||
⚠️ RETURNING CONNECTIONS — ${RETURNING_COUNT} KNOWN IP(s)
|
||||
==========================================================
|
||||
"
|
||||
for i in $(seq 0 $((RETURNING_COUNT - 1))); do
|
||||
IP=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].ip")
|
||||
LATEST=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].latest")
|
||||
NEW_ATTEMPTS=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].new_attempts")
|
||||
TOTAL_ATTEMPTS=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].total_attempts")
|
||||
REASON=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].reason")
|
||||
ORIGIN=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].origin")
|
||||
UA=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].user_agent")
|
||||
REMOTE=$(echo "$CHECK_RESULT" | jq -r ".returning_ips[$i].remote")
|
||||
|
||||
# Look up existing thread
|
||||
THREAD_INFO=$("$SCRIPT_DIR/index-threads.sh" lookup "$IP" 2>/dev/null) || THREAD_INFO=""
|
||||
SESSION_KEY=""
|
||||
if [ -n "$THREAD_INFO" ]; then
|
||||
SESSION_KEY=$(echo "$THREAD_INFO" | jq -r '.session_key // empty')
|
||||
fi
|
||||
|
||||
OUTPUT+="
|
||||
ACTION:UPDATE_THREAD
|
||||
IP:${IP}
|
||||
SESSION_KEY:${SESSION_KEY}
|
||||
---
|
||||
⚠️ **RETURNING UNAUTHORIZED CONNECTION**
|
||||
|
||||
**IP:** \`${IP}\`
|
||||
**Latest Attempt:** ${LATEST}
|
||||
**New Attempts (this period):** ${NEW_ATTEMPTS}
|
||||
**Total Attempts (all time):** ${TOTAL_ATTEMPTS}
|
||||
**Reason:** ${REASON}
|
||||
**Origin:** ${ORIGIN}
|
||||
**User Agent:** ${UA}
|
||||
**Remote (proxy):** ${REMOTE}
|
||||
|
||||
_Recurring access attempt — updated by security monitor._
|
||||
---END_ACTION---
|
||||
"
|
||||
done
|
||||
fi
|
||||
|
||||
echo -e "$OUTPUT"
|
||||
123
workspace-security/monitor-unauthorized/scripts/index-threads.sh
Executable file
123
workspace-security/monitor-unauthorized/scripts/index-threads.sh
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/bin/bash
|
||||
# Thread index manager for monitor-unauthorized
|
||||
#
|
||||
# Maintains a local JSON index of Discord threads so the cron-wrapper
|
||||
# can determine whether to create a new thread or update an existing one.
|
||||
#
|
||||
# The index is populated BY THE AGENT (not by this script) because only
|
||||
# the agent has access to OpenClaw session/thread listing tools.
|
||||
#
|
||||
# This script provides helper operations:
|
||||
# bash index-threads.sh lookup <ip> — find thread for an IP (exit 0 = found)
|
||||
# bash index-threads.sh status — check if index exists and is fresh
|
||||
# bash index-threads.sh record <ip> <session_key> — add/update an entry
|
||||
# bash index-threads.sh needs-refresh — exit 0 if index is missing/stale
|
||||
#
|
||||
# Index location: STATE_DIR/thread-index.json
|
||||
# Format:
|
||||
# {
|
||||
# "indexed_at": "2026-02-16T12:00:00Z",
|
||||
# "channel_id": "1471181304782389381",
|
||||
# "threads": {
|
||||
# "1.2.3.4": {
|
||||
# "session_key": "agent:security:discord:channel:1471181304782389381:thread:🚨 1.2.3.4 — unauthorized gateway access",
|
||||
# "thread_name": "🚨 1.2.3.4 — unauthorized gateway access",
|
||||
# "first_indexed": "2026-02-10T08:00:00Z",
|
||||
# "last_updated": "2026-02-16T12:00:00Z",
|
||||
# "update_count": 3
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
set -e
|
||||
|
||||
STATE_DIR="/home/node/.openclaw/workspace-security/memory"
|
||||
INDEX_FILE="$STATE_DIR/thread-index.json"
|
||||
CHANNEL_ID="1471181304782389381"
|
||||
MAX_AGE=86400 # 24 hours before considered stale
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# Initialize empty index if missing
|
||||
init_index() {
|
||||
if [ ! -f "$INDEX_FILE" ] || [ ! -s "$INDEX_FILE" ]; then
|
||||
jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg ch "$CHANNEL_ID" \
|
||||
'{indexed_at: $ts, channel_id: $ch, threads: {}}' > "$INDEX_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if index needs refresh (missing, empty, or stale)
|
||||
needs_refresh() {
|
||||
if [ ! -f "$INDEX_FILE" ] || [ ! -s "$INDEX_FILE" ]; then
|
||||
echo "missing"
|
||||
return 0
|
||||
fi
|
||||
local age=$(( $(date +%s) - $(stat -c%Y "$INDEX_FILE" 2>/dev/null || echo 0) ))
|
||||
if [ "$age" -gt "$MAX_AGE" ]; then
|
||||
echo "stale"
|
||||
return 0
|
||||
fi
|
||||
echo "fresh"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Look up a thread by IP
|
||||
lookup() {
|
||||
local ip="$1"
|
||||
init_index
|
||||
local result
|
||||
result=$(jq -e --arg ip "$ip" '.threads[$ip] // empty' "$INDEX_FILE" 2>/dev/null)
|
||||
if [ -n "$result" ]; then
|
||||
echo "$result"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Record a thread entry for an IP
|
||||
record() {
|
||||
local ip="$1"
|
||||
local session_key="$2"
|
||||
local thread_name="${3:-🚨 $ip — unauthorized gateway access}"
|
||||
local now
|
||||
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
init_index
|
||||
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
jq --arg ip "$ip" \
|
||||
--arg sk "$session_key" \
|
||||
--arg tn "$thread_name" \
|
||||
--arg now "$now" \
|
||||
'
|
||||
.threads[$ip] = (
|
||||
(.threads[$ip] // {first_indexed: $now, update_count: 0}) |
|
||||
.session_key = $sk |
|
||||
.thread_name = $tn |
|
||||
.last_updated = $now |
|
||||
.update_count = (.update_count + 1)
|
||||
)
|
||||
' "$INDEX_FILE" > "$tmp" && mv "$tmp" "$INDEX_FILE"
|
||||
echo '{"ok":true}'
|
||||
}
|
||||
|
||||
# Print index status
|
||||
status() {
|
||||
init_index
|
||||
local count
|
||||
count=$(jq '.threads | length' "$INDEX_FILE")
|
||||
local indexed_at
|
||||
indexed_at=$(jq -r '.indexed_at' "$INDEX_FILE")
|
||||
local age=$(( $(date +%s) - $(stat -c%Y "$INDEX_FILE" 2>/dev/null || echo 0) ))
|
||||
echo "{\"entries\":$count,\"indexed_at\":\"$indexed_at\",\"age_seconds\":$age,\"stale\":$([ $age -gt $MAX_AGE ] && echo true || echo false)}"
|
||||
}
|
||||
|
||||
# Dispatch
|
||||
case "${1:-status}" in
|
||||
lookup) lookup "$2" ;;
|
||||
record) record "$2" "$3" "$4" ;;
|
||||
status) status ;;
|
||||
needs-refresh) needs_refresh ;;
|
||||
*) echo "Usage: $0 {lookup|record|status|needs-refresh} [args...]" >&2; exit 1 ;;
|
||||
esac
|
||||
76
workspace-security/monitor-unauthorized/scripts/log-splitter.sh
Executable file
76
workspace-security/monitor-unauthorized/scripts/log-splitter.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# Log splitter for monitor-unauthorized
|
||||
# Extracts unauthorized WebSocket connection entries from the gateway log
|
||||
# into a dedicated log file for efficient incremental processing.
|
||||
#
|
||||
# Usage:
|
||||
# bash log-splitter.sh — extract recent entries (batch mode)
|
||||
# bash log-splitter.sh --full — re-extract from entire log (rebuild)
|
||||
#
|
||||
# Filters for log lines containing:
|
||||
# - "forwardedFor" AND ("unauthorized" OR "pairing-required")
|
||||
#
|
||||
# Output: /tmp/openclaw/unauthorized-connections.log
|
||||
# Each line is a valid JSON object extracted from the gateway log.
|
||||
|
||||
set -e
|
||||
|
||||
GATEWAY_LOG="/tmp/openclaw/openclaw.log"
|
||||
UNAUTH_LOG="/tmp/openclaw/unauthorized-connections.log"
|
||||
STATE_DIR="/home/node/.openclaw/workspace-security/memory"
|
||||
OFFSET_FILE="$STATE_DIR/unauth-splitter-offset"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
touch "$UNAUTH_LOG"
|
||||
|
||||
FULL_MODE=false
|
||||
[ "${1:-}" = "--full" ] && FULL_MODE=true
|
||||
|
||||
if [ ! -f "$GATEWAY_LOG" ]; then
|
||||
echo '{"error":"Gateway log not found","log":"'"$GATEWAY_LOG"'"}' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILE_SIZE=$(stat -c%s "$GATEWAY_LOG")
|
||||
|
||||
# Determine where to start reading
|
||||
LAST_OFFSET=0
|
||||
if [ -f "$OFFSET_FILE" ] && [ "$FULL_MODE" != "true" ]; then
|
||||
LAST_OFFSET=$(cat "$OFFSET_FILE")
|
||||
fi
|
||||
|
||||
# If file shrank (log rotation), reset
|
||||
if [ "$LAST_OFFSET" -gt "$FILE_SIZE" ]; then
|
||||
LAST_OFFSET=0
|
||||
fi
|
||||
|
||||
BYTES_NEW=$((FILE_SIZE - LAST_OFFSET))
|
||||
|
||||
if [ "$BYTES_NEW" -le 0 ]; then
|
||||
echo "$FILE_SIZE" > "$OFFSET_FILE"
|
||||
echo '{"new_lines":0,"total_lines":'$(wc -l < "$UNAUTH_LOG" | tr -d ' ')'}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract unauthorized connection lines from new bytes
|
||||
# Filter: must have forwardedFor AND be unauthorized/pairing-required
|
||||
NEW_LINES=0
|
||||
TMPFILE=$(mktemp)
|
||||
trap "rm -f $TMPFILE" EXIT
|
||||
|
||||
tail -c +"$((LAST_OFFSET + 1))" "$GATEWAY_LOG" \
|
||||
| grep '"forwardedFor"' \
|
||||
| grep -E '"unauthorized"|"pairing-required"' \
|
||||
> "$TMPFILE" 2>/dev/null || true
|
||||
|
||||
NEW_LINES=$(wc -l < "$TMPFILE" | tr -d ' ')
|
||||
|
||||
if [ "$NEW_LINES" -gt 0 ]; then
|
||||
cat "$TMPFILE" >> "$UNAUTH_LOG"
|
||||
fi
|
||||
|
||||
# Save new offset
|
||||
echo "$FILE_SIZE" > "$OFFSET_FILE"
|
||||
|
||||
TOTAL_LINES=$(wc -l < "$UNAUTH_LOG" | tr -d ' ')
|
||||
echo "{\"new_lines\":$NEW_LINES,\"total_lines\":$TOTAL_LINES}"
|
||||
38
workspace-security/vt-monitor/SKILL.md
Normal file
38
workspace-security/vt-monitor/SKILL.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: vt-monitor
|
||||
description: >-
|
||||
Monitor VT-Sentinel activity from gateway logs. Parses scan events, uploads,
|
||||
verdicts, quarantines, blocks, and failures. Returns structured JSON of all
|
||||
VT-Sentinel activity since last check.
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "🛡️"
|
||||
---
|
||||
|
||||
# VT-Sentinel Monitor
|
||||
|
||||
Parses the OpenClaw gateway log for all VT-Sentinel activity and returns structured reports.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### `vt_monitor_check` — Check Recent Activity
|
||||
Returns all VT-Sentinel events since last check (or last N minutes).
|
||||
|
||||
```
|
||||
vt_monitor_check [minutes]
|
||||
```
|
||||
|
||||
Arguments:
|
||||
- `minutes` (optional, default: 60) — How far back to look
|
||||
|
||||
Output: JSON with categorized events (scans, uploads, verdicts, blocks, quarantines, failures).
|
||||
|
||||
### `vt_monitor_tail` — Live Tail
|
||||
Returns the last N VT-Sentinel log entries.
|
||||
|
||||
```
|
||||
vt_monitor_tail [count]
|
||||
```
|
||||
|
||||
Arguments:
|
||||
- `count` (optional, default: 50) — Number of recent entries
|
||||
111
workspace-security/vt-monitor/scripts/check.sh
Executable file
111
workspace-security/vt-monitor/scripts/check.sh
Executable file
@@ -0,0 +1,111 @@
|
||||
#!/bin/bash
|
||||
# Check VT-Sentinel activity — incremental (byte-offset tracking)
|
||||
# Only reads NEW log lines since last check
|
||||
set -e
|
||||
|
||||
LOG_FILE="/tmp/openclaw/vt-sentinel.log"
|
||||
STATE_DIR="/home/node/.openclaw/workspace-security/memory"
|
||||
OFFSET_FILE="$STATE_DIR/vt-log-offset"
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
echo '{"error":"Gateway log not found"}'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Current file size
|
||||
FILE_SIZE=$(stat -c%s "$LOG_FILE")
|
||||
|
||||
# Last read offset (0 = first run, reads last 1MB as bootstrap)
|
||||
LAST_OFFSET=0
|
||||
if [ -f "$OFFSET_FILE" ]; then
|
||||
LAST_OFFSET=$(cat "$OFFSET_FILE")
|
||||
fi
|
||||
|
||||
# If file shrank (log rotation), reset
|
||||
if [ "$LAST_OFFSET" -gt "$FILE_SIZE" ]; then
|
||||
LAST_OFFSET=0
|
||||
fi
|
||||
|
||||
# Dedicated log is small — no need for bootstrap limit
|
||||
SKIP=$LAST_OFFSET
|
||||
|
||||
BYTES_NEW=$((FILE_SIZE - SKIP))
|
||||
|
||||
# Nothing new
|
||||
if [ "$BYTES_NEW" -le 0 ]; then
|
||||
echo "$FILE_SIZE" > "$OFFSET_FILE"
|
||||
echo '{"totalEvents":0,"alerts":false,"summary":{"uploads":0,"upload_complete":0,"upload_failed":0,"cache_hits":0,"verdicts":{"clean":0,"malicious":0,"suspicious":0},"quarantined":0,"blocked":0},"events":[]}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read only new bytes, grep VT-Sentinel
|
||||
TMPFILE=$(mktemp)
|
||||
trap "rm -f $TMPFILE" EXIT
|
||||
|
||||
tail -c +"$((SKIP + 1))" "$LOG_FILE" | grep '"subsystem.*gateway"' | grep '\[VT-Sentinel\]' | while IFS= read -r line; do
|
||||
TIMESTAMP=$(echo "$line" | grep -oP '"date":"\K[^"]*' | head -1)
|
||||
MESSAGE=$(echo "$line" | grep -oP '\[VT-Sentinel\] \K[^"\\]*' | head -1)
|
||||
[ -z "$TIMESTAMP" ] || [ -z "$MESSAGE" ] && continue
|
||||
|
||||
CATEGORY="info"
|
||||
case "$MESSAGE" in
|
||||
*"Unknown"*"uploading"*) CATEGORY="upload" ;;
|
||||
*"Uploaded for analysis"*) CATEGORY="upload_complete" ;;
|
||||
*"Upload failed"*) CATEGORY="upload_failed" ;;
|
||||
*"Cache hit"*) CATEGORY="cache_hit" ;;
|
||||
*"MALICIOUS"*) CATEGORY="verdict_malicious" ;;
|
||||
*"SUSPICIOUS"*) CATEGORY="verdict_suspicious" ;;
|
||||
*"clean"*|*"BENIGN"*) CATEGORY="verdict_clean" ;;
|
||||
*"quarantin"*) CATEGORY="quarantine" ;;
|
||||
*"BLOCKED"*|*"blocked"*) CATEGORY="blocked" ;;
|
||||
*"Watching:"*) CATEGORY="config" ;;
|
||||
*"Plugin loaded"*|*"Service stopped"*|*"Auto-"*|*"Registered"*|*"Using"*) CATEGORY="lifecycle" ;;
|
||||
esac
|
||||
|
||||
jq -cn --arg ts "$TIMESTAMP" --arg msg "$MESSAGE" --arg cat "$CATEGORY" \
|
||||
'{"timestamp":$ts,"message":$msg,"category":$cat}'
|
||||
done > "$TMPFILE"
|
||||
|
||||
# Save new offset
|
||||
echo "$FILE_SIZE" > "$OFFSET_FILE"
|
||||
|
||||
TOTAL=$(wc -l < "$TMPFILE" | tr -d ' ')
|
||||
[ -z "$TOTAL" ] && TOTAL=0
|
||||
|
||||
if [ "$TOTAL" -eq 0 ]; then
|
||||
echo '{"totalEvents":0,"alerts":false,"summary":{"uploads":0,"upload_complete":0,"upload_failed":0,"cache_hits":0,"verdicts":{"clean":0,"malicious":0,"suspicious":0},"quarantined":0,"blocked":0},"events":[]}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EVENTS_JSON=$(jq -s '.' "$TMPFILE")
|
||||
|
||||
count_cat() {
|
||||
local c
|
||||
c=$(grep -c "\"category\":\"$1\"" "$TMPFILE" 2>/dev/null || true)
|
||||
echo "${c:-0}"
|
||||
}
|
||||
|
||||
jq -n \
|
||||
--argjson events "$EVENTS_JSON" \
|
||||
--argjson total "$TOTAL" \
|
||||
--argjson uploads "$(count_cat upload)" \
|
||||
--argjson upload_complete "$(count_cat upload_complete)" \
|
||||
--argjson upload_failed "$(count_cat upload_failed)" \
|
||||
--argjson cache_hits "$(count_cat cache_hit)" \
|
||||
--argjson clean "$(count_cat verdict_clean)" \
|
||||
--argjson malicious "$(count_cat verdict_malicious)" \
|
||||
--argjson suspicious "$(count_cat verdict_suspicious)" \
|
||||
--argjson quarantined "$(count_cat quarantine)" \
|
||||
--argjson blocked "$(count_cat blocked)" \
|
||||
'{
|
||||
totalEvents: $total,
|
||||
summary: {
|
||||
uploads: $uploads, upload_complete: $upload_complete, upload_failed: $upload_failed,
|
||||
cache_hits: $cache_hits,
|
||||
verdicts: {clean: $clean, malicious: $malicious, suspicious: $suspicious},
|
||||
quarantined: $quarantined, blocked: $blocked
|
||||
},
|
||||
alerts: ($malicious > 0 or $suspicious > 0 or $quarantined > 0 or $blocked > 0),
|
||||
events: $events
|
||||
}'
|
||||
136
workspace-security/vt-monitor/scripts/cron-wrapper.sh
Executable file
136
workspace-security/vt-monitor/scripts/cron-wrapper.sh
Executable file
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
# VT-Sentinel monitoring cron wrapper (incremental)
|
||||
# Checks both VT-Sentinel file activity AND plugin updates
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
LOG_FILE="/tmp/openclaw/openclaw.log"
|
||||
STATE_DIR="/home/node/.openclaw/workspace-security/memory"
|
||||
SEEN_FILE="$STATE_DIR/seen-plugin-updates.txt"
|
||||
mkdir -p "$STATE_DIR"
|
||||
touch "$SEEN_FILE"
|
||||
|
||||
OUTPUT=""
|
||||
|
||||
# --- VT-Sentinel activity ---
|
||||
REPORT=$("$SCRIPT_DIR/check.sh" 2>/dev/null)
|
||||
TOTAL=$(echo "$REPORT" | jq '.totalEvents')
|
||||
ALERTS=$(echo "$REPORT" | jq '.alerts')
|
||||
ACTION_EVENTS=$(echo "$REPORT" | jq '[.summary.uploads, .summary.upload_complete, .summary.upload_failed, .summary.cache_hits, .summary.verdicts.clean, .summary.verdicts.malicious, .summary.verdicts.suspicious, .summary.quarantined, .summary.blocked] | add')
|
||||
|
||||
if [ "$ACTION_EVENTS" -gt 0 ]; then
|
||||
OUTPUT+="🛡️ VT-Sentinel Activity Report\n===============================\n\n"
|
||||
OUTPUT+="📊 Summary: ${ACTION_EVENTS} file events\n"
|
||||
OUTPUT+=$(echo "$REPORT" | jq -r '
|
||||
.summary |
|
||||
(if .uploads > 0 then " ⬆️ Uploads initiated: \(.uploads)" else empty end),
|
||||
(if .upload_complete > 0 then " ✅ Uploads completed: \(.upload_complete)" else empty end),
|
||||
(if .upload_failed > 0 then " ❌ Upload failures: \(.upload_failed)" else empty end),
|
||||
(if .cache_hits > 0 then " 💾 Cache hits: \(.cache_hits)" else empty end),
|
||||
(if .verdicts.clean > 0 then " ✅ Clean verdicts: \(.verdicts.clean)" else empty end),
|
||||
(if .verdicts.malicious > 0 then " 🚨 MALICIOUS: \(.verdicts.malicious)" else empty end),
|
||||
(if .verdicts.suspicious > 0 then " ⚠️ SUSPICIOUS: \(.verdicts.suspicious)" else empty end),
|
||||
(if .quarantined > 0 then " 📦 Quarantined: \(.quarantined)" else empty end),
|
||||
(if .blocked > 0 then " 🚫 Blocked: \(.blocked)" else empty end)
|
||||
')
|
||||
OUTPUT+="\n\n📋 Event Details:\n"
|
||||
OUTPUT+=$(echo "$REPORT" | jq -r '.events[] | select(.category != "lifecycle" and .category != "config") | " [\(.timestamp)] \(.category): \(.message)"')
|
||||
|
||||
if [ "$ALERTS" = "true" ]; then
|
||||
OUTPUT+="\n\n⚠️ SECURITY ALERT: Review malicious/suspicious/blocked events above!"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- VT scan thread creation for new uploads ---
|
||||
PENDING_FILE="$STATE_DIR/pending-scans.json"
|
||||
[ ! -f "$PENDING_FILE" ] || [ ! -s "$PENDING_FILE" ] && echo '[]' > "$PENDING_FILE"
|
||||
SEEN_SCANS="$STATE_DIR/seen-scan-hashes.txt"
|
||||
touch "$SEEN_SCANS"
|
||||
|
||||
UPLOADS=$(echo "$REPORT" | jq -r '[.events[] | select(.category == "upload")] | .[]' 2>/dev/null)
|
||||
COMPLETES=$(echo "$REPORT" | jq -r '[.events[] | select(.category == "upload_complete")] | .[].message' 2>/dev/null)
|
||||
|
||||
if [ -n "$UPLOADS" ]; then
|
||||
# Extract filenames + risk categories from upload events
|
||||
echo "$REPORT" | jq -r '.events[] | select(.category == "upload") | .message' | while IFS= read -r msg; do
|
||||
RISK_CAT=$(echo "$msg" | grep -oP 'Unknown \K[A-Z_]+' || echo "UNKNOWN")
|
||||
FNAME=$(echo "$msg" | grep -oP 'file \K[^,]+' || echo "unknown")
|
||||
[ -z "$FNAME" ] || [ "$FNAME" = "unknown" ] && continue
|
||||
|
||||
# Check if already tracked
|
||||
grep -qF "$FNAME" "$SEEN_SCANS" 2>/dev/null && continue
|
||||
|
||||
# Find corresponding transaction ID from upload_complete events
|
||||
HASH=""
|
||||
if [ -n "$COMPLETES" ]; then
|
||||
# Get first unmatched transaction ID, decode to extract hash
|
||||
TXN_ID=$(echo "$COMPLETES" | grep -oP '\(\K[A-Za-z0-9+/=]+(?=\))' | head -1)
|
||||
if [ -n "$TXN_ID" ]; then
|
||||
HASH=$(echo "$TXN_ID" | base64 -d 2>/dev/null | cut -d: -f1)
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create thread
|
||||
RESULT=$("$SCRIPT_DIR/scan-thread.sh" "$FNAME" "$RISK_CAT" "${HASH:-pending}" 2>/dev/null)
|
||||
THREAD_ID=$(echo "$RESULT" | jq -r '.threadId // empty')
|
||||
|
||||
if [ -n "$THREAD_ID" ] && [ -n "$HASH" ]; then
|
||||
# Add to pending scans
|
||||
PENDING=$(cat "$PENDING_FILE")
|
||||
PENDING=$(echo "$PENDING" | jq --arg h "$HASH" --arg f "$FNAME" --arg t "$THREAD_ID" --arg r "$RISK_CAT" \
|
||||
'. += [{"hash":$h,"filename":$f,"threadId":$t,"riskCategory":$r}]')
|
||||
echo "$PENDING" > "$PENDING_FILE"
|
||||
OUTPUT+="\n🛡️ Created scan thread: [$RISK_CAT] $FNAME (hash: ${HASH:0:12}...)\n"
|
||||
fi
|
||||
|
||||
echo "$FNAME" >> "$SEEN_SCANS"
|
||||
done
|
||||
fi
|
||||
|
||||
# --- Follow up on pending scans ---
|
||||
PENDING_COUNT=$(jq 'length' "$PENDING_FILE" 2>/dev/null || echo 0)
|
||||
if [ "$PENDING_COUNT" -gt 0 ]; then
|
||||
FOLLOWUP=$("$SCRIPT_DIR/followup.sh" 2>/dev/null)
|
||||
if [ -n "$FOLLOWUP" ]; then
|
||||
OUTPUT+="\n$FOLLOWUP\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Plugin update check (creates Discord forum threads) ---
|
||||
# Filter: only plugins subsystem entries, strip ANSI + multibyte artifacts, deduplicate
|
||||
PLUGIN_UPDATES=$(tail -c 5000000 "$LOG_FILE" 2>/dev/null \
|
||||
| grep '"subsystem.*plugins"' \
|
||||
| grep -oP 'Update available: [^"\\]+' \
|
||||
| sed 's/\x1b\[[0-9;]*m//g; s/\[3[0-9]m//g' \
|
||||
| tr -d '\r' \
|
||||
| sort -u)
|
||||
if [ -n "$PLUGIN_UPDATES" ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
grep -qF "$line" "$SEEN_FILE" 2>/dev/null && continue
|
||||
|
||||
# Parse: "Update available: 0.4.0 → 0.6.0. Run: openclaw plugins install plugin-name"
|
||||
OLD_VER=$(echo "$line" | grep -oP 'Update available: \K[0-9.]+')
|
||||
NEW_VER=$(echo "$line" | grep -oP '→ \K[0-9.]+')
|
||||
INSTALL_CMD=$(echo "$line" | grep -oP 'Run: \K.*')
|
||||
PLUGIN_NAME=$(echo "$INSTALL_CMD" | grep -oP 'install \K\S+')
|
||||
|
||||
if [ -n "$PLUGIN_NAME" ] && [ -n "$OLD_VER" ] && [ -n "$NEW_VER" ]; then
|
||||
RESULT=$("$SCRIPT_DIR/plugin-update-thread.sh" "$PLUGIN_NAME" "$OLD_VER" "$NEW_VER" "$INSTALL_CMD" 2>/dev/null)
|
||||
if echo "$RESULT" | jq -e '.ok == true' >/dev/null 2>&1; then
|
||||
OUTPUT+="\n📦 Created thread for plugin update: ${PLUGIN_NAME} ${OLD_VER} → ${NEW_VER}\n"
|
||||
else
|
||||
OUTPUT+="\n📦 Plugin update: ${PLUGIN_NAME} ${OLD_VER} → ${NEW_VER} (thread creation failed)\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$line" >> "$SEEN_FILE"
|
||||
done <<< "$PLUGIN_UPDATES"
|
||||
fi
|
||||
|
||||
# --- Output ---
|
||||
if [ -z "$OUTPUT" ]; then
|
||||
echo "NO_REPLY"
|
||||
else
|
||||
echo -e "$OUTPUT"
|
||||
fi
|
||||
106
workspace-security/vt-monitor/scripts/followup.sh
Executable file
106
workspace-security/vt-monitor/scripts/followup.sh
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
# Follow up on pending VT scans — poll VT API, update Discord threads
|
||||
# Usage: followup.sh
|
||||
set -e
|
||||
|
||||
STATE_DIR="/home/node/.openclaw/workspace-security/memory"
|
||||
PENDING_FILE="$STATE_DIR/pending-scans.json"
|
||||
CHANNEL_ID="1470849667737714851"
|
||||
|
||||
# Initialize if missing
|
||||
if [ ! -f "$PENDING_FILE" ] || [ ! -s "$PENDING_FILE" ]; then
|
||||
echo '[]' > "$PENDING_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PENDING=$(cat "$PENDING_FILE")
|
||||
COUNT=$(echo "$PENDING" | jq 'length')
|
||||
[ "$COUNT" -eq 0 ] && exit 0
|
||||
|
||||
TOKEN=$(printenv DISCORD_BOT_TOKEN)
|
||||
VT_KEY=$(printenv VIRUSTOTAL_API_KEY)
|
||||
|
||||
if [ -z "$TOKEN" ] || [ -z "$VT_KEY" ]; then
|
||||
echo "Missing DISCORD_BOT_TOKEN or VIRUSTOTAL_API_KEY" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UPDATED="[]"
|
||||
RESOLVED=0
|
||||
|
||||
for i in $(seq 0 $((COUNT - 1))); do
|
||||
ENTRY=$(echo "$PENDING" | jq ".[$i]")
|
||||
HASH=$(echo "$ENTRY" | jq -r '.hash')
|
||||
THREAD_ID=$(echo "$ENTRY" | jq -r '.threadId')
|
||||
FILENAME=$(echo "$ENTRY" | jq -r '.filename')
|
||||
RISK_CAT=$(echo "$ENTRY" | jq -r '.riskCategory')
|
||||
|
||||
# Query VT API
|
||||
VT_RESULT=$(curl -s --max-time 10 -H "x-apikey: $VT_KEY" \
|
||||
"https://www.virustotal.com/api/v3/files/$HASH" 2>/dev/null)
|
||||
|
||||
STATS=$(echo "$VT_RESULT" | jq '.data.attributes.last_analysis_stats // empty' 2>/dev/null)
|
||||
|
||||
if [ -z "$STATS" ] || [ "$STATS" = "null" ]; then
|
||||
# Still pending or not found — keep in queue
|
||||
UPDATED=$(echo "$UPDATED" | jq --argjson entry "$ENTRY" '. += [$entry]')
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract verdict
|
||||
MALICIOUS=$(echo "$STATS" | jq '.malicious // 0')
|
||||
SUSPICIOUS=$(echo "$STATS" | jq '.suspicious // 0')
|
||||
UNDETECTED=$(echo "$STATS" | jq '.undetected // 0')
|
||||
TOTAL=$((MALICIOUS + SUSPICIOUS + UNDETECTED))
|
||||
TYPE_DESC=$(echo "$VT_RESULT" | jq -r '.data.attributes.type_description // "Unknown"')
|
||||
SHA256=$(echo "$VT_RESULT" | jq -r '.data.attributes.sha256 // "unknown"')
|
||||
VT_LINK="https://www.virustotal.com/gui/file/$SHA256"
|
||||
|
||||
# Determine verdict
|
||||
if [ "$MALICIOUS" -gt 0 ]; then
|
||||
VERDICT="🚨 MALICIOUS"
|
||||
EMOJI="🚨"
|
||||
VERDICT_SHORT="MALICIOUS ($MALICIOUS/$TOTAL)"
|
||||
elif [ "$SUSPICIOUS" -gt 0 ]; then
|
||||
VERDICT="⚠️ SUSPICIOUS"
|
||||
EMOJI="⚠️"
|
||||
VERDICT_SHORT="SUSPICIOUS ($SUSPICIOUS/$TOTAL)"
|
||||
else
|
||||
VERDICT="✅ CLEAN"
|
||||
EMOJI="✅"
|
||||
VERDICT_SHORT="CLEAN (0/$TOTAL)"
|
||||
fi
|
||||
|
||||
# Post verdict to thread
|
||||
MSG=$(printf '%s **Analysis Complete — %s**\n\n**File:** `%s`\n**Type:** %s\n**SHA-256:** `%s`\n\n**Results:**\n• Malicious: %s\n• Suspicious: %s\n• Undetected: %s engines\n\n**VT Link:** %s\n\n**Verdict:** %s' \
|
||||
"$EMOJI" "$VERDICT_SHORT" "$FILENAME" "$TYPE_DESC" "$SHA256" \
|
||||
"$MALICIOUS" "$SUSPICIOUS" "$UNDETECTED" "$VT_LINK" "$VERDICT")
|
||||
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bot $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg content "$MSG" '{content: $content}')" \
|
||||
"https://discord.com/api/v10/channels/$THREAD_ID/messages" > /dev/null
|
||||
|
||||
# Update thread title
|
||||
NEW_TITLE=$(printf '[%s] %s — %s' "$RISK_CAT" "$FILENAME" "$VERDICT")
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: Bot $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg name "$NEW_TITLE" '{name: $name}')" \
|
||||
"https://discord.com/api/v10/channels/$THREAD_ID" > /dev/null
|
||||
|
||||
RESOLVED=$((RESOLVED + 1))
|
||||
echo "✅ Resolved: $FILENAME → $VERDICT_SHORT"
|
||||
|
||||
# VT rate limit: 4 req/min on free tier, be conservative
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Save remaining pending scans
|
||||
echo "$UPDATED" > "$PENDING_FILE"
|
||||
|
||||
if [ "$RESOLVED" -gt 0 ]; then
|
||||
REMAINING=$(echo "$UPDATED" | jq 'length')
|
||||
echo "Resolved $RESOLVED scan(s), $REMAINING still pending"
|
||||
fi
|
||||
51
workspace-security/vt-monitor/scripts/log-splitter.sh
Executable file
51
workspace-security/vt-monitor/scripts/log-splitter.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# VT-Sentinel log splitter — extracts sentinel entries into dedicated log
|
||||
# Usage: bash log-splitter.sh [start|stop|status]
|
||||
set -eo pipefail
|
||||
|
||||
GATEWAY_LOG="/tmp/openclaw/openclaw.log"
|
||||
VT_LOG="/tmp/openclaw/vt-sentinel.log"
|
||||
PID_FILE="/tmp/openclaw/vt-log-splitter.pid"
|
||||
PATTERN="VT-Sentinel\|vt-sentinel\|vtsentinel"
|
||||
|
||||
start() {
|
||||
# Kill existing
|
||||
stop 2>/dev/null || true
|
||||
|
||||
# Start tail from current end of file
|
||||
nohup tail -F "$GATEWAY_LOG" 2>/dev/null \
|
||||
| grep --line-buffered -i "$PATTERN" \
|
||||
>> "$VT_LOG" 2>/dev/null &
|
||||
echo $! > "$PID_FILE"
|
||||
echo "Started (PID $(cat $PID_FILE)), writing to $VT_LOG"
|
||||
}
|
||||
|
||||
stop() {
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
# Kill the tail pipeline (parent + children)
|
||||
pkill -P "$PID" 2>/dev/null || true
|
||||
kill "$PID" 2>/dev/null || true
|
||||
rm -f "$PID_FILE"
|
||||
echo "Stopped"
|
||||
else
|
||||
echo "Not running"
|
||||
fi
|
||||
}
|
||||
|
||||
status() {
|
||||
if [ -f "$PID_FILE" ] && kill -0 "$(cat $PID_FILE)" 2>/dev/null; then
|
||||
echo "Running (PID $(cat $PID_FILE))"
|
||||
[ -f "$VT_LOG" ] && echo "Log size: $(du -h "$VT_LOG" | cut -f1), $(wc -l < "$VT_LOG") lines"
|
||||
else
|
||||
echo "Not running"
|
||||
rm -f "$PID_FILE" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-start}" in
|
||||
start) start ;;
|
||||
stop) stop ;;
|
||||
status) status ;;
|
||||
*) echo "Usage: $0 [start|stop|status]" ;;
|
||||
esac
|
||||
41
workspace-security/vt-monitor/scripts/plugin-update-thread.sh
Executable file
41
workspace-security/vt-monitor/scripts/plugin-update-thread.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# Create Discord forum thread for a plugin update notification
|
||||
# Usage: plugin-update-thread.sh "plugin-name" "old_version" "new_version" "install_cmd"
|
||||
set -e
|
||||
|
||||
PLUGIN="$1"
|
||||
OLD_VER="$2"
|
||||
NEW_VER="$3"
|
||||
INSTALL_CMD="$4"
|
||||
CHANNEL_ID="1470849667737714851"
|
||||
|
||||
TOKEN=$(printenv DISCORD_BOT_TOKEN)
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo '{"error":"DISCORD_BOT_TOKEN not set"}'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build content with real newlines using printf
|
||||
CONTENT=$(printf '📦 **Plugin Update Available**\n\n**Plugin:** `%s`\n**Current:** %s\n**Available:** %s\n\n**Install:**\n```\n%s\n```\n\n---\n*Detected by VT-Sentinel Monitor*' \
|
||||
"$PLUGIN" "$OLD_VER" "$NEW_VER" "$INSTALL_CMD")
|
||||
|
||||
THREAD_NAME=$(printf '📦 %s — %s → %s' "$PLUGIN" "$OLD_VER" "$NEW_VER")
|
||||
|
||||
# Use jq to properly encode the content with real newlines
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg name "$THREAD_NAME" \
|
||||
--arg content "$CONTENT" \
|
||||
'{name: $name, message: {content: $content}, auto_archive_duration: 1440}')
|
||||
|
||||
RESULT=$(curl -s -X POST \
|
||||
-H "Authorization: Bot $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"https://discord.com/api/v10/channels/${CHANNEL_ID}/threads")
|
||||
|
||||
THREAD_ID=$(echo "$RESULT" | jq -r '.id // empty')
|
||||
if [ -n "$THREAD_ID" ]; then
|
||||
echo "{\"ok\":true,\"threadId\":\"$THREAD_ID\",\"plugin\":\"$PLUGIN\"}"
|
||||
else
|
||||
echo "{\"ok\":false,\"error\":$(echo "$RESULT" | jq -c '.')}"
|
||||
fi
|
||||
35
workspace-security/vt-monitor/scripts/scan-thread.sh
Executable file
35
workspace-security/vt-monitor/scripts/scan-thread.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Create a Discord forum thread for a VT file scan event
|
||||
# Usage: scan-thread.sh "filename" "risk_category" ["hash"]
|
||||
set -e
|
||||
|
||||
FILENAME="$1"
|
||||
RISK_CAT="$2"
|
||||
HASH="${3:-unknown}"
|
||||
CHANNEL_ID="1470849667737714851"
|
||||
|
||||
TOKEN=$(printenv DISCORD_BOT_TOKEN)
|
||||
[ -z "$TOKEN" ] && echo '{"ok":false,"error":"no token"}' && exit 1
|
||||
|
||||
CONTENT=$(printf '🛡️ **VT-Sentinel File Scan**\n\n**File:** `%s`\n**Category:** %s\n**Status:** ⏳ PENDING — uploaded to VirusTotal for analysis\n**Hash:** `%s`\n\n---\n*Will update when verdict is available.*' \
|
||||
"$FILENAME" "$RISK_CAT" "$HASH")
|
||||
|
||||
THREAD_NAME=$(printf '[%s] %s — ⏳ PENDING' "$RISK_CAT" "$FILENAME")
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg name "$THREAD_NAME" \
|
||||
--arg content "$CONTENT" \
|
||||
'{name: $name, message: {content: $content}, auto_archive_duration: 1440}')
|
||||
|
||||
RESULT=$(curl -s -X POST \
|
||||
-H "Authorization: Bot $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"https://discord.com/api/v10/channels/${CHANNEL_ID}/threads")
|
||||
|
||||
THREAD_ID=$(echo "$RESULT" | jq -r '.id // empty')
|
||||
if [ -n "$THREAD_ID" ]; then
|
||||
echo "{\"ok\":true,\"threadId\":\"$THREAD_ID\"}"
|
||||
else
|
||||
echo "{\"ok\":false,\"error\":$(echo "$RESULT" | jq -c '.')}"
|
||||
fi
|
||||
20
workspace-security/vt-monitor/scripts/tail.sh
Executable file
20
workspace-security/vt-monitor/scripts/tail.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Tail recent VT-Sentinel log entries (last N from recent log tail)
|
||||
# Usage: tail.sh [count]
|
||||
set -e
|
||||
|
||||
COUNT="${1:-50}"
|
||||
LOG_FILE="/tmp/openclaw/openclaw.log"
|
||||
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
echo '{"error":"Gateway log not found"}'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read last 5MB, grep VT-Sentinel, take last N
|
||||
tail -c 5000000 "$LOG_FILE" | grep '"subsystem.*gateway"' | grep '\[VT-Sentinel\]' | tail -n "$COUNT" | while IFS= read -r line; do
|
||||
TIMESTAMP=$(echo "$line" | grep -oP '"date":"\K[^"]*' | head -1)
|
||||
MESSAGE=$(echo "$line" | grep -oP '\[VT-Sentinel\] \K[^"\\]*' | head -1)
|
||||
[ -n "$TIMESTAMP" ] && [ -n "$MESSAGE" ] && \
|
||||
jq -cn --arg ts "$TIMESTAMP" --arg msg "$MESSAGE" '{"timestamp":$ts,"message":$msg}'
|
||||
done | jq -s '{count:length, entries:.}'
|
||||
Reference in New Issue
Block a user