From 81bc03489515f47ca81c6c98cdb080ef4c633f01 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 05:20:44 +0200 Subject: [PATCH 01/14] feat(openclaw): openclawctl control-plane core (guards, status --json, start/stop, selftest) --- packages/secubox-openclaw/sbin/openclawctl | 68 +++++++++++++++++++ .../tests/test_openclawctl_guards.sh | 12 ++++ 2 files changed, 80 insertions(+) create mode 100755 packages/secubox-openclaw/sbin/openclawctl create mode 100755 packages/secubox-openclaw/tests/test_openclawctl_guards.sh diff --git a/packages/secubox-openclaw/sbin/openclawctl b/packages/secubox-openclaw/sbin/openclawctl new file mode 100755 index 00000000..507c2c0d --- /dev/null +++ b/packages/secubox-openclaw/sbin/openclawctl @@ -0,0 +1,68 @@ +#!/bin/bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# SecuBox OpenClaw controller — privileged LXC tool-runner control plane. +# The ONLY privileged surface for the openclaw module (invoked via sudo). +set -uo pipefail + +CONFIG_FILE="/etc/secubox/openclaw.toml" +LXC_PATH="${SECUBOX_LXC_PATH:-/data/lxc}" +CONTAINER="${SECUBOX_LXC_NAME:-openclaw}" +LXC_BRIDGE="${SECUBOX_LXC_BRIDGE:-br-lxc}" +LXC_IP="${SECUBOX_LXC_IP:-10.100.0.41}" +LXC_GW="${SECUBOX_LXC_GW:-10.100.0.1}" +SCANS_DIR="${SECUBOX_OPENCLAW_SCANS:-/var/lib/secubox/openclaw/scans}" + +err() { echo "[ERROR] $*" >&2; } + +# ---- injection guards (defense in depth; API validates too) ---- +_valid_target() { [[ "$1" =~ ^[A-Za-z0-9._:@/-]+$ ]]; } +_valid_scanid() { [[ "$1" =~ ^[a-f0-9]{8}$ ]]; } +_valid_type() { case "$1" in domain|ip|email|dns|whois|certs|ports) return 0;; *) return 1;; esac; } + +# ---- lxc helpers (mirror nextcloudctl) ---- +lxc_running() { lxc-info -n "$CONTAINER" -P "$LXC_PATH" 2>/dev/null | grep -q "State:.*RUNNING"; } +lxc_exists() { [ -d "$LXC_PATH/$CONTAINER/rootfs" ]; } +# Run a command in the container. Extra args after the command string are +# passed as positional $1.. to `sh -c` — NEVER interpolate targets into the +# command string. +lxc_attach() { local cmd="$1"; shift; lxc-attach -n "$CONTAINER" -P "$LXC_PATH" -- sh -c "$cmd" _ "$@"; } + +has_tool() { lxc_running && lxc_attach 'command -v "$1" >/dev/null' "$1"; } + +cmd_status() { + local running=false installed=false + lxc_exists && installed=true + lxc_running && running=true + local nmap=false dig=false whois=false curl=false + if [ "$running" = true ]; then + has_tool nmap && nmap=true + has_tool dig && dig=true + has_tool whois && whois=true + has_tool curl && curl=true + fi + printf '{"running":%s,"installed":%s,"ip":"%s","tools":{"nmap":%s,"dig":%s,"whois":%s,"curl":%s}}\n' \ + "$running" "$installed" "$LXC_IP" "$nmap" "$dig" "$whois" "$curl" +} + +cmd_start() { lxc_exists || { err "not installed"; return 1; }; lxc-start -n "$CONTAINER" -P "$LXC_PATH" 2>/dev/null; sleep 1; lxc_running; } +cmd_stop() { lxc-stop -n "$CONTAINER" -P "$LXC_PATH" 2>/dev/null; ! lxc_running; } +cmd_selftest() { lxc_running || { err "container not running"; return 1; }; lxc_attach 'nmap --version && dig -v 2>&1 | head -1 && whois --version 2>&1 | head -1'; } + +usage() { echo "usage: openclawctl {install|start|stop|status [--json]|scan |selftest}" >&2; } + +case "${1:-}" in + __guard) # hidden: validate a value, exit 0/1 (for tests) + case "${2:-}" in + target) _valid_target "${3:-}";; + scanid) _valid_scanid "${3:-}";; + type) _valid_type "${3:-}";; + *) exit 2;; + esac ;; + status) shift; cmd_status ;; + start) cmd_start ;; + stop) cmd_stop ;; + selftest) cmd_selftest ;; + install) cmd_install ;; # Task 2 + scan) shift; cmd_scan "$@" ;; # Task 3 + *) usage; exit 2 ;; +esac diff --git a/packages/secubox-openclaw/tests/test_openclawctl_guards.sh b/packages/secubox-openclaw/tests/test_openclawctl_guards.sh new file mode 100755 index 00000000..b950ebae --- /dev/null +++ b/packages/secubox-openclaw/tests/test_openclawctl_guards.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Exercises openclawctl's injection guards via the hidden __guard entrypoint. +set -u +CTL="$(dirname "$0")/../sbin/openclawctl" +fail=0 +ok() { "$CTL" __guard "$1" "$2" >/dev/null 2>&1 && echo "PASS accept $1 '$2'" || { echo "FAIL should-accept $1 '$2'"; fail=1; }; } +no() { "$CTL" __guard "$1" "$2" >/dev/null 2>&1 && { echo "FAIL should-reject $1 '$2'"; fail=1; } || echo "PASS reject $1 '$2'"; } +ok target "example.com"; ok target "192.168.1.10"; ok target "10.0.0.0/24"; ok target "a@b.com" +no target 'a;rm -rf /'; no target 'a b'; no target "$(printf 'a\nb')" +ok scanid "a1b2c3d4"; no scanid "XYZ"; no scanid "a1b2c3d4e5" +ok type domain; ok type ip; ok type email; ok type dns; ok type whois; ok type certs; ok type ports; no type pwn +exit $fail From 1220e59147ce37807655523879fb5b9bf1adbbcd Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 05:42:46 +0200 Subject: [PATCH 02/14] feat(openclaw): openclawctl install (debootstrap sandbox + nmap/dig/whois/curl toolchain) --- packages/secubox-openclaw/sbin/openclawctl | 40 +++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/secubox-openclaw/sbin/openclawctl b/packages/secubox-openclaw/sbin/openclawctl index 507c2c0d..d3aba7d9 100755 --- a/packages/secubox-openclaw/sbin/openclawctl +++ b/packages/secubox-openclaw/sbin/openclawctl @@ -20,7 +20,7 @@ _valid_scanid() { [[ "$1" =~ ^[a-f0-9]{8}$ ]]; } _valid_type() { case "$1" in domain|ip|email|dns|whois|certs|ports) return 0;; *) return 1;; esac; } # ---- lxc helpers (mirror nextcloudctl) ---- -lxc_running() { lxc-info -n "$CONTAINER" -P "$LXC_PATH" 2>/dev/null | grep -q "State:.*RUNNING"; } +lxc_running() { local _s; _s=$(lxc-info -n "$CONTAINER" -P "$LXC_PATH" 2>/dev/null); [[ "$_s" == *"State:"*"RUNNING"* ]]; } lxc_exists() { [ -d "$LXC_PATH/$CONTAINER/rootfs" ]; } # Run a command in the container. Extra args after the command string are # passed as positional $1.. to `sh -c` — NEVER interpolate targets into the @@ -48,6 +48,44 @@ cmd_start() { lxc_exists || { err "not installed"; return 1; }; lxc-start -n "$C cmd_stop() { lxc-stop -n "$CONTAINER" -P "$LXC_PATH" 2>/dev/null; ! lxc_running; } cmd_selftest() { lxc_running || { err "container not running"; return 1; }; lxc_attach 'nmap --version && dig -v 2>&1 | head -1 && whois --version 2>&1 | head -1'; } +_write_lxc_config() { + cat > "$LXC_PATH/$CONTAINER/config" < "$LXC_PATH/$CONTAINER/rootfs/etc/hostname" + printf 'nameserver %s\nnameserver 1.1.1.1\n' "$LXC_GW" > "$LXC_PATH/$CONTAINER/rootfs/etc/resolv.conf" + _write_lxc_config + lxc_running || lxc-start -n "$CONTAINER" -P "$LXC_PATH" 2>/dev/null + sleep 2 + echo "[openclaw] installing toolchain…" + lxc_attach 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y --no-install-recommends nmap ndiff dnsutils whois curl ca-certificates >/dev/null 2>&1' + lxc_attach 'command -v nmap >/dev/null && command -v dig >/dev/null && command -v whois >/dev/null' \ + && echo "[openclaw] install complete" || { err "toolchain missing after install"; return 1; } +} + usage() { echo "usage: openclawctl {install|start|stop|status [--json]|scan |selftest}" >&2; } case "${1:-}" in From d57b5decf8718d3aff263d890718f7e733290168 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 05:47:52 +0200 Subject: [PATCH 03/14] =?UTF-8?q?feat(openclaw):=20openclawctl=20scan=20?= =?UTF-8?q?=E2=80=94=20run=20tools=20in-container,=20write=20host-side=20r?= =?UTF-8?q?esult=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/secubox-openclaw/sbin/openclawctl | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/secubox-openclaw/sbin/openclawctl b/packages/secubox-openclaw/sbin/openclawctl index d3aba7d9..40a675f4 100755 --- a/packages/secubox-openclaw/sbin/openclawctl +++ b/packages/secubox-openclaw/sbin/openclawctl @@ -86,6 +86,37 @@ cmd_install() { && echo "[openclaw] install complete" || { err "toolchain missing after install"; return 1; } } +# jq is available on the HOST (SecuBox base). Build the record on the host from +# captured container stdout; never let the target reach a shell unquoted. +_scan_write() { # $1=id $2=json-object-of-record + local f="$SCANS_DIR/$1.json"; mkdir -p "$SCANS_DIR"; printf '%s\n' "$2" > "$f"; chmod 640 "$f" +} + +cmd_scan() { + local type="${1:-}" target="${2:-}" id="${3:-}" + _valid_type "$type" || { err "bad type"; return 2; } + _valid_target "$target"|| { err "bad target"; return 2; } + _valid_scanid "$id" || { err "bad id"; return 2; } + lxc_running || { _scan_write "$id" "$(jq -nc --arg i "$id" --arg t "$type" --arg g "$target" '{id:$i,type:$t,target:$g,status:"failed",error:"container not running",results:null}')"; return 1; } + local started; started="$(date -u +%FT%TZ)" + local raw rc + case "$type" in + dns) raw="$(lxc_attach 'dig +noall +answer ANY "$1"' "$target" 2>&1)"; rc=$? ;; + whois) raw="$(lxc_attach 'whois -- "$1"' "$target" 2>&1)"; rc=$? ;; + certs) raw="$(lxc_attach 'curl -s --max-time 20 "https://crt.sh/?q=$1&output=json"' "$target" 2>&1)"; rc=$? ;; + ports) raw="$(lxc_attach 'nmap -Pn -T4 --top-ports 100 -oG - "$1"' "$target" 2>&1)"; rc=$? ;; + ip) raw="$(lxc_attach 'nmap -Pn -T4 -sV --top-ports 200 "$1"' "$target" 2>&1)"; rc=$? ;; + domain) raw="$(lxc_attach 'echo "== DNS =="; dig +noall +answer ANY "$1"; echo "== WHOIS =="; whois -- "$1" 2>/dev/null | head -40; echo "== CERTS =="; curl -s --max-time 20 "https://crt.sh/?q=$1&output=json" | head -c 20000' "$target" 2>&1)"; rc=$? ;; + email) raw="$(lxc_attach 'd="${1#*@}"; echo "== MX =="; dig +short MX "$d"; echo "== SPF =="; dig +short TXT "$d" | grep -i spf' "$target" 2>&1)"; rc=$? ;; + *) err "unhandled type"; return 2 ;; + esac + local status="completed"; [ "$rc" -ne 0 ] && status="failed" + _scan_write "$id" "$(jq -nc --arg i "$id" --arg t "$type" --arg g "$target" --arg s "$status" \ + --arg st "$started" --arg fi "$(date -u +%FT%TZ)" --arg raw "$raw" \ + '{id:$i,type:$t,target:$g,status:$s,started_at:$st,finished_at:$fi,results:{raw:$raw},error:(if $s=="failed" then "tool exit non-zero" else null end)}')" + [ "$status" = completed ] +} + usage() { echo "usage: openclawctl {install|start|stop|status [--json]|scan |selftest}" >&2; } case "${1:-}" in From fe10cf5dcdcf704f136fa65ef4c9ec44d6657db2 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 05:55:37 +0200 Subject: [PATCH 04/14] docs(openclaw): tighten target regex in plan (reject leading-dash flag-injection) --- docs/superpowers/plans/2026-07-09-openclaw-lxc-scanner.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-09-openclaw-lxc-scanner.md b/docs/superpowers/plans/2026-07-09-openclaw-lxc-scanner.md index 4fe6104b..271a2829 100644 --- a/docs/superpowers/plans/2026-07-09-openclaw-lxc-scanner.md +++ b/docs/superpowers/plans/2026-07-09-openclaw-lxc-scanner.md @@ -15,7 +15,7 @@ - **Toolchain in container:** `nmap` (+ NSE), `dnsutils`, `whois`, `curl`, `ca-certificates`. Host adds `debootstrap`, `lxc` deps only — scan tools live in the container, never on the host. - **Privilege:** API runs as `secubox` under the aggregator (`NoNewPrivileges=no`). The ONLY privileged surface is `sudo -n /usr/sbin/openclawctl`, granted by `/etc/sudoers.d/secubox-openclaw`: `secubox ALL=(root) NOPASSWD: /usr/sbin/openclawctl` (`0440 root:root`). - **Aggregator safety:** every FastAPI route handler is plain `def` (threadpooled) — never `async def`. No handler runs a scan inline; scans are detached workers. `/status` and `/scans`-heavy reads use the single-flight + stale-while-revalidate cache. -- **Injection:** targets validated `^[A-Za-z0-9._:@/-]+$` and scan_id `^[a-f0-9]{8}$` **before** any shell/`lxc-attach`; inner `sh -c '…"$1"'` takes positional args, never string interpolation. Same rule in bash (`openclawctl`) and Python (`api/main.py`). +- **Injection:** targets validated `^[A-Za-z0-9][A-Za-z0-9._:@/-]*$` (leading alphanumeric — blocks leading-dash flag-injection) and scan_id `^[a-f0-9]{8}$` **before** any shell/`lxc-attach`; inner `sh -c '…"$1"'` takes positional args, never string interpolation. Same rule in bash (`openclawctl`) and Python (`api/main.py`). - **Target policy:** passive OSINT unrestricted; active `nmap` allowed for RFC1918/LAN + box-owned domains, else requires `authorized: true` (409 otherwise) and every active scan is appended to `/var/log/secubox/audit.log`. - **Paths that must NOT be re-chowned/loosened:** `/run/secubox` (1777 root:root), `/etc/secubox` (0755), `/var/log/secubox` (0755) parents. Scan store `/var/lib/secubox/openclaw/scans/` owned `secubox`, created with `mkdir(parents=True, exist_ok=True)`. - **Copy/naming:** module id `openclaw`; API base `/api/v1/openclaw`; dashboard at `/openclaw/`. @@ -390,7 +390,7 @@ SCANS_DIR = DATA_DIR / "scans" AUDIT_LOG = Path("/var/log/secubox/audit.log") SCANS_DIR.mkdir(parents=True, exist_ok=True) -_UID_RE = _re.compile(r"^[A-Za-z0-9._:@/-]+$") +_UID_RE = _re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]*$") _ID_RE = _re.compile(r"^[a-f0-9]{8}$") OWNED = [d.lower().lstrip(".") for d in config.get("owned_domains", ["gk2.secubox.in"])] From 87a345a530ddf7551a3c88e25735923436b042ec Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 05:56:57 +0200 Subject: [PATCH 05/14] fix(openclaw): reject leading-dash targets (flag-injection) + failure-record schema --- packages/secubox-openclaw/sbin/openclawctl | 12 ++++++++---- .../tests/test_openclawctl_guards.sh | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/secubox-openclaw/sbin/openclawctl b/packages/secubox-openclaw/sbin/openclawctl index 40a675f4..0be7c67f 100755 --- a/packages/secubox-openclaw/sbin/openclawctl +++ b/packages/secubox-openclaw/sbin/openclawctl @@ -15,7 +15,9 @@ SCANS_DIR="${SECUBOX_OPENCLAW_SCANS:-/var/lib/secubox/openclaw/scans}" err() { echo "[ERROR] $*" >&2; } # ---- injection guards (defense in depth; API validates too) ---- -_valid_target() { [[ "$1" =~ ^[A-Za-z0-9._:@/-]+$ ]]; } +# Require an alphanumeric first char so a leading '-' can never be parsed as a +# flag by dig/nmap/whois (flag-injection). Domains/IPs/emails/CIDRs all qualify. +_valid_target() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._:@/-]*$ ]]; } _valid_scanid() { [[ "$1" =~ ^[a-f0-9]{8}$ ]]; } _valid_type() { case "$1" in domain|ip|email|dns|whois|certs|ports) return 0;; *) return 1;; esac; } @@ -97,7 +99,7 @@ cmd_scan() { _valid_type "$type" || { err "bad type"; return 2; } _valid_target "$target"|| { err "bad target"; return 2; } _valid_scanid "$id" || { err "bad id"; return 2; } - lxc_running || { _scan_write "$id" "$(jq -nc --arg i "$id" --arg t "$type" --arg g "$target" '{id:$i,type:$t,target:$g,status:"failed",error:"container not running",results:null}')"; return 1; } + lxc_running || { local now; now="$(date -u +%FT%TZ)"; _scan_write "$id" "$(jq -nc --arg i "$id" --arg t "$type" --arg g "$target" --arg n "$now" '{id:$i,type:$t,target:$g,status:"failed",started_at:$n,finished_at:$n,results:null,error:"container not running"}')"; return 1; } local started; started="$(date -u +%FT%TZ)" local raw rc case "$type" in @@ -106,8 +108,10 @@ cmd_scan() { certs) raw="$(lxc_attach 'curl -s --max-time 20 "https://crt.sh/?q=$1&output=json"' "$target" 2>&1)"; rc=$? ;; ports) raw="$(lxc_attach 'nmap -Pn -T4 --top-ports 100 -oG - "$1"' "$target" 2>&1)"; rc=$? ;; ip) raw="$(lxc_attach 'nmap -Pn -T4 -sV --top-ports 200 "$1"' "$target" 2>&1)"; rc=$? ;; - domain) raw="$(lxc_attach 'echo "== DNS =="; dig +noall +answer ANY "$1"; echo "== WHOIS =="; whois -- "$1" 2>/dev/null | head -40; echo "== CERTS =="; curl -s --max-time 20 "https://crt.sh/?q=$1&output=json" | head -c 20000' "$target" 2>&1)"; rc=$? ;; - email) raw="$(lxc_attach 'd="${1#*@}"; echo "== MX =="; dig +short MX "$d"; echo "== SPF =="; dig +short TXT "$d" | grep -i spf' "$target" 2>&1)"; rc=$? ;; + # domain/email are OSINT aggregates: partial data is expected and the + # trailing head/grep would mask the real tool rc, so always report done. + domain) raw="$(lxc_attach 'echo "== DNS =="; dig +noall +answer ANY "$1"; echo "== WHOIS =="; whois -- "$1" 2>/dev/null | head -40; echo "== CERTS =="; curl -s --max-time 20 "https://crt.sh/?q=$1&output=json" | head -c 20000' "$target" 2>&1)"; rc=0 ;; + email) raw="$(lxc_attach 'd="${1#*@}"; echo "== MX =="; dig +short MX "$d"; echo "== SPF =="; dig +short TXT "$d" | grep -i spf' "$target" 2>&1)"; rc=0 ;; *) err "unhandled type"; return 2 ;; esac local status="completed"; [ "$rc" -ne 0 ] && status="failed" diff --git a/packages/secubox-openclaw/tests/test_openclawctl_guards.sh b/packages/secubox-openclaw/tests/test_openclawctl_guards.sh index b950ebae..d0653722 100755 --- a/packages/secubox-openclaw/tests/test_openclawctl_guards.sh +++ b/packages/secubox-openclaw/tests/test_openclawctl_guards.sh @@ -7,6 +7,7 @@ ok() { "$CTL" __guard "$1" "$2" >/dev/null 2>&1 && echo "PASS accept $1 '$2'" | no() { "$CTL" __guard "$1" "$2" >/dev/null 2>&1 && { echo "FAIL should-reject $1 '$2'"; fail=1; } || echo "PASS reject $1 '$2'"; } ok target "example.com"; ok target "192.168.1.10"; ok target "10.0.0.0/24"; ok target "a@b.com" no target 'a;rm -rf /'; no target 'a b'; no target "$(printf 'a\nb')" +no target '-f/etc/hostname'; no target '-iL/tmp/x' ok scanid "a1b2c3d4"; no scanid "XYZ"; no scanid "a1b2c3d4e5" ok type domain; ok type ip; ok type email; ok type dns; ok type whois; ok type certs; ok type ports; no type pwn exit $fail From 6b0151f78842f8d870f5a802682eb5f3a07ed804 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 06:03:36 +0200 Subject: [PATCH 06/14] fix(openclaw): close email-arm flag-injection (re-validate post-@ domain) --- packages/secubox-openclaw/sbin/openclawctl | 7 ++++++- packages/secubox-openclaw/tests/test_openclawctl_guards.sh | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/secubox-openclaw/sbin/openclawctl b/packages/secubox-openclaw/sbin/openclawctl index 0be7c67f..b2137ed7 100755 --- a/packages/secubox-openclaw/sbin/openclawctl +++ b/packages/secubox-openclaw/sbin/openclawctl @@ -111,7 +111,12 @@ cmd_scan() { # domain/email are OSINT aggregates: partial data is expected and the # trailing head/grep would mask the real tool rc, so always report done. domain) raw="$(lxc_attach 'echo "== DNS =="; dig +noall +answer ANY "$1"; echo "== WHOIS =="; whois -- "$1" 2>/dev/null | head -40; echo "== CERTS =="; curl -s --max-time 20 "https://crt.sh/?q=$1&output=json" | head -c 20000' "$target" 2>&1)"; rc=0 ;; - email) raw="$(lxc_attach 'd="${1#*@}"; echo "== MX =="; dig +short MX "$d"; echo "== SPF =="; dig +short TXT "$d" | grep -i spf' "$target" 2>&1)"; rc=0 ;; + # Derive the post-@ domain ON THE HOST and re-validate it: _valid_target + # anchors only the first char of the whole string, so 'a@-f/etc/hostname' + # passes as an email but its domain '-f/etc/hostname' would be a dig flag. + email) local _d="${target#*@}" + _valid_target "$_d" || { err "bad email domain"; return 2; } + raw="$(lxc_attach 'echo "== MX =="; dig +short MX "$1"; echo "== SPF =="; dig +short TXT "$1" | grep -i spf' "$_d" 2>&1)"; rc=0 ;; *) err "unhandled type"; return 2 ;; esac local status="completed"; [ "$rc" -ne 0 ] && status="failed" diff --git a/packages/secubox-openclaw/tests/test_openclawctl_guards.sh b/packages/secubox-openclaw/tests/test_openclawctl_guards.sh index d0653722..eb75a3c6 100755 --- a/packages/secubox-openclaw/tests/test_openclawctl_guards.sh +++ b/packages/secubox-openclaw/tests/test_openclawctl_guards.sh @@ -8,6 +8,11 @@ no() { "$CTL" __guard "$1" "$2" >/dev/null 2>&1 && { echo "FAIL should-reject $ ok target "example.com"; ok target "192.168.1.10"; ok target "10.0.0.0/24"; ok target "a@b.com" no target 'a;rm -rf /'; no target 'a b'; no target "$(printf 'a\nb')" no target '-f/etc/hostname'; no target '-iL/tmp/x' +# NB: 'a@-f/etc/hostname' PASSES _valid_target as a whole (email-shaped, starts +# alphanumeric). The flag-injection defense for the email arm is cmd_scan +# re-validating the post-@ segment ('-f/etc/hostname') with _valid_target +# before it reaches the container. Whole-string check below documents this: +ok target 'a@-f/etc/hostname'; no target "${_email_dom:=-f/etc/hostname}" ok scanid "a1b2c3d4"; no scanid "XYZ"; no scanid "a1b2c3d4e5" ok type domain; ok type ip; ok type email; ok type dns; ok type whois; ok type certs; ok type ports; no type pwn exit $fail From 5e81a25e1279ac48bfeb31ec98ac710e3baf10fd Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 06:13:53 +0200 Subject: [PATCH 07/14] =?UTF-8?q?feat(openclaw):=20API=20core=20=E2=80=94?= =?UTF-8?q?=20ctl=20routing,=20def+cached=20status,=20target=20classificat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/secubox-openclaw/api/__init__.py | 1 + packages/secubox-openclaw/api/main.py | 229 ++++++++++++------ packages/secubox-openclaw/tests/__init__.py | 0 packages/secubox-openclaw/tests/conftest.py | 11 + .../secubox-openclaw/tests/test_policy.py | 17 ++ .../tests/test_status_cache.py | 19 ++ 6 files changed, 200 insertions(+), 77 deletions(-) create mode 100644 packages/secubox-openclaw/api/__init__.py create mode 100644 packages/secubox-openclaw/tests/__init__.py create mode 100644 packages/secubox-openclaw/tests/conftest.py create mode 100644 packages/secubox-openclaw/tests/test_policy.py create mode 100644 packages/secubox-openclaw/tests/test_status_cache.py diff --git a/packages/secubox-openclaw/api/__init__.py b/packages/secubox-openclaw/api/__init__.py new file mode 100644 index 00000000..c7d90fb2 --- /dev/null +++ b/packages/secubox-openclaw/api/__init__.py @@ -0,0 +1 @@ +# SecuBox OpenClaw API diff --git a/packages/secubox-openclaw/api/main.py b/packages/secubox-openclaw/api/main.py index 28d97586..0865e5c0 100644 --- a/packages/secubox-openclaw/api/main.py +++ b/packages/secubox-openclaw/api/main.py @@ -1,49 +1,168 @@ -"""SecuBox OpenClaw API - OSINT Intelligence Gathering +"""SecuBox OpenClaw API — OSINT + active scanner driven through a sandboxed LXC. -Open Source Intelligence (OSINT) tool for reconnaissance and information gathering: -- Domain reconnaissance -- IP intelligence -- Email harvesting detection -- Social media footprint -- DNS enumeration -- Whois lookup -- Subdomain discovery -- Certificate transparency -- Shodan/Censys integration +Every handler is plain `def` (FastAPI threadpools it) — the module is mounted +in-process by the aggregator, so an async handler running subprocess would +freeze the shared loop. Container ops go through `sudo -n openclawctl`. """ -import asyncio -import subprocess -import re +import re as _re import os import json -import socket import time -import uuid -import hashlib +import ipaddress +import threading +import subprocess from pathlib import Path -from datetime import datetime, timedelta -from typing import Optional, List, Dict, Any -from enum import Enum -from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks, Query -from fastapi.responses import Response -from pydantic import BaseModel, Field -import httpx +from datetime import datetime, timezone +from typing import Optional +from fastapi import FastAPI, Depends, HTTPException +from pydantic import BaseModel from secubox_core.auth import require_jwt from secubox_core.config import get_config -app = FastAPI(title="SecuBox OpenClaw", version="1.0.0") +app = FastAPI(title="SecuBox OpenClaw", version="2.0.0") config = get_config("openclaw") -# Data directories +CTL = "/usr/sbin/openclawctl" +CONTAINER_IP = config.get("lxc_ip", "10.100.0.41") DATA_DIR = Path("/var/lib/secubox/openclaw") SCANS_DIR = DATA_DIR / "scans" -CONFIG_FILE = DATA_DIR / "config.json" -CACHE_DIR = Path("/var/cache/secubox/openclaw") - -# Ensure directories exist -DATA_DIR.mkdir(parents=True, exist_ok=True) +AUDIT_LOG = Path("/var/log/secubox/audit.log") SCANS_DIR.mkdir(parents=True, exist_ok=True) -CACHE_DIR.mkdir(parents=True, exist_ok=True) + +_UID_RE = _re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]*$") +_ID_RE = _re.compile(r"^[a-f0-9]{8}$") +OWNED = [d.lower().lstrip(".") for d in config.get("owned_domains", ["gk2.secubox.in"])] + + +def run_cmd(cmd, timeout=60): + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode == 0, r.stdout.strip(), r.stderr.strip() + except subprocess.TimeoutExpired: + return False, "", "timed out" + except Exception as e: # pragma: no cover + return False, "", str(e) + + +def ctl(subcmd, timeout=60, stdin=None): + """`sudo -n openclawctl ` — the only privileged path. Fail-safe.""" + cmd = ["sudo", "-n", CTL, *subcmd] + if stdin is None: + return run_cmd(cmd, timeout) + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, input=stdin) + return r.returncode == 0, r.stdout.strip(), r.stderr.strip() + except Exception as e: # pragma: no cover + return False, "", str(e) + + +def _valid_target(t): return bool(t) and bool(_UID_RE.fullmatch(t)) +def _valid_scanid(i): return bool(i) and bool(_ID_RE.fullmatch(i)) + + +def _is_local_or_owned(target: str) -> bool: + """True if target is RFC1918/loopback/link-local (IP or CIDR) or a box-owned + domain suffix. Used to gate active scans without an explicit authorization.""" + t = target.strip().lower() + host = t.split("/")[0].split("@")[-1] + try: + ip = ipaddress.ip_address(host) + return ip.is_private or ip.is_loopback or ip.is_link_local + except ValueError: + pass + try: + net = ipaddress.ip_network(t, strict=False) + return net.is_private or net.is_loopback + except ValueError: + pass + return any(host == d or host.endswith("." + d) for d in OWNED) + + +# ---- single-flight, stale-while-revalidate cache (ported from nextcloud) ---- +_STATS_CACHE: dict = {} +_CACHE_LOCKS: dict = {} +_CACHE_LOCKS_GUARD = threading.Lock() + +def _cache_lock(key): + with _CACHE_LOCKS_GUARD: + return _CACHE_LOCKS.setdefault(key, threading.Lock()) + +def _cached(key, ttl, producer): + now = time.monotonic(); hit = _STATS_CACHE.get(key) + if hit and (now - hit[0]) < ttl: + return hit[1] + lock = _cache_lock(key) + if hit is not None: + if lock.acquire(blocking=False): + def _bg(): + try: _STATS_CACHE[key] = (time.monotonic(), producer()) + except Exception: pass + finally: lock.release() + threading.Thread(target=_bg, name=f"oc-cache-{key}", daemon=True).start() + return hit[1] + with lock: + hit = _STATS_CACHE.get(key) + if hit and (time.monotonic() - hit[0]) < ttl: + return hit[1] + val = producer(); _STATS_CACHE[key] = (time.monotonic(), val); return val + +def _invalidate_stats(): _STATS_CACHE.clear() + + +def _ctl_status(): + ok, out, _ = ctl(["status", "--json"], timeout=25) + if not ok: + return {"running": False, "installed": False, "ip": CONTAINER_IP, + "tools": {"nmap": False, "dig": False, "whois": False, "curl": False}} + try: + return json.loads(out) + except Exception: + return {"running": False, "installed": False, "ip": CONTAINER_IP, "tools": {}} + +def lxc_running() -> bool: + return bool(_ctl_status().get("running")) + +def _require_installed(): + if not _ctl_status().get("installed"): + raise HTTPException(409, "OpenClaw container is not installed") + + +@app.get("/health") +def health(): + return {"status": "ok", "module": "openclaw"} + +@app.get("/status") +def status(): + return _cached("status", 15.0, _compute_status) + +def _compute_status(): + s = _ctl_status() + return {"module": "openclaw", "enabled": config.get("enabled", True), + "running": s.get("running", False), "installed": s.get("installed", False), + "ip": s.get("ip", CONTAINER_IP), "tools": s.get("tools", {}), + "total_scans": len(list(SCANS_DIR.glob("*.json")))} + +@app.get("/config", dependencies=[Depends(require_jwt)]) +def get_config_endpoint(): + return {"enabled": config.get("enabled", True), "owned_domains": OWNED, + "integrations": {k: bool(config.get(k)) for k in + ("shodan_api_key", "censys_api_id", "virustotal_api_key")}} + + +# ============================================================================ +# Legacy OSINT scan code — TEMPORARY, replaced in Task 5. +# ============================================================================ +import asyncio +import re +import socket +import uuid +from typing import List, Dict, Any +from enum import Enum +from fastapi import BackgroundTasks, Query +from fastapi.responses import Response +import httpx + +CONFIG_FILE = DATA_DIR / "config.json" # Default configuration DEFAULT_CONFIG = { @@ -712,53 +831,9 @@ async def _run_scan(scan_id: str, target: str, scan_type: ScanType, options: Dic # ============================================================================ -# API Endpoints +# Legacy scan API Endpoints — replaced in Task 5 # ============================================================================ -@app.get("/health") -async def health(): - """Health check endpoint.""" - return {"status": "healthy", "service": "secubox-openclaw", "version": "1.0.0"} - - -@app.get("/status") -async def status(): - """Status endpoint with statistics.""" - scans = _list_scans(1000) - cfg = _load_config() - - return { - "module": "openclaw", - "status": "ok", - "version": "1.0.0", - "total_scans": len(scans), - "completed_scans": sum(1 for s in scans if s.get("status") == "completed"), - "integrations": { - "shodan": bool(cfg.get("shodan_api_key")), - "censys": bool(cfg.get("censys_api_id")), - "virustotal": bool(cfg.get("virustotal_api_key")), - "securitytrails": bool(cfg.get("securitytrails_api_key")) - } - } - - -@app.get("/config", dependencies=[Depends(require_jwt)]) -async def get_config_endpoint(): - """Get current configuration (sensitive values masked).""" - cfg = _load_config() - - # Mask sensitive values - masked = cfg.copy() - for key in ["shodan_api_key", "censys_api_id", "censys_api_secret", - "virustotal_api_key", "securitytrails_api_key"]: - if masked.get(key): - masked[key] = "***configured***" - else: - masked[key] = "" - - return masked - - @app.post("/config", dependencies=[Depends(require_jwt)]) async def update_config(update: ConfigUpdate): """Update configuration.""" diff --git a/packages/secubox-openclaw/tests/__init__.py b/packages/secubox-openclaw/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/secubox-openclaw/tests/conftest.py b/packages/secubox-openclaw/tests/conftest.py new file mode 100644 index 00000000..67a160dd --- /dev/null +++ b/packages/secubox-openclaw/tests/conftest.py @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +"""Seed secubox_core config so `api.main` imports without reading /etc.""" +import secubox_core.config as _cfgmod +_cfgmod._CONFIG = { + "openclaw": { + "enabled": True, + "container_name": "openclaw", + "lxc_ip": "10.100.0.41", + "owned_domains": ["gk2.secubox.in"], + }, +} diff --git a/packages/secubox-openclaw/tests/test_policy.py b/packages/secubox-openclaw/tests/test_policy.py new file mode 100644 index 00000000..73a113b4 --- /dev/null +++ b/packages/secubox-openclaw/tests/test_policy.py @@ -0,0 +1,17 @@ +import importlib +def _load(monkeypatch): + import api.main as m; importlib.reload(m) + from secubox_core.auth import require_jwt + m.app.dependency_overrides[require_jwt] = lambda: {"sub": "admin"} + return m + +def test_local_targets_are_owned(monkeypatch): + m = _load(monkeypatch) + assert m._is_local_or_owned("192.168.1.10") is True + assert m._is_local_or_owned("10.0.0.5") is True + assert m._is_local_or_owned("nc.gk2.secubox.in") is True # box-owned suffix + +def test_external_targets_not_owned(monkeypatch): + m = _load(monkeypatch) + assert m._is_local_or_owned("scanme.nmap.org") is False + assert m._is_local_or_owned("8.8.8.8") is False diff --git a/packages/secubox-openclaw/tests/test_status_cache.py b/packages/secubox-openclaw/tests/test_status_cache.py new file mode 100644 index 00000000..452fca24 --- /dev/null +++ b/packages/secubox-openclaw/tests/test_status_cache.py @@ -0,0 +1,19 @@ +import importlib +from fastapi.testclient import TestClient +def _load(monkeypatch): + import api.main as m; importlib.reload(m) + from secubox_core.auth import require_jwt + m.app.dependency_overrides[require_jwt] = lambda: {"sub": "admin"} + return m + +def test_status_single_flight(monkeypatch): + m = _load(monkeypatch); calls = {"n": 0} + def counting(sub, *a, **k): + if list(sub[:1]) == ["status"]: calls["n"] += 1 + return (True, '{"running":true,"installed":true,"ip":"10.100.0.41","tools":{"nmap":true,"dig":true,"whois":true,"curl":true}}', "") + monkeypatch.setattr(m, "ctl", counting) + c = TestClient(m.app) + c.get("/status"); c.get("/status") + assert calls["n"] == 1 # 2nd served from cache + m._invalidate_stats(); c.get("/status") + assert calls["n"] == 2 From 3dee901d92544cc2e25b477e5fc866d0b3dae763 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 06:21:10 +0200 Subject: [PATCH 08/14] fix(openclaw): remove disconnected legacy POST /config + unused imports --- packages/secubox-openclaw/api/main.py | 31 +++++---------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/packages/secubox-openclaw/api/main.py b/packages/secubox-openclaw/api/main.py index 0865e5c0..975ac458 100644 --- a/packages/secubox-openclaw/api/main.py +++ b/packages/secubox-openclaw/api/main.py @@ -5,14 +5,13 @@ in-process by the aggregator, so an async handler running subprocess would freeze the shared loop. Container ops go through `sudo -n openclawctl`. """ import re as _re -import os import json import time import ipaddress import threading import subprocess from pathlib import Path -from datetime import datetime, timezone +from datetime import datetime from typing import Optional from fastapi import FastAPI, Depends, HTTPException from pydantic import BaseModel @@ -198,18 +197,6 @@ class ScanRequest(BaseModel): options: Optional[Dict[str, Any]] = None -class ConfigUpdate(BaseModel): - shodan_api_key: Optional[str] = None - censys_api_id: Optional[str] = None - censys_api_secret: Optional[str] = None - virustotal_api_key: Optional[str] = None - securitytrails_api_key: Optional[str] = None - max_concurrent_scans: Optional[int] = None - scan_timeout: Optional[int] = None - cache_ttl: Optional[int] = None - dns_servers: Optional[List[str]] = None - - class ExportRequest(BaseModel): scan_id: str format: str = "json" # json, csv, xml @@ -833,18 +820,10 @@ async def _run_scan(scan_id: str, target: str, scan_type: ScanType, options: Dic # ============================================================================ # Legacy scan API Endpoints — replaced in Task 5 # ============================================================================ - -@app.post("/config", dependencies=[Depends(require_jwt)]) -async def update_config(update: ConfigUpdate): - """Update configuration.""" - cfg = _load_config() - - updates = update.dict(exclude_none=True) - cfg.update(updates) - _save_config(cfg) - - return {"status": "updated", "updated_fields": list(updates.keys())} - +# NOTE: no POST /config here — config is operator-edited TOML/secrets (GET /config +# in the core is read-only). The old writable POST /config wrote a stale +# config.json that GET /config never read; removed to avoid a silently +# diverging endpoint. @app.post("/scan/domain", dependencies=[Depends(require_jwt)]) async def scan_domain(target: str, background_tasks: BackgroundTasks): From ad5fc2c6386353693ee8d56ac1d7e68bc3a6e300 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 06:23:39 +0200 Subject: [PATCH 09/14] feat(openclaw): async-job scan endpoints + target policy/audit + sync lookups; drop async machinery --- packages/secubox-openclaw/api/main.py | 1005 ++--------------- packages/secubox-openclaw/tests/test_scans.py | 60 + 2 files changed, 150 insertions(+), 915 deletions(-) create mode 100644 packages/secubox-openclaw/tests/test_scans.py diff --git a/packages/secubox-openclaw/api/main.py b/packages/secubox-openclaw/api/main.py index 975ac458..2f16267f 100644 --- a/packages/secubox-openclaw/api/main.py +++ b/packages/secubox-openclaw/api/main.py @@ -4,6 +4,7 @@ Every handler is plain `def` (FastAPI threadpools it) — the module is mounted in-process by the aggregator, so an async handler running subprocess would freeze the shared loop. Container ops go through `sudo -n openclawctl`. """ +import os import re as _re import json import time @@ -11,8 +12,7 @@ import ipaddress import threading import subprocess from pathlib import Path -from datetime import datetime -from typing import Optional +from datetime import datetime, timezone from fastapi import FastAPI, Depends, HTTPException from pydantic import BaseModel from secubox_core.auth import require_jwt @@ -148,950 +148,125 @@ def get_config_endpoint(): ("shodan_api_key", "censys_api_id", "virustotal_api_key")}} + # ============================================================================ -# Legacy OSINT scan code — TEMPORARY, replaced in Task 5. +# Scan API — async-job model. Every handler is plain `def`; the worker is a +# fully-detached subprocess (`sudo -n openclawctl scan ...`) started via +# start_new_session=True so it never blocks the aggregator's shared loop. # ============================================================================ -import asyncio -import re -import socket -import uuid -from typing import List, Dict, Any -from enum import Enum -from fastapi import BackgroundTasks, Query -from fastapi.responses import Response -import httpx -CONFIG_FILE = DATA_DIR / "config.json" - -# Default configuration -DEFAULT_CONFIG = { - "shodan_api_key": "", - "censys_api_id": "", - "censys_api_secret": "", - "virustotal_api_key": "", - "securitytrails_api_key": "", - "max_concurrent_scans": 3, - "scan_timeout": 300, - "cache_ttl": 3600, - "dns_servers": ["8.8.8.8", "1.1.1.1"], - "user_agent": "SecuBox-OpenClaw/1.0 OSINT Scanner" -} - - -class ScanType(str, Enum): - DOMAIN = "domain" - IP = "ip" - EMAIL = "email" - - -class ScanStatus(str, Enum): - PENDING = "pending" - RUNNING = "running" - COMPLETED = "completed" - FAILED = "failed" - - -class ScanRequest(BaseModel): +class ScanReq(BaseModel): target: str - scan_type: ScanType = ScanType.DOMAIN - options: Optional[Dict[str, Any]] = None + authorized: bool = False +# domain/email = passive (unrestricted); ip/ports = active (policy-gated) +_ACTIVE_TYPES = {"ip", "ports"} -class ExportRequest(BaseModel): - scan_id: str - format: str = "json" # json, csv, xml +def _new_id(): + return os.urandom(4).hex() +def _spawn_worker(scan_type: str, target: str, scan_id: str): + """Detached — runs entirely off the aggregator. openclawctl does the work + and writes scans/.json. We only record 'pending' first.""" + rec = {"id": scan_id, "type": scan_type, "target": target, "status": "pending", + "started_at": datetime.now(timezone.utc).isoformat(), "results": None, "error": None} + (SCANS_DIR / f"{scan_id}.json").write_text(json.dumps(rec)) + subprocess.Popen(["sudo", "-n", CTL, "scan", scan_type, target, scan_id], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True) -# Configuration management -def _load_config() -> Dict: - """Load configuration.""" - if CONFIG_FILE.exists(): - try: - with open(CONFIG_FILE) as f: - cfg = json.load(f) - return {**DEFAULT_CONFIG, **cfg} - except Exception: - pass - return DEFAULT_CONFIG.copy() - - -def _save_config(cfg: Dict): - """Save configuration.""" - with open(CONFIG_FILE, 'w') as f: - json.dump(cfg, f, indent=2) - - -# Scan management -def _load_scan(scan_id: str) -> Optional[Dict]: - """Load a scan by ID.""" - scan_file = SCANS_DIR / f"{scan_id}.json" - if scan_file.exists(): - try: - with open(scan_file) as f: - return json.load(f) - except Exception: - pass - return None - - -def _save_scan(scan: Dict): - """Save a scan.""" - scan_file = SCANS_DIR / f"{scan['id']}.json" - with open(scan_file, 'w') as f: - json.dump(scan, f, indent=2) - - -def _list_scans(limit: int = 50) -> List[Dict]: - """List all scans, most recent first.""" - scans = [] - for f in SCANS_DIR.glob("*.json"): - try: - with open(f) as fp: - scan = json.load(fp) - scans.append({ - "id": scan.get("id"), - "target": scan.get("target"), - "type": scan.get("type"), - "status": scan.get("status"), - "created_at": scan.get("created_at"), - "completed_at": scan.get("completed_at"), - "findings_count": len(scan.get("results", {}).get("findings", [])) - }) - except Exception: - pass - scans.sort(key=lambda x: x.get("created_at", ""), reverse=True) - return scans[:limit] - - -# DNS enumeration -async def _dns_lookup(domain: str, record_type: str = "A") -> List[str]: - """Perform DNS lookup.""" - results = [] +def _audit(op, scan_type, target, authorized, scan_id): try: - proc = await asyncio.create_subprocess_exec( - "dig", "+short", domain, record_type, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30) - for line in stdout.decode().strip().split('\n'): - if line: - results.append(line.strip()) - except Exception: - pass - return results - - -async def _dns_enumeration(domain: str) -> Dict: - """Full DNS enumeration.""" - record_types = ["A", "AAAA", "MX", "NS", "TXT", "SOA", "CNAME", "PTR", "SRV"] - results = {} - - tasks = [] - for rtype in record_types: - tasks.append(_dns_lookup(domain, rtype)) - - records = await asyncio.gather(*tasks) - - for rtype, values in zip(record_types, records): - if values: - results[rtype] = values - - return results - - -# Whois lookup -async def _whois_lookup(target: str) -> Dict: - """Perform WHOIS lookup.""" - result = { - "raw": "", - "parsed": {}, - "error": None - } - - try: - proc = await asyncio.create_subprocess_exec( - "whois", target, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60) - result["raw"] = stdout.decode() - - # Parse common fields - raw = result["raw"] - patterns = { - "registrar": r"Registrar:\s*(.+)", - "creation_date": r"Creation Date:\s*(.+)", - "expiry_date": r"(?:Expir(?:y|ation) Date|Registry Expiry Date):\s*(.+)", - "updated_date": r"Updated Date:\s*(.+)", - "name_servers": r"Name Server:\s*(.+)", - "status": r"Status:\s*(.+)", - "registrant_org": r"Registrant Organization:\s*(.+)", - "registrant_country": r"Registrant Country:\s*(.+)", - "admin_email": r"Admin Email:\s*(.+)", - "tech_email": r"Tech Email:\s*(.+)", - } - - for key, pattern in patterns.items(): - matches = re.findall(pattern, raw, re.IGNORECASE) - if matches: - result["parsed"][key] = matches if len(matches) > 1 else matches[0] - - except asyncio.TimeoutError: - result["error"] = "Timeout" - except Exception as e: - result["error"] = str(e) - - return result - - -# Subdomain discovery -async def _discover_subdomains(domain: str) -> List[str]: - """Discover subdomains using various techniques.""" - subdomains = set() - - # Common subdomains to check - common_prefixes = [ - "www", "mail", "ftp", "webmail", "smtp", "pop", "imap", "blog", - "admin", "administrator", "api", "app", "apps", "beta", "cdn", - "cloud", "cpanel", "dashboard", "db", "dev", "download", "files", - "forum", "git", "gitlab", "help", "images", "img", "info", "intranet", - "jenkins", "jira", "login", "m", "mobile", "mysql", "news", "ns", - "ns1", "ns2", "ns3", "old", "panel", "portal", "proxy", "remote", - "search", "secure", "server", "shop", "ssl", "staging", "static", - "status", "store", "support", "test", "vpn", "web", "wiki", "www2" - ] - - async def check_subdomain(sub: str) -> Optional[str]: - full = f"{sub}.{domain}" - try: - socket.gethostbyname(full) - return full - except socket.gaierror: - return None - - # Check common subdomains in parallel - tasks = [check_subdomain(sub) for sub in common_prefixes] - results = await asyncio.gather(*tasks, return_exceptions=True) - - for result in results: - if result and not isinstance(result, Exception): - subdomains.add(result) - - # Try zone transfer (usually fails but worth trying) - try: - ns_records = await _dns_lookup(domain, "NS") - for ns in ns_records[:2]: # Try first 2 NS servers - try: - proc = await asyncio.create_subprocess_exec( - "dig", f"@{ns}", domain, "AXFR", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30) - for line in stdout.decode().split('\n'): - match = re.match(rf"(\S+\.{re.escape(domain)})\.", line) - if match: - subdomains.add(match.group(1)) - except Exception: - pass - except Exception: + AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True) + with AUDIT_LOG.open("a") as f: + f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(), + "module": "openclaw", "op": op, "operator": op, + "type": scan_type, "target": target, + "authorized": authorized, "scan_id": scan_id}) + "\n") + except Exception: # pragma: no cover - audit must never break a scan pass - return sorted(list(subdomains)) - - -# Certificate transparency -async def _cert_transparency(domain: str) -> List[Dict]: - """Query certificate transparency logs.""" - certs = [] - cfg = _load_config() - - try: - async with httpx.AsyncClient(timeout=30.0) as client: - # Query crt.sh - response = await client.get( - f"https://crt.sh/?q=%.{domain}&output=json", - headers={"User-Agent": cfg.get("user_agent", DEFAULT_CONFIG["user_agent"])} - ) - if response.status_code == 200: - data = response.json() - seen = set() - for entry in data[:100]: # Limit to 100 entries - name = entry.get("name_value", "") - if name not in seen: - seen.add(name) - certs.append({ - "name": name, - "issuer": entry.get("issuer_name", ""), - "not_before": entry.get("not_before", ""), - "not_after": entry.get("not_after", ""), - "serial": entry.get("serial_number", "") - }) - except Exception: - pass - - return certs - - -# IP intelligence -async def _ip_intelligence(ip: str) -> Dict: - """Gather intelligence about an IP address.""" - info = { - "ip": ip, - "reverse_dns": [], - "geolocation": {}, - "asn": {}, - "ports": [], - "reputation": {} - } - - # Reverse DNS - try: - proc = await asyncio.create_subprocess_exec( - "dig", "+short", "-x", ip, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30) - for line in stdout.decode().strip().split('\n'): - if line: - info["reverse_dns"].append(line.strip().rstrip('.')) - except Exception: - pass - - # ASN lookup using Team Cymru - try: - reversed_ip = '.'.join(reversed(ip.split('.'))) - proc = await asyncio.create_subprocess_exec( - "dig", "+short", f"{reversed_ip}.origin.asn.cymru.com", "TXT", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30) - output = stdout.decode().strip().replace('"', '') - if output: - parts = output.split('|') - if len(parts) >= 3: - info["asn"] = { - "number": parts[0].strip(), - "prefix": parts[1].strip(), - "country": parts[2].strip() - } - except Exception: - pass - - return info - - -# Email reconnaissance -async def _email_recon(email: str) -> Dict: - """Reconnaissance on email address.""" - info = { - "email": email, - "valid_format": False, - "domain": "", - "mx_records": [], - "spf": "", - "dmarc": "", - "breaches": [] - } - - # Validate format - email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' - info["valid_format"] = bool(re.match(email_pattern, email)) - - if not info["valid_format"]: - return info - - # Extract domain - domain = email.split('@')[1] - info["domain"] = domain - - # MX records - info["mx_records"] = await _dns_lookup(domain, "MX") - - # SPF record - txt_records = await _dns_lookup(domain, "TXT") - for txt in txt_records: - if "v=spf1" in txt: - info["spf"] = txt - break - - # DMARC record - dmarc_records = await _dns_lookup(f"_dmarc.{domain}", "TXT") - for txt in dmarc_records: - if "v=DMARC1" in txt: - info["dmarc"] = txt - break - - return info - - -# Shodan integration -async def _shodan_lookup(target: str, api_key: str) -> Dict: - """Query Shodan for target.""" - if not api_key: - return {"error": "Shodan API key not configured"} - - results = {} - try: - async with httpx.AsyncClient(timeout=30.0) as client: - # Determine if IP or domain - try: - socket.inet_aton(target) - endpoint = f"https://api.shodan.io/shodan/host/{target}" - except socket.error: - endpoint = f"https://api.shodan.io/dns/resolve" - params = {"hostnames": target, "key": api_key} - response = await client.get(endpoint, params=params) - if response.status_code == 200: - data = response.json() - if target in data: - target = data[target] - endpoint = f"https://api.shodan.io/shodan/host/{target}" - else: - return {"error": "Could not resolve domain"} - - response = await client.get(endpoint, params={"key": api_key}) - if response.status_code == 200: - results = response.json() - elif response.status_code == 404: - results = {"error": "No information available"} - else: - results = {"error": f"Shodan API error: {response.status_code}"} - except Exception as e: - results = {"error": str(e)} - - return results - - -# Port scanning (basic, non-intrusive) -async def _port_check(ip: str, port: int, timeout: float = 2.0) -> bool: - """Check if a port is open.""" - try: - conn = asyncio.open_connection(ip, port) - reader, writer = await asyncio.wait_for(conn, timeout=timeout) - writer.close() - await writer.wait_closed() - return True - except Exception: - return False - - -async def _scan_common_ports(ip: str) -> List[Dict]: - """Scan common ports.""" - common_ports = { - 21: "FTP", - 22: "SSH", - 23: "Telnet", - 25: "SMTP", - 53: "DNS", - 80: "HTTP", - 110: "POP3", - 143: "IMAP", - 443: "HTTPS", - 445: "SMB", - 993: "IMAPS", - 995: "POP3S", - 3306: "MySQL", - 3389: "RDP", - 5432: "PostgreSQL", - 6379: "Redis", - 8080: "HTTP-Proxy", - 8443: "HTTPS-Alt", - 27017: "MongoDB" - } - - open_ports = [] - - async def check_port(port: int, service: str): - if await _port_check(ip, port): - return {"port": port, "service": service, "state": "open"} - return None - - tasks = [check_port(port, service) for port, service in common_ports.items()] - results = await asyncio.gather(*tasks) - - for result in results: - if result: - open_ports.append(result) - - return sorted(open_ports, key=lambda x: x["port"]) - - -# Reputation check -async def _check_reputation(target: str) -> Dict: - """Check target reputation against various sources.""" - reputation = { - "target": target, - "blacklists": [], - "score": "unknown" - } - - # Check common RBLs for IP - try: - socket.inet_aton(target) - is_ip = True - except socket.error: - is_ip = False - - if is_ip: - rbls = [ - "zen.spamhaus.org", - "bl.spamcop.net", - "dnsbl.sorbs.net", - "b.barracudacentral.org" - ] - - reversed_ip = '.'.join(reversed(target.split('.'))) - - for rbl in rbls: - try: - socket.gethostbyname(f"{reversed_ip}.{rbl}") - reputation["blacklists"].append(rbl) - except socket.gaierror: - pass - - if reputation["blacklists"]: - reputation["score"] = "bad" - else: - reputation["score"] = "clean" - - return reputation - - -# Main scan function -async def _run_scan(scan_id: str, target: str, scan_type: ScanType, options: Dict = None): - """Run the actual scan.""" - scan = _load_scan(scan_id) - if not scan: - return - - scan["status"] = ScanStatus.RUNNING.value - scan["started_at"] = datetime.utcnow().isoformat() + "Z" - _save_scan(scan) - - cfg = _load_config() - results = { - "target": target, - "type": scan_type.value, - "findings": [], - "data": {} - } - - try: - if scan_type == ScanType.DOMAIN: - # DNS enumeration - dns_results = await _dns_enumeration(target) - if dns_results: - results["data"]["dns"] = dns_results - results["findings"].append({ - "type": "dns", - "title": "DNS Records Found", - "severity": "info", - "count": sum(len(v) for v in dns_results.values()) - }) - - # Whois lookup - whois_results = await _whois_lookup(target) - if whois_results.get("parsed"): - results["data"]["whois"] = whois_results - results["findings"].append({ - "type": "whois", - "title": "WHOIS Information", - "severity": "info", - "registrar": whois_results["parsed"].get("registrar", "Unknown") - }) - - # Subdomain discovery - subdomains = await _discover_subdomains(target) - if subdomains: - results["data"]["subdomains"] = subdomains - results["findings"].append({ - "type": "subdomains", - "title": "Subdomains Discovered", - "severity": "low", - "count": len(subdomains) - }) - - # Certificate transparency - certs = await _cert_transparency(target) - if certs: - results["data"]["certificates"] = certs - results["findings"].append({ - "type": "certificates", - "title": "SSL Certificates Found", - "severity": "info", - "count": len(certs) - }) - - # Shodan (if API key configured) - if cfg.get("shodan_api_key"): - shodan_data = await _shodan_lookup(target, cfg["shodan_api_key"]) - if "error" not in shodan_data: - results["data"]["shodan"] = shodan_data - ports = shodan_data.get("ports", []) - if ports: - results["findings"].append({ - "type": "shodan", - "title": "Shodan Intelligence", - "severity": "medium" if len(ports) > 5 else "low", - "ports": ports - }) - - elif scan_type == ScanType.IP: - # IP intelligence - ip_info = await _ip_intelligence(target) - results["data"]["ip_info"] = ip_info - - # Port scan - ports = await _scan_common_ports(target) - if ports: - results["data"]["ports"] = ports - results["findings"].append({ - "type": "ports", - "title": "Open Ports Detected", - "severity": "medium" if len(ports) > 3 else "low", - "count": len(ports) - }) - - # Reputation check - reputation = await _check_reputation(target) - results["data"]["reputation"] = reputation - if reputation.get("blacklists"): - results["findings"].append({ - "type": "reputation", - "title": "Blacklist Hits", - "severity": "high", - "blacklists": reputation["blacklists"] - }) - - # Shodan (if API key configured) - if cfg.get("shodan_api_key"): - shodan_data = await _shodan_lookup(target, cfg["shodan_api_key"]) - if "error" not in shodan_data: - results["data"]["shodan"] = shodan_data - - elif scan_type == ScanType.EMAIL: - # Email reconnaissance - email_info = await _email_recon(target) - results["data"]["email_info"] = email_info - - if not email_info.get("valid_format"): - results["findings"].append({ - "type": "email", - "title": "Invalid Email Format", - "severity": "high" - }) - else: - if email_info.get("mx_records"): - results["findings"].append({ - "type": "email", - "title": "Email Domain Valid", - "severity": "info", - "mx_count": len(email_info["mx_records"]) - }) - if not email_info.get("spf"): - results["findings"].append({ - "type": "email_security", - "title": "No SPF Record", - "severity": "medium" - }) - if not email_info.get("dmarc"): - results["findings"].append({ - "type": "email_security", - "title": "No DMARC Record", - "severity": "medium" - }) - - scan["status"] = ScanStatus.COMPLETED.value - scan["results"] = results - - except Exception as e: - scan["status"] = ScanStatus.FAILED.value - scan["error"] = str(e) - - scan["completed_at"] = datetime.utcnow().isoformat() + "Z" - _save_scan(scan) - - -# ============================================================================ -# Legacy scan API Endpoints — replaced in Task 5 -# ============================================================================ -# NOTE: no POST /config here — config is operator-edited TOML/secrets (GET /config -# in the core is read-only). The old writable POST /config wrote a stale -# config.json that GET /config never read; removed to avoid a silently -# diverging endpoint. +def _start_scan(scan_type, req: ScanReq, operator: str): + _require_installed() + if not _valid_target(req.target): + raise HTTPException(400, "invalid target") + if scan_type in _ACTIVE_TYPES and not _is_local_or_owned(req.target) and not req.authorized: + raise HTTPException(409, "external active scan requires authorized=true") + scan_id = _new_id() + if scan_type in _ACTIVE_TYPES: + _audit(operator, scan_type, req.target, req.authorized, scan_id) + _spawn_worker(scan_type, req.target, scan_id) + return {"status": "started", "scan_id": scan_id, "type": scan_type, "target": req.target} @app.post("/scan/domain", dependencies=[Depends(require_jwt)]) -async def scan_domain(target: str, background_tasks: BackgroundTasks): - """Start a domain scan.""" - # Validate domain - if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$', target): - raise HTTPException(status_code=400, detail="Invalid domain format") - - scan_id = str(uuid.uuid4())[:8] - scan = { - "id": scan_id, - "target": target, - "type": ScanType.DOMAIN.value, - "status": ScanStatus.PENDING.value, - "created_at": datetime.utcnow().isoformat() + "Z", - "results": None, - "error": None - } - _save_scan(scan) - - background_tasks.add_task(_run_scan, scan_id, target, ScanType.DOMAIN) - - return {"status": "started", "scan_id": scan_id, "target": target} - +def scan_domain(req: ScanReq, claims: dict = Depends(require_jwt)): + return _start_scan("domain", req, claims.get("sub", "?")) @app.post("/scan/ip", dependencies=[Depends(require_jwt)]) -def scan_ip(target: str, background_tasks: BackgroundTasks): - """Start an IP scan.""" - # Validate IP - try: - socket.inet_aton(target) - except socket.error: - raise HTTPException(status_code=400, detail="Invalid IP address") - - scan_id = str(uuid.uuid4())[:8] - scan = { - "id": scan_id, - "target": target, - "type": ScanType.IP.value, - "status": ScanStatus.PENDING.value, - "created_at": datetime.utcnow().isoformat() + "Z", - "results": None, - "error": None - } - _save_scan(scan) - - background_tasks.add_task(_run_scan, scan_id, target, ScanType.IP) - - return {"status": "started", "scan_id": scan_id, "target": target} - +def scan_ip(req: ScanReq, claims: dict = Depends(require_jwt)): + return _start_scan("ip", req, claims.get("sub", "?")) @app.post("/scan/email", dependencies=[Depends(require_jwt)]) -async def scan_email(target: str, background_tasks: BackgroundTasks): - """Start an email scan.""" - scan_id = str(uuid.uuid4())[:8] - scan = { - "id": scan_id, - "target": target, - "type": ScanType.EMAIL.value, - "status": ScanStatus.PENDING.value, - "created_at": datetime.utcnow().isoformat() + "Z", - "results": None, - "error": None - } - _save_scan(scan) - - background_tasks.add_task(_run_scan, scan_id, target, ScanType.EMAIL) - - return {"status": "started", "scan_id": scan_id, "target": target} - +def scan_email(req: ScanReq, claims: dict = Depends(require_jwt)): + return _start_scan("email", req, claims.get("sub", "?")) @app.get("/scans", dependencies=[Depends(require_jwt)]) -async def get_scans(limit: int = Query(default=50, ge=1, le=200)): - """Get scan history.""" - scans = _list_scans(limit) - return {"scans": scans, "total": len(scans)} - +def list_scans(): + out = [] + for f in sorted(SCANS_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:200]: + try: out.append(json.loads(f.read_text())) + except Exception: pass + return {"scans": out} @app.get("/scan/{scan_id}", dependencies=[Depends(require_jwt)]) -async def get_scan(scan_id: str): - """Get scan results.""" - scan = _load_scan(scan_id) - if not scan: - raise HTTPException(status_code=404, detail="Scan not found") - return scan - +def get_scan(scan_id: str): + if not _valid_scanid(scan_id): + raise HTTPException(400, "invalid scan id") + f = SCANS_DIR / f"{scan_id}.json" + if not f.exists(): + raise HTTPException(404, "not found") + return json.loads(f.read_text()) @app.delete("/scan/{scan_id}", dependencies=[Depends(require_jwt)]) -async def delete_scan(scan_id: str): - """Delete a scan.""" - scan_file = SCANS_DIR / f"{scan_id}.json" - if not scan_file.exists(): - raise HTTPException(status_code=404, detail="Scan not found") - - scan_file.unlink() +def delete_scan(scan_id: str): + if not _valid_scanid(scan_id): + raise HTTPException(400, "invalid scan id") + f = SCANS_DIR / f"{scan_id}.json" + if f.exists(): f.unlink() return {"status": "deleted", "scan_id": scan_id} - -@app.get("/subdomains/{domain}", dependencies=[Depends(require_jwt)]) -async def get_subdomains(domain: str): - """Enumerate subdomains for a domain.""" - if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$', domain): - raise HTTPException(status_code=400, detail="Invalid domain format") - - subdomains = await _discover_subdomains(domain) - return {"domain": domain, "subdomains": subdomains, "count": len(subdomains)} - +def _sync_lookup(scan_type, target): + _require_installed() + if not _valid_target(target): + raise HTTPException(400, "invalid target") + ok, out, err = ctl(["scan", scan_type, target, "00000000"], timeout=45) + # the sync path reuses the same helper but we read the record it wrote + f = SCANS_DIR / "00000000.json" + if f.exists(): + try: return json.loads(f.read_text()) + except Exception: pass + return {"status": "failed" if not ok else "completed", "results": {"raw": out or err}} @app.get("/dns/{domain}", dependencies=[Depends(require_jwt)]) -async def get_dns(domain: str): - """Get DNS records for a domain.""" - if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$', domain): - raise HTTPException(status_code=400, detail="Invalid domain format") - - records = await _dns_enumeration(domain) - return {"domain": domain, "records": records} - +def dns_lookup(domain: str): return _sync_lookup("dns", domain) @app.get("/whois/{target}", dependencies=[Depends(require_jwt)]) -async def get_whois(target: str): - """Get WHOIS information.""" - result = await _whois_lookup(target) - return {"target": target, "whois": result} - +def whois_lookup(target: str): return _sync_lookup("whois", target) @app.get("/certs/{domain}", dependencies=[Depends(require_jwt)]) -async def get_certs(domain: str): - """Get certificate transparency logs.""" - if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$', domain): - raise HTTPException(status_code=400, detail="Invalid domain format") - - certs = await _cert_transparency(domain) - return {"domain": domain, "certificates": certs, "count": len(certs)} - +def certs_lookup(domain: str): return _sync_lookup("certs", domain) @app.get("/ports/{ip}", dependencies=[Depends(require_jwt)]) -async def get_ports(ip: str): - """Scan common ports on an IP.""" - try: - socket.inet_aton(ip) - except socket.error: - raise HTTPException(status_code=400, detail="Invalid IP address") +def ports_lookup(ip: str): return _sync_lookup("ports", ip) - cfg = _load_config() - - # First try Shodan if configured - if cfg.get("shodan_api_key"): - shodan_data = await _shodan_lookup(ip, cfg["shodan_api_key"]) - if "error" not in shodan_data: - return { - "ip": ip, - "source": "shodan", - "ports": shodan_data.get("ports", []), - "data": shodan_data.get("data", []) - } - - # Fall back to direct scan - ports = await _scan_common_ports(ip) - return {"ip": ip, "source": "direct", "ports": ports} - - -@app.get("/reputation/{target}", dependencies=[Depends(require_jwt)]) -async def get_reputation(target: str): - """Check reputation of IP or domain.""" - result = await _check_reputation(target) - return result - - -@app.get("/exports", dependencies=[Depends(require_jwt)]) -async def get_export_formats(): - """Get available export formats.""" - return { - "formats": [ - {"id": "json", "name": "JSON", "description": "Full JSON export"}, - {"id": "csv", "name": "CSV", "description": "CSV spreadsheet"}, - {"id": "xml", "name": "XML", "description": "XML format"} - ] - } - - -@app.post("/export", dependencies=[Depends(require_jwt)]) -async def export_scan(request: ExportRequest): - """Export scan results.""" - scan = _load_scan(request.scan_id) - if not scan: - raise HTTPException(status_code=404, detail="Scan not found") - - if request.format == "json": - return Response( - content=json.dumps(scan, indent=2), - media_type="application/json", - headers={"Content-Disposition": f"attachment; filename=openclaw-{request.scan_id}.json"} - ) - elif request.format == "csv": - # Simple CSV export of findings - lines = ["Type,Title,Severity,Details"] - for finding in scan.get("results", {}).get("findings", []): - details = json.dumps({k: v for k, v in finding.items() if k not in ["type", "title", "severity"]}) - lines.append(f"{finding.get('type','')},{finding.get('title','')},{finding.get('severity','')},{details}") - - return Response( - content='\n'.join(lines), - media_type="text/csv", - headers={"Content-Disposition": f"attachment; filename=openclaw-{request.scan_id}.csv"} - ) - elif request.format == "xml": - # Basic XML export - xml = [''] - xml.append(f'') - xml.append(f' {scan.get("status")}') - xml.append(' ') - for finding in scan.get("results", {}).get("findings", []): - xml.append(f' ') - xml.append(f' {finding.get("title", "")}') - xml.append(' ') - xml.append(' ') - xml.append('') - - return Response( - content='\n'.join(xml), - media_type="application/xml", - headers={"Content-Disposition": f"attachment; filename=openclaw-{request.scan_id}.xml"} - ) - else: - raise HTTPException(status_code=400, detail="Invalid export format") - - -@app.get("/integrations", dependencies=[Depends(require_jwt)]) -async def get_integrations(): - """Get integration status.""" - cfg = _load_config() - - integrations = [ - { - "id": "shodan", - "name": "Shodan", - "description": "Internet-wide port scanning and device search", - "configured": bool(cfg.get("shodan_api_key")), - "url": "https://shodan.io" - }, - { - "id": "censys", - "name": "Censys", - "description": "Internet asset discovery and monitoring", - "configured": bool(cfg.get("censys_api_id") and cfg.get("censys_api_secret")), - "url": "https://censys.io" - }, - { - "id": "virustotal", - "name": "VirusTotal", - "description": "File and URL scanning and analysis", - "configured": bool(cfg.get("virustotal_api_key")), - "url": "https://virustotal.com" - }, - { - "id": "securitytrails", - "name": "SecurityTrails", - "description": "DNS and domain intelligence", - "configured": bool(cfg.get("securitytrails_api_key")), - "url": "https://securitytrails.com" - }, - { - "id": "crtsh", - "name": "crt.sh", - "description": "Certificate transparency logs", - "configured": True, - "url": "https://crt.sh" - } - ] - - return {"integrations": integrations} +@app.post("/install", dependencies=[Depends(require_jwt)]) +def install(): + """Build the sandbox container (debootstrap + toolchain) in the background. + Detached like a scan worker — never runs on the request path.""" + if _ctl_status().get("installed"): + raise HTTPException(400, "already installed") + subprocess.Popen(["sudo", "-n", CTL, "install"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True) + _invalidate_stats() + return {"status": "installing"} diff --git a/packages/secubox-openclaw/tests/test_scans.py b/packages/secubox-openclaw/tests/test_scans.py new file mode 100644 index 00000000..3d59e12a --- /dev/null +++ b/packages/secubox-openclaw/tests/test_scans.py @@ -0,0 +1,60 @@ +import importlib, json +from fastapi.testclient import TestClient +def _load(monkeypatch, installed=True): + import api.main as m; importlib.reload(m) + from secubox_core.auth import require_jwt + m.app.dependency_overrides[require_jwt] = lambda: {"sub": "admin"} + monkeypatch.setattr(m, "_ctl_status", lambda: {"running": True, "installed": installed, "ip": "10.100.0.41", "tools": {}}) + return m + +def test_external_active_scan_refused_without_authorization(monkeypatch): + m = _load(monkeypatch) + monkeypatch.setattr(m, "_spawn_worker", lambda *a, **k: None) + c = TestClient(m.app) + r = c.post("/scan/ip", json={"target": "scanme.nmap.org"}) + assert r.status_code == 409 + +def test_external_active_scan_allowed_when_authorized(monkeypatch): + m = _load(monkeypatch); spawned = {} + monkeypatch.setattr(m, "_spawn_worker", lambda t, tgt, i: spawned.update(type=t, target=tgt, id=i)) + c = TestClient(m.app) + r = c.post("/scan/ip", json={"target": "scanme.nmap.org", "authorized": True}) + assert r.status_code == 200 and r.json()["status"] == "started" + assert spawned["type"] == "ip" + +def test_lan_active_scan_allowed_without_authorization(monkeypatch): + m = _load(monkeypatch) + monkeypatch.setattr(m, "_spawn_worker", lambda *a, **k: None) + c = TestClient(m.app) + assert c.post("/scan/ip", json={"target": "192.168.1.5"}).status_code == 200 + +def test_passive_domain_scan_always_allowed(monkeypatch): + m = _load(monkeypatch) + monkeypatch.setattr(m, "_spawn_worker", lambda *a, **k: None) + c = TestClient(m.app) + assert c.post("/scan/domain", json={"target": "scanme.nmap.org"}).status_code == 200 + +def test_bad_target_rejected_400(monkeypatch): + m = _load(monkeypatch) + monkeypatch.setattr(m, "_spawn_worker", lambda *a, **k: None) + c = TestClient(m.app) + assert c.post("/scan/domain", json={"target": "a;rm -rf /"}).status_code == 400 + +def test_scan_get_and_delete_id_validated(monkeypatch): + m = _load(monkeypatch) + c = TestClient(m.app) + assert c.get("/scan/NOTHEX99").status_code == 400 + assert c.delete("/scan/../etc").status_code in (400, 404) + +def test_install_detached_when_absent(monkeypatch): + m = _load(monkeypatch, installed=False) + called = {} + monkeypatch.setattr(m.subprocess, "Popen", lambda *a, **k: called.setdefault("argv", a[0])) + c = TestClient(m.app) + r = c.post("/install") + assert r.status_code == 200 and r.json()["status"] == "installing" + assert called["argv"][:4] == ["sudo", "-n", m.CTL, "install"] + +def test_install_refused_when_present(monkeypatch): + m = _load(monkeypatch, installed=True) + assert TestClient(m.app).post("/install").status_code == 400 From f4468484f195090e32158db2a18eab65c86d4379 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 06:30:47 +0200 Subject: [PATCH 10/14] fix(openclaw): gate+audit ports quick-lookup; per-request scan-id (no shared-file race) --- packages/secubox-openclaw/api/main.py | 41 ++++++++++++++----- packages/secubox-openclaw/tests/test_scans.py | 15 +++++++ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/packages/secubox-openclaw/api/main.py b/packages/secubox-openclaw/api/main.py index 2f16267f..ab5327a5 100644 --- a/packages/secubox-openclaw/api/main.py +++ b/packages/secubox-openclaw/api/main.py @@ -175,12 +175,14 @@ def _spawn_worker(scan_type: str, target: str, scan_id: str): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, start_new_session=True) -def _audit(op, scan_type, target, authorized, scan_id): +def _audit(operator, scan_type, target, authorized, scan_id, action="scan"): + # Append-only — never truncate. `operator` = JWT sub (WHO), `action` = WHAT. try: AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True) with AUDIT_LOG.open("a") as f: f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(), - "module": "openclaw", "op": op, "operator": op, + "module": "openclaw", "action": action, + "operator": operator, "type": scan_type, "target": target, "authorized": authorized, "scan_id": scan_id}) + "\n") except Exception: # pragma: no cover - audit must never break a scan @@ -194,7 +196,7 @@ def _start_scan(scan_type, req: ScanReq, operator: str): raise HTTPException(409, "external active scan requires authorized=true") scan_id = _new_id() if scan_type in _ACTIVE_TYPES: - _audit(operator, scan_type, req.target, req.authorized, scan_id) + _audit(operator, scan_type, req.target, req.authorized, scan_id, action="scan") _spawn_worker(scan_type, req.target, scan_id) return {"status": "started", "scan_id": scan_id, "type": scan_type, "target": req.target} @@ -239,14 +241,22 @@ def _sync_lookup(scan_type, target): _require_installed() if not _valid_target(target): raise HTTPException(400, "invalid target") - ok, out, err = ctl(["scan", scan_type, target, "00000000"], timeout=45) - # the sync path reuses the same helper but we read the record it wrote - f = SCANS_DIR / "00000000.json" - if f.exists(): - try: return json.loads(f.read_text()) - except Exception: pass - return {"status": "failed" if not ok else "completed", "results": {"raw": out or err}} + # Per-request id: two concurrent (threadpooled) lookups must not race on one + # file and read back each other's result. Transient — cleaned up, never + # persisted in /scans. + tmp_id = _new_id() + ctl(["scan", scan_type, target, tmp_id], timeout=45) + f = SCANS_DIR / f"{tmp_id}.json" + try: + if f.exists(): + data = json.loads(f.read_text()) + return data + return {"status": "failed", "results": {"raw": ""}} + finally: + try: f.unlink() + except OSError: pass +# Passive OSINT quick-lookups — unrestricted (no active probing). @app.get("/dns/{domain}", dependencies=[Depends(require_jwt)]) def dns_lookup(domain: str): return _sync_lookup("dns", domain) @@ -256,8 +266,17 @@ def whois_lookup(target: str): return _sync_lookup("whois", target) @app.get("/certs/{domain}", dependencies=[Depends(require_jwt)]) def certs_lookup(domain: str): return _sync_lookup("certs", domain) +# Active quick-lookup — `ports` is a real nmap probe, so it is policy-gated and +# audited. External targets must go through the gated POST /scan/ip (authorized=true). @app.get("/ports/{ip}", dependencies=[Depends(require_jwt)]) -def ports_lookup(ip: str): return _sync_lookup("ports", ip) +def ports_lookup(ip: str, claims: dict = Depends(require_jwt)): + _require_installed() + if not _valid_target(ip): + raise HTTPException(400, "invalid target") + if not _is_local_or_owned(ip): + raise HTTPException(409, "external active port scan requires POST /scan/ip with authorized=true") + _audit(claims.get("sub", "?"), "ports", ip, False, "quicklook", action="scan") + return _sync_lookup("ports", ip) @app.post("/install", dependencies=[Depends(require_jwt)]) def install(): diff --git a/packages/secubox-openclaw/tests/test_scans.py b/packages/secubox-openclaw/tests/test_scans.py index 3d59e12a..659f4a1f 100644 --- a/packages/secubox-openclaw/tests/test_scans.py +++ b/packages/secubox-openclaw/tests/test_scans.py @@ -58,3 +58,18 @@ def test_install_detached_when_absent(monkeypatch): def test_install_refused_when_present(monkeypatch): m = _load(monkeypatch, installed=True) assert TestClient(m.app).post("/install").status_code == 400 + +def test_ports_lookup_external_refused_409(monkeypatch): + m = _load(monkeypatch) + monkeypatch.setattr(m, "_sync_lookup", lambda *a, **k: {"status": "completed"}) + c = TestClient(m.app) + assert c.get("/ports/8.8.8.8").status_code == 409 + +def test_ports_lookup_lan_allowed(monkeypatch): + m = _load(monkeypatch); audited = {} + monkeypatch.setattr(m, "_sync_lookup", lambda t, tgt: {"status": "completed", "type": t, "target": tgt}) + monkeypatch.setattr(m, "_audit", lambda *a, **k: audited.update(args=a, kwargs=k)) + c = TestClient(m.app) + r = c.get("/ports/192.168.1.5") + assert r.status_code == 200 and r.json()["status"] == "completed" + assert audited["args"][1] == "ports" and audited["args"][2] == "192.168.1.5" From cc61eb9c424df7e37d059ff35a2e60ca272fb145 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 06:35:11 +0200 Subject: [PATCH 11/14] =?UTF-8?q?feat(openclaw):=20packaging=20=E2=80=94?= =?UTF-8?q?=20sudoers+visudo,=20install=20openclawctl+toml,=20deps=20deboo?= =?UTF-8?q?tstrap/lxc/jq?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../secubox-openclaw/conf/openclaw.toml.example | 12 ++++++++++++ packages/secubox-openclaw/debian/control | 2 +- packages/secubox-openclaw/debian/postinst | 12 +++++++++--- packages/secubox-openclaw/debian/rules | 13 +++++++++++++ .../debian/secubox-openclaw.sudoers | 3 +++ 5 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 packages/secubox-openclaw/conf/openclaw.toml.example create mode 100644 packages/secubox-openclaw/debian/secubox-openclaw.sudoers diff --git a/packages/secubox-openclaw/conf/openclaw.toml.example b/packages/secubox-openclaw/conf/openclaw.toml.example new file mode 100644 index 00000000..afd13dad --- /dev/null +++ b/packages/secubox-openclaw/conf/openclaw.toml.example @@ -0,0 +1,12 @@ +# /etc/secubox/openclaw.toml — cp from this example. +enabled = true +container_name = "openclaw" +lxc_ip = "10.100.0.41" +# Active nmap scans are auto-allowed for RFC1918/LAN + these owned suffixes; +# any other external active target needs an explicit authorized=true per scan. +owned_domains = ["gk2.secubox.in"] +# Optional OSINT integrations (leave blank to disable). Put real keys in +# /etc/secubox/secrets/ (chmod 600) if you prefer them off the TOML. +# shodan_api_key = "" +# censys_api_id = "" +# virustotal_api_key = "" diff --git a/packages/secubox-openclaw/debian/control b/packages/secubox-openclaw/debian/control index 51ff7d24..ad08e834 100644 --- a/packages/secubox-openclaw/debian/control +++ b/packages/secubox-openclaw/debian/control @@ -7,7 +7,7 @@ Standards-Version: 4.6.2 Package: secubox-openclaw Architecture: all -Depends: ${misc:Depends}, secubox-core (>= 1.0.0), dnsutils, whois, curl +Depends: ${misc:Depends}, secubox-core (>= 1.0.0), debootstrap, lxc, jq Recommends: nmap Description: SecuBox OpenClaw OSINT Module Open Source Intelligence (OSINT) tool for reconnaissance and diff --git a/packages/secubox-openclaw/debian/postinst b/packages/secubox-openclaw/debian/postinst index e2ef4935..07d8ce30 100755 --- a/packages/secubox-openclaw/debian/postinst +++ b/packages/secubox-openclaw/debian/postinst @@ -1,6 +1,13 @@ #!/bin/sh set -e if [ "$1" = "configure" ]; then + if [ -f /etc/sudoers.d/secubox-openclaw ]; then + visudo -cf /etc/sudoers.d/secubox-openclaw >/dev/null || { + echo "secubox-openclaw: invalid sudoers, removing" >&2 + rm -f /etc/sudoers.d/secubox-openclaw + } + fi + # Create data directories. The openclaw FastAPI imports a module that # touches /var/lib/secubox/openclaw/scans at import time, so the user # that loads it MUST be able to traverse the parent dir. Under @@ -14,10 +21,9 @@ if [ "$1" = "configure" ]; then chmod 0755 /var/lib/secubox/openclaw chmod 0750 /var/lib/secubox/openclaw/scans - # Enable and start service + # The aggregator serves this module in-process (imports api/main.py as + # user secubox) — do NOT enable the dedicated secubox-openclaw.service. systemctl daemon-reload - systemctl enable secubox-openclaw.service || true - systemctl start secubox-openclaw.service || true # Reload nginx if available if systemctl is-active --quiet nginx; then diff --git a/packages/secubox-openclaw/debian/rules b/packages/secubox-openclaw/debian/rules index 86540550..07eb31ea 100755 --- a/packages/secubox-openclaw/debian/rules +++ b/packages/secubox-openclaw/debian/rules @@ -13,3 +13,16 @@ override_dh_auto_install: [ -d nginx ] && cp -r nginx/. debian/secubox-openclaw/etc/nginx/secubox.d/ || true install -d debian/secubox-openclaw/lib/systemd/system [ -d systemd ] && cp systemd/*.service debian/secubox-openclaw/lib/systemd/system/ || true + + install -d debian/secubox-openclaw/usr/sbin + install -m 755 sbin/openclawctl debian/secubox-openclaw/usr/sbin/openclawctl + + # Sudoers drop-in: lets the aggregator (user secubox) drive openclawctl + # as root without a password — the ONLY privileged surface for the module. + install -d debian/secubox-openclaw/etc/sudoers.d + install -m 440 debian/secubox-openclaw.sudoers debian/secubox-openclaw/etc/sudoers.d/secubox-openclaw + + # Operator config example. Shipped to /etc/secubox/ so operators can + # `cp openclaw.toml.example openclaw.toml` without hunting for keys. + install -d debian/secubox-openclaw/etc/secubox + install -m 644 conf/openclaw.toml.example debian/secubox-openclaw/etc/secubox/openclaw.toml.example diff --git a/packages/secubox-openclaw/debian/secubox-openclaw.sudoers b/packages/secubox-openclaw/debian/secubox-openclaw.sudoers new file mode 100644 index 00000000..130765f1 --- /dev/null +++ b/packages/secubox-openclaw/debian/secubox-openclaw.sudoers @@ -0,0 +1,3 @@ +# The aggregator (user secubox) drives the OpenClaw LXC through openclawctl. +# This is the ONLY privileged surface for the module. +secubox ALL=(root) NOPASSWD: /usr/sbin/openclawctl From 2fe92754ea2fa6f0344e4b56c1867182212f19dc Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 06:41:43 +0200 Subject: [PATCH 12/14] fix(openclaw): dh_installsystemd --no-enable --no-start (aggregator serves in-process) --- packages/secubox-openclaw/debian/control | 1 - packages/secubox-openclaw/debian/rules | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/secubox-openclaw/debian/control b/packages/secubox-openclaw/debian/control index ad08e834..516c591a 100644 --- a/packages/secubox-openclaw/debian/control +++ b/packages/secubox-openclaw/debian/control @@ -8,7 +8,6 @@ Standards-Version: 4.6.2 Package: secubox-openclaw Architecture: all Depends: ${misc:Depends}, secubox-core (>= 1.0.0), debootstrap, lxc, jq -Recommends: nmap Description: SecuBox OpenClaw OSINT Module Open Source Intelligence (OSINT) tool for reconnaissance and information gathering. Features include: diff --git a/packages/secubox-openclaw/debian/rules b/packages/secubox-openclaw/debian/rules index 07eb31ea..89df8b4b 100755 --- a/packages/secubox-openclaw/debian/rules +++ b/packages/secubox-openclaw/debian/rules @@ -2,6 +2,13 @@ %: dh $@ +# The aggregator serves this module in-process (imports api/main.py as user +# secubox). The dedicated secubox-openclaw.service unit still ships (dormant, +# as an operator fallback) but must NOT be auto-enabled/started by dpkg — +# doing so double-binds openclaw.sock against the aggregator. +override_dh_installsystemd: + dh_installsystemd --no-enable --no-start + override_dh_auto_install: install -d debian/secubox-openclaw/usr/lib/secubox/openclaw cp -r api debian/secubox-openclaw/usr/lib/secubox/openclaw/ From bf22964ee16d600b8ad1202ca07d18f0a915f8ca Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 06:56:56 +0200 Subject: [PATCH 13/14] feat(openclaw): wire + XSS-harden the scanner dashboard --- .../secubox-openclaw/www/openclaw/index.html | 781 +++++++++--------- 1 file changed, 371 insertions(+), 410 deletions(-) diff --git a/packages/secubox-openclaw/www/openclaw/index.html b/packages/secubox-openclaw/www/openclaw/index.html index b2c8040b..b91aa9ec 100644 --- a/packages/secubox-openclaw/www/openclaw/index.html +++ b/packages/secubox-openclaw/www/openclaw/index.html @@ -21,15 +21,16 @@ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'Courier Prime', monospace; background: var(--tube-light); color: var(--tube-dark); display: flex; min-height: 100vh; } .main { flex: 1; margin-left: 220px; padding: 1.5rem; } - .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; } + .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap; gap: 1rem; } .header h1 { font-size: 1.5rem; color: var(--purple); } - .header-actions { display: flex; gap: 0.5rem; } + .header-actions { display: flex; gap: 0.5rem; align-items: center; } .btn { padding: 0.5rem 1rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text); cursor: pointer; font-size: 0.875rem; transition: all 0.2s; } .btn:hover { background: var(--bg-dark); } .btn.primary { background: rgba(139,92,246,0.1); border-color: var(--purple); color: var(--purple); } .btn.primary:hover { background: rgba(139,92,246,0.2); } .btn.success { background: rgba(63,185,80,0.1); border-color: var(--green); color: var(--green); } .btn.danger { background: rgba(239,68,68,0.1); border-color: #ef4444; color: #ef4444; } + .btn:disabled { opacity: 0.5; cursor: not-allowed; } .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem; } .card h2 { font-size: 1rem; margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center; color: var(--purple); } .tabs { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; flex-wrap: wrap; } @@ -51,48 +52,31 @@ .badge.running { background: rgba(139,92,246,0.2); color: var(--purple); } .badge.pending { background: rgba(210,153,34,0.2); color: var(--yellow); } .badge.failed { background: rgba(248,81,73,0.2); color: var(--red); } - .badge.info { background: rgba(139,92,246,0.2); color: var(--purple); } - .badge.low { background: rgba(63,185,80,0.2); color: var(--green); } - .badge.medium { background: rgba(210,153,34,0.2); color: #d97706; } - .badge.high { background: rgba(248,81,73,0.2); color: #ef4444; } .form-group { margin-bottom: 1rem; } .form-group label { display: block; margin-bottom: 0.5rem; color: var(--text-dim); font-size: 0.875rem; } - .form-group input, .form-group textarea, .form-group select { width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-dark); color: var(--text); } - .form-group input:focus, .form-group select:focus { outline: none; border-color: var(--purple); } - .scan-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; } + .scan-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; align-items: center; } .scan-form input { flex: 1; min-width: 200px; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-dark); color: var(--text); font-size: 1rem; } - .scan-form select { padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-dark); color: var(--text); } .scan-form button { padding: 0.75rem 1.5rem; } - .results-section { margin-top: 1rem; } - .results-section h3 { font-size: 0.9rem; color: var(--purple); margin-bottom: 0.5rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; background: rgba(139,92,246,0.1); border-radius: 4px; } - .results-section h3:hover { background: rgba(139,92,246,0.15); } - .results-section .content { padding: 0.75rem; background: var(--bg-dark); border-radius: 4px; margin-bottom: 1rem; max-height: 300px; overflow: auto; } - .results-section .content.collapsed { display: none; } - .results-section pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; } - .integration-card { display: flex; align-items: center; gap: 1rem; padding: 1rem; background: var(--bg-dark); border-radius: 6px; margin-bottom: 0.75rem; } - .integration-card .icon { font-size: 1.5rem; } - .integration-card .info { flex: 1; } - .integration-card .name { font-weight: bold; color: var(--text); } - .integration-card .desc { font-size: 0.8rem; color: var(--text-dim); } - .integration-card .status { font-size: 0.8rem; } - .integration-card .status.configured { color: var(--green); } - .integration-card .status.not-configured { color: var(--text-dim); } - .finding-card { padding: 0.75rem; background: var(--bg-dark); border-radius: 6px; margin-bottom: 0.5rem; border-left: 3px solid var(--purple); } - .finding-card.high { border-left-color: #ef4444; } - .finding-card.medium { border-left-color: #d97706; } - .finding-card.low { border-left-color: var(--green); } - .finding-card .title { font-weight: bold; display: flex; justify-content: space-between; align-items: center; } - .finding-card .details { font-size: 0.8rem; color: var(--text-dim); margin-top: 0.25rem; } .empty-state { text-align: center; padding: 3rem; color: var(--text-dim); } .empty-state .icon { font-size: 3rem; margin-bottom: 1rem; } - .modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; } - .modal.active { display: flex; } - .modal-content { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto; } - .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } - .modal-header h3 { font-size: 1.1rem; color: var(--purple); } - .close-btn { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 1.5rem; } .loader { display: inline-block; width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--purple); border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } + + /* pill / toast / helpers (matches nextcloud dashboard pattern) */ + .pill { display: inline-flex; align-items: center; gap: .4rem; padding: 4px 12px; border-radius: 12px; font-size: .8rem; border: 1px solid var(--border); } + .pill.ok { background: rgba(51,255,102,0.15); color: var(--green); border-color: var(--green); } + .pill.warn { background: rgba(255,179,71,0.15); color: var(--yellow); border-color: var(--yellow); } + .pill.error { background: rgba(255,68,102,0.15); color: var(--red); border-color: var(--red); } + .pill.muted { background: rgba(107,107,122,0.1); color: var(--text-dim); border-color: var(--border); } + .toast{position:fixed;bottom:1.2rem;right:1.2rem;padding:.7rem 1.1rem;border-radius:10px; + background:var(--bg-card);border:1px solid var(--border);color:var(--text);opacity:0; + transform:translateY(8px);transition:all .2s;z-index:50;max-width:min(90vw,360px)} + .toast.show{opacity:1;transform:none} + .toast.success{border-color:var(--green)} .toast.error{border-color:var(--red)} + .err{color:var(--red);font-size:.8rem} .muted{color:var(--text-dim);font-size:.8rem} + pre.log-out { background: var(--tube-light); border: 1px solid var(--border); border-radius: 6px; padding: .75rem; max-height: 400px; overflow: auto; white-space: pre-wrap; word-break: break-all; font-size: .78rem; margin: 0; } + .tool-row { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .5rem; } + .subhead { font-size: 0.9rem; color: var(--text-dim); margin-bottom: 0.5rem; } @@ -102,17 +86,21 @@

OpenClaw OSINT

- + Loading… + +
+
+ Sandbox IP: +
- - +
@@ -123,25 +111,12 @@
Total Scans
-
-
-
Completed
+
-
+
Tools Available
-
-
-
Integrations
-
- - -
-

Quick Scan

-
- - - +
-
+
Sandbox
@@ -153,12 +128,11 @@ Target Type Status - Findings - Date + Started Actions - + Loading… @@ -166,71 +140,66 @@
-

Domain Reconnaissance

-

- Comprehensive domain analysis including DNS records, subdomains, WHOIS, and SSL certificates. +

Launch Scan

+

+ Enter a domain, IP/CIDR, or email — the type is auto-detected. Domain and email scans are + passive OSINT. IP scans are active (nmap) — external targets require typed authorization.

- - + + domain +
-

IP Intelligence

-

- IP analysis including reverse DNS, geolocation, ASN info, port scanning, and reputation check. -

-
- - -
-
- -
-

Email Intelligence

-

- Email validation, MX records, SPF/DMARC checks, and domain security analysis. -

-
- - -
-
- -
-

Quick Lookups

+

Quick Lookups (passive)

-

DNS Lookup

+
DNS Lookup
- +
-

WHOIS Lookup

+
WHOIS Lookup
- +
-

Subdomains

-
- - -
-
-
-

SSL Certificates

+
SSL Certificates
- +
+ +
+

Port Scan (active)

+

+ Real nmap probe. LAN/owned targets run without confirmation; external IPs require typed + authorization or return 409 — use the launcher above with authorization in that case. +

+
+ + +
+
+ +
@@ -256,82 +225,24 @@ Target Type Status - Findings - Created + Started Actions - + Loading… - -
+ +
-

API Integrations

-

- Configure external API integrations to enhance OSINT capabilities. -

-
+

Module Configuration

+
Loading…
-
- - -
-

API Keys

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -
-

Scan Settings

-
- - -
-
- - -
-
- - -
- -
-
- - - @@ -341,19 +252,61 @@ const token = () => localStorage.getItem('sbx_token'); const headers = () => ({ 'Content-Type': 'application/json', ...(token() ? { 'Authorization': 'Bearer ' + token() } : {}) }); - let currentScanId = null; + // --- XSS-hardening helpers --------------------------------------- + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + function toast(msg, kind) { + let t = document.getElementById('toast'); + if (!t) { t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); } + t.textContent = msg; + t.className = 'toast ' + (kind || 'info') + ' show'; + clearTimeout(window.__toastT); + window.__toastT = setTimeout(() => t.classList.remove('show'), 3500); + } + function confirmTyped(word) { + return prompt('Type "' + word + '" to confirm this action:') === word; + } + // --- fail-safe fetch: never throws to the UI, 401 -> login -------- async function api(path, opts = {}) { try { const res = await fetch(API + path, { ...opts, headers: headers() }); - if (res.status === 401) { window.location = '/login.html'; return {}; } - if (!res.ok) return {}; - const text = await res.text(); - return text ? JSON.parse(text) : {}; - } catch { return {}; } + if (res.status === 401) { location.href = '/login.html?redirect=' + encodeURIComponent(location.pathname); return null; } + const ct = res.headers.get('content-type') || ''; + const body = ct.includes('json') ? await res.json().catch(() => ({})) : await res.text(); + if (!res.ok) throw new Error((body && body.detail) || ('HTTP ' + res.status)); + return body; + } catch (e) { + return { __error: e.message || 'request failed' }; + } } - // Tab navigation + // --- target-type auto-detect + local/owned heuristic -------------- + let ownedDomains = ['gk2.secubox.in']; + + function detectType(target) { + const t = (target || '').trim(); + if (t.includes('@')) return 'email'; + if (/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(t)) return 'ip'; + return 'domain'; + } + + function isLocalOrOwned(target) { + const t = (target || '').trim().toLowerCase(); + if (!t) return true; + const host = t.split('/')[0].split('@').pop(); + if (host === 'localhost' || host === '::1' || /^127\./.test(host)) return true; + if (/^10\.(\d{1,3}\.){2}\d{1,3}$/.test(host)) return true; + if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(host)) return true; + if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host)) return true; + if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(host)) return true; + if (/^fe80:/i.test(host)) return true; + return ownedDomains.some(d => host === d || host.endsWith('.' + d)); + } + + // --- tab navigation ------------------------------------------------- document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); @@ -363,295 +316,303 @@ }); }); + function switchTab(name) { + document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name)); + document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name)); + } + + // --- status / install ------------------------------------------------ + let installing = false; + let installPollTimer = null; + async function loadStatus() { - const d = await api('/status'); - document.getElementById('statTotal').textContent = d.total_scans || 0; - document.getElementById('statCompleted').textContent = d.completed_scans || 0; - const intCount = d.integrations ? Object.values(d.integrations).filter(v => v).length : 0; - document.getElementById('statIntegrations').textContent = intCount + '/5'; + const s = await api('/status'); + const pill = document.getElementById('status-pill'); + const installBtn = document.getElementById('btn-install'); + if (!s || s.__error) { + pill.textContent = 'error'; pill.className = 'pill error'; + document.getElementById('statTools').textContent = '-'; + document.getElementById('statSandbox').textContent = '-'; + document.getElementById('oc-ip').textContent = '—'; + installBtn.style.display = 'none'; + return; + } + document.getElementById('statTotal').textContent = s.total_scans != null ? s.total_scans : 0; + document.getElementById('oc-ip').textContent = esc(s.ip || '—'); + + const tools = s.tools || {}; + const toolKeys = Object.keys(tools); + const toolsOk = toolKeys.filter(k => tools[k]).length; + document.getElementById('statTools').textContent = toolKeys.length ? (toolsOk + '/' + toolKeys.length) : '-'; + + if (!s.installed) { + document.getElementById('statSandbox').textContent = installing ? 'installing' : 'not installed'; + pill.textContent = installing ? 'installing…' : 'not installed'; + pill.className = 'pill ' + (installing ? 'warn' : 'muted'); + installBtn.style.display = installing ? 'none' : ''; + } else if (s.running) { + document.getElementById('statSandbox').textContent = 'running'; + pill.textContent = 'running'; pill.className = 'pill ok'; + installBtn.style.display = 'none'; + if (installing) { installing = false; stopInstallPoll(); toast('OpenClaw sandbox installed', 'success'); } + } else { + document.getElementById('statSandbox').textContent = 'stopped'; + pill.textContent = 'installed (stopped)'; pill.className = 'pill warn'; + installBtn.style.display = 'none'; + } + } + + async function doInstall() { + const r = await api('/install', { method: 'POST' }); + if (!r || r.__error) { toast((r && r.__error) || 'install failed', 'error'); return; } + installing = true; + toast('Installing OpenClaw sandbox — this can take several minutes', 'success'); + loadStatus(); + startInstallPoll(); + } + function startInstallPoll() { + if (installPollTimer) return; + installPollTimer = setInterval(loadStatus, 8000); + } + function stopInstallPoll() { + if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null; } + } + + // --- scan launcher --------------------------------------------------- + async function doScan(type, target, authorized) { + const r = await api('/scan/' + type, { method: 'POST', body: JSON.stringify({ target, authorized: !!authorized }) }); + if (!r || r.__error) { + const msg = (r && r.__error) || 'scan failed'; + if (!authorized && /authorized/i.test(msg)) { + if (confirmTyped('I am authorized')) return doScan(type, target, true); + toast('Cancelled — external active scans require authorization', 'error'); + return; + } + toast(msg, 'error'); + return; + } + toast('Scan started: ' + r.scan_id, 'success'); + loadScans(); + } + + async function startScan() { + const el = document.getElementById('scanTarget'); + const target = el.value.trim(); + if (!target) { toast('Enter a target', 'error'); return; } + const type = detectType(target); + let authorized = false; + if (type === 'ip' && !isLocalOrOwned(target)) { + if (!confirmTyped('I am authorized')) { + toast('Cancelled — external IP scans require authorization', 'error'); + return; + } + authorized = true; + } + await doScan(type, target, authorized); + el.value = ''; + document.getElementById('scanTypePill').textContent = 'domain'; + } + + document.getElementById('scanTarget').addEventListener('input', (e) => { + document.getElementById('scanTypePill').textContent = detectType(e.target.value); + }); + + // --- scan history / results ------------------------------------------ + let historyPollTimer = null; + + function statusBadge(s) { + return `${esc(s || 'pending')}`; + } + function fmtDate(iso) { + if (!iso) return '-'; + const d = new Date(iso); + return isNaN(d.getTime()) ? esc(iso) : d.toLocaleString(); + } + + function renderScanRow(s, withDelete) { + return ` + ${esc(s.id)} + ${esc(s.target)} + ${esc(s.type)} + ${statusBadge(s.status)} + ${fmtDate(s.started_at)} + + + ${withDelete ? `` : ''} + + `; } async function loadScans() { - const d = await api('/scans?limit=10'); + const d = await api('/scans'); + const recentEl = document.getElementById('recentScans'); + const histEl = document.getElementById('historyTable'); + if (!d || d.__error) { + const msg = `${esc((d && d.__error) || 'error')}`; + recentEl.innerHTML = msg; histEl.innerHTML = msg; + stopHistoryPoll(); + return; + } const scans = d.scans || []; + recentEl.innerHTML = scans.slice(0, 5).map(s => renderScanRow(s, false)).join('') + || 'No scans yet'; + histEl.innerHTML = scans.map(s => renderScanRow(s, true)).join('') + || 'No scans yet'; - document.getElementById('recentScans').innerHTML = scans.map(s => ` - - ${s.target} - ${s.type} - ${s.status} - ${s.findings_count || 0} - ${s.created_at ? new Date(s.created_at).toLocaleString() : '-'} - - - - - `).join('') || 'No scans yet'; + document.querySelectorAll('.scan-act').forEach(b => b.addEventListener('click', () => { + const id = b.dataset.id, op = b.dataset.op; + if (op === 'view') viewScan(id); + else if (op === 'delete') deleteScan(id); + })); + + const active = scans.some(s => s.status === 'pending' || s.status === 'running'); + if (active) startHistoryPoll(); else stopHistoryPoll(); } - - async function loadHistory() { - const d = await api('/scans?limit=50'); - const scans = d.scans || []; - - document.getElementById('historyTable').innerHTML = scans.map(s => ` - - ${s.id} - ${s.target} - ${s.type} - ${s.status} - ${s.findings_count || 0} - ${s.created_at ? new Date(s.created_at).toLocaleString() : '-'} - - - - - - `).join('') || 'No scans yet'; + function startHistoryPoll() { + if (historyPollTimer) return; + historyPollTimer = setInterval(loadScans, 5000); } - - async function loadIntegrations() { - const d = await api('/integrations'); - const integrations = d.integrations || []; - - document.getElementById('integrationsList').innerHTML = integrations.map(i => ` -
-
${i.id === 'shodan' ? '--' : i.id === 'censys' ? '--' : i.id === 'virustotal' ? '--' : i.id === 'securitytrails' ? '--' : '--'}
-
-
${i.name}
-
${i.description}
-
-
- ${i.configured ? 'Configured' : 'Not Configured'} -
-
- `).join(''); - } - - async function loadConfig() { - const d = await api('/config'); - if (d.shodan_api_key) document.getElementById('cfgShodan').placeholder = '***configured***'; - if (d.censys_api_id) document.getElementById('cfgCensysId').placeholder = '***configured***'; - if (d.censys_api_secret) document.getElementById('cfgCensysSecret').placeholder = '***configured***'; - if (d.virustotal_api_key) document.getElementById('cfgVirusTotal').placeholder = '***configured***'; - if (d.securitytrails_api_key) document.getElementById('cfgSecurityTrails').placeholder = '***configured***'; - if (d.max_concurrent_scans) document.getElementById('cfgMaxScans').value = d.max_concurrent_scans; - if (d.scan_timeout) document.getElementById('cfgTimeout').value = d.scan_timeout; - if (d.cache_ttl) document.getElementById('cfgCacheTTL').value = d.cache_ttl; - } - - async function saveConfig() { - const config = {}; - const shodan = document.getElementById('cfgShodan').value; - const censysId = document.getElementById('cfgCensysId').value; - const censysSecret = document.getElementById('cfgCensysSecret').value; - const virustotal = document.getElementById('cfgVirusTotal').value; - const securitytrails = document.getElementById('cfgSecurityTrails').value; - - if (shodan) config.shodan_api_key = shodan; - if (censysId) config.censys_api_id = censysId; - if (censysSecret) config.censys_api_secret = censysSecret; - if (virustotal) config.virustotal_api_key = virustotal; - if (securitytrails) config.securitytrails_api_key = securitytrails; - - config.max_concurrent_scans = parseInt(document.getElementById('cfgMaxScans').value); - config.scan_timeout = parseInt(document.getElementById('cfgTimeout').value); - config.cache_ttl = parseInt(document.getElementById('cfgCacheTTL').value); - - await api('/config', { method: 'POST', body: JSON.stringify(config) }); - alert('Configuration saved'); - loadConfig(); - loadIntegrations(); - } - - async function quickScan() { - const target = document.getElementById('quickTarget').value.trim(); - const type = document.getElementById('quickType').value; - if (!target) { alert('Please enter a target'); return; } - - const d = await api(`/scan/${type}?target=${encodeURIComponent(target)}`, { method: 'POST' }); - if (d.scan_id) { - alert(`Scan started: ${d.scan_id}`); - document.getElementById('quickTarget').value = ''; - refresh(); - } - } - - async function scanDomain() { - const target = document.getElementById('domainTarget').value.trim(); - if (!target) { alert('Please enter a domain'); return; } - const d = await api(`/scan/domain?target=${encodeURIComponent(target)}`, { method: 'POST' }); - if (d.scan_id) { - alert(`Domain scan started: ${d.scan_id}`); - document.getElementById('domainTarget').value = ''; - refresh(); - } - } - - async function scanIP() { - const target = document.getElementById('ipTarget').value.trim(); - if (!target) { alert('Please enter an IP address'); return; } - const d = await api(`/scan/ip?target=${encodeURIComponent(target)}`, { method: 'POST' }); - if (d.scan_id) { - alert(`IP scan started: ${d.scan_id}`); - document.getElementById('ipTarget').value = ''; - refresh(); - } - } - - async function scanEmail() { - const target = document.getElementById('emailTarget').value.trim(); - if (!target) { alert('Please enter an email address'); return; } - const d = await api(`/scan/email?target=${encodeURIComponent(target)}`, { method: 'POST' }); - if (d.scan_id) { - alert(`Email scan started: ${d.scan_id}`); - document.getElementById('emailTarget').value = ''; - refresh(); - } + function stopHistoryPoll() { + if (historyPollTimer) { clearInterval(historyPollTimer); historyPollTimer = null; } } async function viewScan(scanId) { - currentScanId = scanId; - const d = await api('/scan/' + scanId); - + const d = await api('/scan/' + encodeURIComponent(scanId)); document.getElementById('noResults').style.display = 'none'; const content = document.getElementById('resultsContent'); content.style.display = 'block'; - let html = ` -
-
Target: ${d.target}
-
Type: ${d.type}
-
Status: ${d.status}
-
Created: ${d.created_at ? new Date(d.created_at).toLocaleString() : '-'}
-
- `; - - if (d.results) { - // Findings - if (d.results.findings && d.results.findings.length) { - html += '

Findings

'; - d.results.findings.forEach(f => { - html += ` -
-
- ${f.title} - ${f.severity} -
-
${JSON.stringify({...f, title: undefined, severity: undefined, type: undefined})}
-
- `; - }); - } - - // Data sections - if (d.results.data) { - Object.keys(d.results.data).forEach(key => { - html += ` -
-

${key.toUpperCase()} -

-
-
${JSON.stringify(d.results.data[key], null, 2)}
-
-
- `; - }); - } + if (!d || d.__error) { + content.innerHTML = `

${esc((d && d.__error) || 'failed to load scan')}

`; + switchTab('results'); + return; } - html += ` -
- - + const raw = (d.results && d.results.raw) || ''; + content.innerHTML = ` +
+
ID: ${esc(d.id)}
+
Target: ${esc(d.target)}
+
Type: ${esc(d.type)}
+
Status: ${statusBadge(d.status)}
+
Started: ${fmtDate(d.started_at)}
+
Finished: ${fmtDate(d.finished_at)}
+ ${d.error ? `
Error: ${esc(d.error)}
` : ''}
+

Raw Output

+
${raw ? esc(raw) : '(no output)'}
`; - - content.innerHTML = html; - - // Switch to results tab - document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - document.querySelector('[data-tab="results"]').classList.add('active'); - document.getElementById('tab-results').classList.add('active'); - } - - function toggleSection(elem) { - const content = elem.nextElementSibling; - const icon = elem.querySelector('span'); - content.classList.toggle('collapsed'); - icon.textContent = content.classList.contains('collapsed') ? '+' : '-'; + switchTab('results'); } async function deleteScan(scanId) { - if (!confirm('Delete this scan?')) return; - await api('/scan/' + scanId, { method: 'DELETE' }); - refresh(); + if (!confirmTyped(scanId)) return; + const r = await api('/scan/' + encodeURIComponent(scanId), { method: 'DELETE' }); + toast(r && !r.__error ? 'Scan deleted' : ((r && r.__error) || 'delete failed'), r && !r.__error ? 'success' : 'error'); + loadScans(); } - async function exportScan(scanId, format) { - const d = await api('/export', { - method: 'POST', - body: JSON.stringify({ scan_id: scanId, format: format }) - }); - // The response is actually handled differently for downloads - window.open(API + `/export?scan_id=${scanId}&format=${format}`, '_blank'); + // --- quick lookups ----------------------------------------------- + function showLookupModal(title) { + document.getElementById('lookupTitle').textContent = title; + document.getElementById('lookupContent').innerHTML = '
Loading...'; + document.getElementById('lookupModal').style.display = 'flex'; + } + function closeLookupModal() { + document.getElementById('lookupModal').style.display = 'none'; + } + function renderLookupResult(d, externalActiveHint) { + const content = document.getElementById('lookupContent'); + if (!d || d.__error) { + const msg = (d && d.__error) || 'lookup failed'; + if (externalActiveHint && /authorized/i.test(msg)) { + content.innerHTML = `

External target — use the Scan launcher (Scan tab) with typed authorization to port-scan this target.

`; + } else { + content.innerHTML = `

${esc(msg)}

`; + } + return; + } + const raw = (d.results && d.results.raw) || ''; + content.innerHTML = `
status: ${esc(d.status || '-')}
+
${raw ? esc(raw) : '(no output)'}
`; } - // Quick lookups async function dnsLookup() { const target = document.getElementById('dnsTarget').value.trim(); if (!target) return; showLookupModal('DNS Lookup: ' + target); - const d = await api('/dns/' + encodeURIComponent(target)); - document.getElementById('lookupContent').innerHTML = `
${JSON.stringify(d, null, 2)}
`; + renderLookupResult(await api('/dns/' + encodeURIComponent(target)), false); } - async function whoisLookup() { const target = document.getElementById('whoisTarget').value.trim(); if (!target) return; showLookupModal('WHOIS Lookup: ' + target); - const d = await api('/whois/' + encodeURIComponent(target)); - document.getElementById('lookupContent').innerHTML = `
${JSON.stringify(d, null, 2)}
`; + renderLookupResult(await api('/whois/' + encodeURIComponent(target)), false); } - - async function subdomainLookup() { - const target = document.getElementById('subdomainTarget').value.trim(); - if (!target) return; - showLookupModal('Subdomains: ' + target); - const d = await api('/subdomains/' + encodeURIComponent(target)); - document.getElementById('lookupContent').innerHTML = `
${JSON.stringify(d, null, 2)}
`; - } - async function certLookup() { const target = document.getElementById('certTarget').value.trim(); if (!target) return; showLookupModal('SSL Certificates: ' + target); - const d = await api('/certs/' + encodeURIComponent(target)); - document.getElementById('lookupContent').innerHTML = `
${JSON.stringify(d, null, 2)}
`; + renderLookupResult(await api('/certs/' + encodeURIComponent(target)), false); + } + async function portsLookup() { + const target = document.getElementById('portsTarget').value.trim(); + if (!target) return; + if (!isLocalOrOwned(target)) { + toast('External IP — use the Scan launcher with authorization instead', 'error'); + return; + } + showLookupModal('Port Scan: ' + target); + renderLookupResult(await api('/ports/' + encodeURIComponent(target)), true); } - function showLookupModal(title) { - document.getElementById('lookupTitle').textContent = title; - document.getElementById('lookupContent').innerHTML = '
Loading...'; - document.getElementById('lookupModal').classList.add('active'); + // --- config (read-only — no POST /config in this API) ---------------- + async function loadConfig() { + const el = document.getElementById('configBody'); + const d = await api('/config'); + if (!d || d.__error) { el.innerHTML = `${esc((d && d.__error) || 'error')}`; return; } + const integ = d.integrations || {}; + const owned = d.owned_domains || []; + if (owned.length) ownedDomains = owned.map(x => String(x).toLowerCase()); + el.innerHTML = ` +
Enabled: ${d.enabled ? 'yes' : 'no'}
+
Owned domains: ${owned.length ? owned.map(o => `${esc(o)}`).join(', ') : 'none'}
+
Integrations configured: ${Object.keys(integ).length + ? Object.keys(integ).map(k => `${esc(k)}: ${integ[k] ? 'yes' : 'no'}`).join(' ') + : 'none'}
+ `; } - function closeLookupModal() { - document.getElementById('lookupModal').classList.remove('active'); + async function loadTools() { + const el = document.getElementById('toolsBody'); + const s = await api('/status'); + if (!s || s.__error) { el.innerHTML = `${esc((s && s.__error) || 'error')}`; return; } + const tools = s.tools || {}; + const keys = Object.keys(tools); + el.innerHTML = keys.length + ? keys.map(k => `${esc(k)}: ${tools[k] ? 'available' : 'missing'}`).join('') + : 'Sandbox not running'; } + // --- wiring -------------------------------------------------------- + document.getElementById('btn-refresh').addEventListener('click', refresh); + document.getElementById('btn-install').addEventListener('click', doInstall); + document.getElementById('btn-start-scan').addEventListener('click', startScan); + document.getElementById('btn-dns').addEventListener('click', dnsLookup); + document.getElementById('btn-whois').addEventListener('click', whoisLookup); + document.getElementById('btn-certs').addEventListener('click', certLookup); + document.getElementById('btn-ports').addEventListener('click', portsLookup); + document.getElementById('btn-close-lookup').addEventListener('click', closeLookupModal); + function refresh() { loadStatus(); loadScans(); - loadHistory(); - loadIntegrations(); + loadConfig(); + loadTools(); } // Initial load - loadStatus(); - loadScans(); - loadHistory(); - loadIntegrations(); - loadConfig(); + refresh(); From db9841071a07384cdbbfa588628c5c41dca5a163 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 9 Jul 2026 07:06:56 +0200 Subject: [PATCH 14/14] docs(openclaw): mark module implemented (LXC scanner live on gk2) Task 8 verified the full 1.0.1 deb builds/installs cleanly on gk2 over the already-provisioned openclaw LXC, the aggregator sudoers grant works, and a real scan does not block the shared aggregator loop (concurrent cookies/status returned in 6ms during an active scan). Dashboard renders wired markup at /openclaw/. Board recovered fully after the single required aggregator restart. --- .claude/HISTORY.md | 17 ++++ .superpowers/sdd/task-8-report.md | 156 ++++++++++++++++++++++++------ 2 files changed, 145 insertions(+), 28 deletions(-) diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index d75e2382..a9f97e39 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -3,6 +3,23 @@ --- +## 2026-07-09 — OpenClaw OSINT scanner: LXC module live end-to-end on gk2 (branch `feature/openclaw-lxc-scanner`) + +8-task subagent-driven build (async-job scan endpoints dropping the old sync-shell-out machinery, +target-policy/audit gate, packaging with sudoers+visudo, aggregator-in-process wiring, XSS-hardened +dashboard) — Task 8 deployed + verified. `secubox-openclaw` 1.0.1-1~bookworm1 built and installed on +gk2 over the existing 1.0.0 (LXC container `openclaw`/10.100.0.41 with nmap/dig/whois/curl already +provisioned in earlier tasks reused as-is). `sudo -u secubox sudo -n openclawctl status --json` proves +the sudoers grant; `secubox-aggregator` restarted once to load the new in-process `api/main.py`. +**SPOF proof**: kicked a real `openclawctl scan ip 127.0.0.1` in the background — a concurrent +`/api/v1/cookies/status` call through the aggregator socket returned in **6ms**, and the scan itself +reached `status: completed`, confirming the async-job design does not block the shared aggregator +loop the way the old sync-shell-out path would have. Dashboard (`/openclaw/`) verified 200 with wired +markup via the generic static-root fallback (no per-module nginx alias needed — same pattern as +`/cookies/`). Board confirmed healthy after the single restart (all sampled vhosts/APIs 200/401, no 502). + +--- + ## 2026-07-06/07 — Sentinel threat engine + activation + 3 surfaces + C2 auto-learning (#821 #823–#828) Brainstorm → spec → plan → subagent-driven (per-task two-stage review + adversarial whole-branch review) throughout. All merged; all deployed + verified live on gk2. diff --git a/.superpowers/sdd/task-8-report.md b/.superpowers/sdd/task-8-report.md index 896970aa..713b740d 100644 --- a/.superpowers/sdd/task-8-report.md +++ b/.superpowers/sdd/task-8-report.md @@ -1,41 +1,141 @@ -# Task 8a Report — p2p UI + 1.9.0 changelog +# Task 8 report — OpenClaw LXC scanner: live end-to-end deployment + verification (gk2) -## Files Changed +Branch: `feature/openclaw-lxc-scanner` (base commit `bf22964e`). +Board: `root@192.168.1.200` (gk2). -| File | Change | -|------|--------| -| `packages/secubox-p2p/api/registry.py` | `set_active()` gains `endpoint=` kwarg; `merge_services()` surfaces `row["endpoint"]` from overlay when present | -| `packages/secubox-p2p/api/main.py` | `activate_service()` M2 path passes `endpoint=endpoint or None` to `set_active()` | -| `packages/secubox-p2p/www/p2p/index.html` | `loadServices()` renders SOCKS endpoint + Revoke button for automatable+active+endpoint rows; `revokeAccess()` function added | -| `packages/secubox-p2p/tests/test_registry.py` | Two new tests: `test_overlay_endpoint_surfaces_in_merged_row`, `test_overlay_endpoint_absent_when_not_set` | -| `packages/secubox-p2p/debian/changelog` | Prepended `1.9.0-1~bookworm1` entry | - -## node --check Output +## Step 1 — Build ``` -node --check: PASSED +cd packages/secubox-openclaw && dpkg-buildpackage -us -uc -b ``` -No syntax errors in the extracted `