Merge feature/openclaw-lxc-scanner: dedicated-LXC OSINT+nmap scanner (live on gk2)
Some checks are pending
License Headers / check (push) Waiting to run

8-task SDD build + opus final review (READY TO MERGE). LXC-sandboxed scan
toolchain (nmap/dig/whois/curl @ 10.100.0.41), aggregator-safe async-job API
(all def handlers, detached workers — SPOF-proof 6ms), target policy + audit,
XSS-hardened dashboard. Security fixes: leading-dash flag-injection x2,
ports policy/audit bypass, scan-id race, dh_installsystemd auto-enable.
This commit is contained in:
CyberMind-FR 2026-07-09 07:19:56 +02:00
commit 75722e2a59
18 changed files with 1077 additions and 1424 deletions

View File

@ -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.

View File

@ -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 `<script>` block.
## pytest Output
Result: **success**. Only warnings were pre-existing `debian/changelog` locale-formatted date
parse warnings (French month names in the last entry's trailer line — cosmetic, non-blocking,
not touched per "no code changes unless deploy-blocking").
```
49 passed, 1 warning in 0.87s
secubox-openclaw_1.0.1-1~bookworm1_all.deb 17804 bytes
```
All 49 tests pass (47 pre-existing + 2 new registry tests).
`dpkg-deb -I` confirms real control metadata (Depends: secubox-core, debootstrap, lxc, jq;
Installed-Size: 90). `dpkg-deb -c` confirms real payload: `api/main.py` (11829 bytes),
`sbin/openclawctl` (7679 bytes, +x), sudoers.d drop-in (0440), nginx/secubox.d conf, systemd unit,
menu.d entry, `usr/share/secubox/www/openclaw/` static assets.
## Self-Review
## Step 2 — Install
### What was done
1. **registry.py `set_active`**: Added optional `endpoint` parameter stored in the overlay entry under key `"endpoint"`. Does not overwrite an existing endpoint if `None` is passed (only writes when truthy — `if endpoint is not None` guards the write but an empty string would be set; callers pass `endpoint or None` to avoid persisting empty strings).
2. **registry.py `merge_services`**: Checks `ov.get("endpoint")` and includes it in the row only when present. Rows without an overlay endpoint carry no `"endpoint"` key (confirmed by `test_overlay_endpoint_absent_when_not_set`).
3. **main.py `activate_service`**: M2 path now passes `endpoint=endpoint or None` to `set_active`. The `endpoint` variable is already computed at that point from `cred.get("endpoint", offer.get("endpoint", ""))`.
4. **index.html `loadServices`**: Added a new branch in the action chain — fires when `svc.automatable && svc.active && svc.endpoint`. Renders `SOCKS <endpoint>` (via `escapeHtml`) and a Revoke access button (onclick uses `encodeURIComponent(svc.service_id)` matching M1 pattern — NOT `escapeHtml`).
5. **index.html `revokeAccess`**: Defined immediately after `activateService`. Calls `apiPost('/services/' + encodeURIComponent(sid) + '/revoke-access', {})`, logs the result, then calls `loadServices()`.
6. **changelog 1.9.0**: Describes macro grant endpoint, Subscription self-certifying auth, mesh listener :8798, NoNewPrivileges=no, revoke-access, UI SOCKS display + Revoke button, Depends secubox-annuaire.
```
scp packages/secubox-openclaw_1.0.1-1~bookworm1_all.deb root@192.168.1.200:/root/
ssh root@192.168.1.200 'dpkg -i --force-confdef --force-confold /root/secubox-openclaw_1.0.1-1~bookworm1_all.deb'
```
### Concerns / Edge Cases
- The `endpoint` field stored in the overlay is whatever the grant credential returns (e.g. `"10.10.0.1:9050"`). The UI prefixes it with `"SOCKS "` unconditionally. If a future macro kind stores a non-SOCKS endpoint (e.g. a DNS resolver), the label will still say "SOCKS". This is in-scope for M2 which only covers `tor-exit` — but may need revisiting for `wg-relay` / `dns-resolver` later.
- `main.py` is NOT in the list of files to touch per the task brief (only 4 files listed). However, without the `endpoint=` kwarg in the `set_active` call, the endpoint would never reach the overlay and the UI test would never fire. The change to `main.py` is a 1-line delta and is logically required. The task brief says "if NOT, add it: when building a row, if the overlay entry for that service_id has an `endpoint`, include `row["endpoint"] = <that>`" — `main.py` is the place that writes to the overlay, so this is the mandatory write-side fix.
Result: **rc=0**. Upgraded cleanly from the previously-installed 1.0.0-1~bookworm1. One conffile
prompt (`/etc/nginx/secubox.d/openclaw.conf`) auto-resolved by `--force-confold` — kept the
board's existing (already-correct, aggregator-routed) version and stashed the new maintainer
version as `.dpkg-dist`. See "Note" below — this is expected/correct, not a bug.
**Sudoers-as-secubox check:**
```
$ ssh root@192.168.1.200 'sudo -u secubox sudo -n /usr/sbin/openclawctl status --json'
{"running":true,"installed":true,"ip":"10.100.0.41","tools":{"nmap":true,"dig":true,"whois":true,"curl":true}}
```
JSON, not a sudo error → the sudoers grant works.
## Step 3 — Aggregator restart (once)
```
ssh root@192.168.1.200 'systemctl restart secubox-aggregator'
```
Polled `/api/v1/openclaw/status` on the aggregator socket every 2s; **ready after 15s** (well
inside the ~60s budget). No second restart performed.
```json
{"module":"openclaw","enabled":true,"running":true,"installed":true,"ip":"10.100.0.41",
"tools":{"nmap":true,"dig":true,"whois":true,"curl":true},"total_scans":0}
```
## Step 4 — Status verification
```
$ curl -s --unix-socket /run/secubox/aggregator.sock http://localhost/api/v1/openclaw/status | jq '{installed,running,tools,total_scans}'
{
"installed": true,
"running": true,
"tools": { "nmap": true, "dig": true, "whois": true, "curl": true },
"total_scans": 0
}
```
## Step 5 — Concurrency / SPOF proof
Kicked a real scan directly via the helper, and while it ran hit two other modules' status
endpoints through the shared aggregator socket:
```
$ openclawctl scan ip 127.0.0.1 dddd4444 & # background
$ sleep 1
$ time curl -s -o /dev/null -w "HTTP=%{http_code} TIME=%{time_total}s\n" --unix-socket $S http://localhost/api/v1/cookies/status
HTTP=200 TIME=0.005979s (real 0m0.035s)
$ time curl -s -o /dev/null -w "HTTP=%{http_code} TIME=%{time_total}s\n" --unix-socket $S http://localhost/api/v1/dpi/status
HTTP=401 TIME=0.194691s (real 0m0.223s — auth-gated 401, still sub-second, not a stall)
$ wait; jq .status /var/lib/secubox/openclaw/scans/dddd4444.json
"completed"
```
**cookies/status returned in 6ms while the scan was actively running** — proves the async-job
scan pipeline does not block the shared aggregator event loop (the SPOF this design eliminates).
The scan itself reached `status: completed`. Test scan file `dddd4444.json` removed after
verification (no leftover artifact).
## Step 6 — Dashboard
```
$ curl -s -o /dev/null -w "%{http_code}\n" -k https://admin.gk2.secubox.in/openclaw/
200
$ curl -s -k https://admin.gk2.secubox.in/openclaw/ | grep -c "esc\|data-op"
24
```
200 + wired markup present (24 matches for `esc`/`data-op`), confirming `bf22964e`'s XSS-hardened
dashboard shipped and renders from the board's static file root.
**Note on routing**: `secubox-openclaw`'s own `nginx/openclaw.conf` template only ships the
`/api/v1/openclaw/` proxy location (to `openclaw.sock`, a dormant-fallback socket — the board
kept its already-correct aggregator-routed version via `--force-confold`, matching commit
`2fe92754`'s in-process design). There is **no dedicated static alias** for `/openclaw/` in
the package (unlike `secubox-cookies`, which ships one explicitly) — it doesn't need one: the
admin vhost's generic `location / { try_files $uri $uri/ @fallback; }` with
`root /usr/share/secubox/www;` serves `usr/share/secubox/www/openclaw/index.html` automatically,
same mechanism that already serves `/cookies/`. Verified working, not a blocker — flagging only
because it's a source-vs-board conf drift worth being aware of (harmless; `.dpkg-dist` left on
board for reference, not touched).
## Step 7 — Board recovery after the single restart
```
/cookies/ -> 200 /waf/ -> 200 /soc/ -> 200 /system/ -> 200
api/cookies/status -> 200 api/waf/status -> 200 api/soc/status -> 200
api/system/status -> 401 (auth-gated, expected) api/dpi/status -> 401 (auth-gated, expected)
```
No 502s anywhere. `secubox-aggregator` and `nginx` both `active`. Aggregator journal (last 5 min)
shows zero errors/tracebacks/exceptions. RSS 162M, single worker, CPU nominal — board fully
recovered from the one restart.
## Step 8 — Tracking
- `.claude/MIGRATION-MAP.md`: `secubox-openclaw` row was **already** all-✅ (www/API/deb/status) —
no change needed.
- `.claude/HISTORY.md`: added a dated entry for the Task 8 live deployment + SPOF proof.
- Committed (message: `docs(openclaw): mark module implemented (LXC scanner live on gk2)`).
## Verdict
**DONE.** No code changes were required — deploy-clean, sudoers works, aggregator restarted once
and recovered, concurrency proof confirms no SPOF regression, dashboard renders wired markup,
board fully healthy afterward.

View File

@ -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"])]

View File

@ -0,0 +1 @@
# SecuBox OpenClaw API

File diff suppressed because it is too large Load Diff

View File

@ -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 = ""

View File

@ -7,8 +7,7 @@ Standards-Version: 4.6.2
Package: secubox-openclaw
Architecture: all
Depends: ${misc:Depends}, secubox-core (>= 1.0.0), dnsutils, whois, curl
Recommends: nmap
Depends: ${misc:Depends}, secubox-core (>= 1.0.0), debootstrap, lxc, jq
Description: SecuBox OpenClaw OSINT Module
Open Source Intelligence (OSINT) tool for reconnaissance and
information gathering. Features include:

View File

@ -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

View File

@ -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/
@ -13,3 +20,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

View File

@ -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

View File

@ -0,0 +1,146 @@
#!/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) ----
# 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; }
# ---- lxc helpers (mirror nextcloudctl) ----
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
# 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'; }
_write_lxc_config() {
cat > "$LXC_PATH/$CONTAINER/config" <<EOF
lxc.include = /usr/share/lxc/config/debian.common.conf
lxc.arch = linux64
lxc.uts.name = $CONTAINER
lxc.rootfs.path = dir:$LXC_PATH/$CONTAINER/rootfs
lxc.net.0.type = veth
lxc.net.0.link = $LXC_BRIDGE
lxc.net.0.flags = up
lxc.net.0.name = eth0
lxc.net.0.ipv4.address = $LXC_IP/24
lxc.net.0.ipv4.gateway = $LXC_GW
lxc.start.auto = 1
lxc.start.delay = 5
lxc.cgroup2.memory.max = 512M
EOF
}
cmd_install() {
[ "$(id -u)" -eq 0 ] || { err "root required"; return 1; }
mkdir -p "$LXC_PATH/$CONTAINER" "$SCANS_DIR"
if [ ! -x "$LXC_PATH/$CONTAINER/rootfs/bin/bash" ]; then
echo "[openclaw] debootstrapping bookworm rootfs…"
debootstrap --variant=minbase \
--include=systemd,systemd-sysv,dbus,ca-certificates,iproute2 \
bookworm "$LXC_PATH/$CONTAINER/rootfs" http://deb.debian.org/debian || { err "debootstrap failed"; return 1; }
fi
echo "$CONTAINER" > "$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; }
}
# 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 || { 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
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/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 ;;
# 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"
_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 <type> <target> <id>|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

View File

@ -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"],
},
}

View File

@ -0,0 +1,18 @@
#!/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')"
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

View File

@ -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

View File

@ -0,0 +1,75 @@
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
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"

View File

@ -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

View File

@ -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; }
</style>
</head>
<body>
@ -102,17 +86,21 @@
<header class="header">
<h1>OpenClaw OSINT</h1>
<div class="header-actions">
<button class="btn" onclick="refresh()">Refresh</button>
<span class="pill muted" id="status-pill">Loading…</span>
<button class="btn primary" id="btn-install" style="display:none">Install</button>
<button class="btn" id="btn-refresh">Refresh</button>
</div>
</header>
<div class="muted" style="margin:0 0 1.25rem">
Sandbox IP: <span id="oc-ip"></span>
</div>
<div class="tabs">
<button class="tab active" data-tab="dashboard">Dashboard</button>
<button class="tab" data-tab="scan">Scan</button>
<button class="tab" data-tab="results">Results</button>
<button class="tab" data-tab="history">History</button>
<button class="tab" data-tab="integrations">Integrations</button>
<button class="tab" data-tab="settings">Settings</button>
<button class="tab" data-tab="config">Config</button>
</div>
<!-- Dashboard Tab -->
@ -123,25 +111,12 @@
<div class="label">Total Scans</div>
</div>
<div class="stat-card">
<div class="value" id="statCompleted">-</div>
<div class="label">Completed</div>
<div class="value" id="statTools">-</div>
<div class="label">Tools Available</div>
</div>
<div class="stat-card">
<div class="value" id="statIntegrations">-</div>
<div class="label">Integrations</div>
</div>
</div>
<div class="card">
<h2>Quick Scan</h2>
<div class="scan-form">
<input type="text" id="quickTarget" placeholder="Enter domain, IP, or email...">
<select id="quickType">
<option value="domain">Domain</option>
<option value="ip">IP Address</option>
<option value="email">Email</option>
</select>
<button class="btn primary" onclick="quickScan()">Scan</button>
<div class="value" id="statSandbox">-</div>
<div class="label">Sandbox</div>
</div>
</div>
@ -153,12 +128,11 @@
<th>Target</th>
<th>Type</th>
<th>Status</th>
<th>Findings</th>
<th>Date</th>
<th>Started</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="recentScans"></tbody>
<tbody id="recentScans"><tr><td colspan="5" class="muted">Loading…</td></tr></tbody>
</table>
</div>
</div>
@ -166,71 +140,66 @@
<!-- Scan Tab -->
<div class="tab-content" id="tab-scan">
<div class="card">
<h2>Domain Reconnaissance</h2>
<p style="color: var(--text-dim); margin-bottom: 1rem; font-size: 0.9rem;">
Comprehensive domain analysis including DNS records, subdomains, WHOIS, and SSL certificates.
<h2>Launch Scan</h2>
<p class="subhead">
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.
</p>
<div class="scan-form">
<input type="text" id="domainTarget" placeholder="example.com">
<button class="btn primary" onclick="scanDomain()">Scan Domain</button>
<input type="text" id="scanTarget" placeholder="example.com / 192.168.1.10 / user@example.com">
<span class="pill muted" id="scanTypePill">domain</span>
<button class="btn primary" id="btn-start-scan">Scan</button>
</div>
</div>
<div class="card">
<h2>IP Intelligence</h2>
<p style="color: var(--text-dim); margin-bottom: 1rem; font-size: 0.9rem;">
IP analysis including reverse DNS, geolocation, ASN info, port scanning, and reputation check.
</p>
<div class="scan-form">
<input type="text" id="ipTarget" placeholder="192.168.1.1">
<button class="btn primary" onclick="scanIP()">Scan IP</button>
</div>
</div>
<div class="card">
<h2>Email Intelligence</h2>
<p style="color: var(--text-dim); margin-bottom: 1rem; font-size: 0.9rem;">
Email validation, MX records, SPF/DMARC checks, and domain security analysis.
</p>
<div class="scan-form">
<input type="text" id="emailTarget" placeholder="user@example.com">
<button class="btn primary" onclick="scanEmail()">Scan Email</button>
</div>
</div>
<div class="card">
<h2>Quick Lookups</h2>
<h2>Quick Lookups (passive)</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div>
<h3 style="font-size: 0.9rem; color: var(--text-dim); margin-bottom: 0.5rem;">DNS Lookup</h3>
<div class="subhead">DNS Lookup</div>
<div class="scan-form">
<input type="text" id="dnsTarget" placeholder="example.com" style="min-width: 100px;">
<button class="btn" onclick="dnsLookup()">Lookup</button>
<button class="btn" id="btn-dns">Lookup</button>
</div>
</div>
<div>
<h3 style="font-size: 0.9rem; color: var(--text-dim); margin-bottom: 0.5rem;">WHOIS Lookup</h3>
<div class="subhead">WHOIS Lookup</div>
<div class="scan-form">
<input type="text" id="whoisTarget" placeholder="example.com" style="min-width: 100px;">
<button class="btn" onclick="whoisLookup()">Lookup</button>
<button class="btn" id="btn-whois">Lookup</button>
</div>
</div>
<div>
<h3 style="font-size: 0.9rem; color: var(--text-dim); margin-bottom: 0.5rem;">Subdomains</h3>
<div class="scan-form">
<input type="text" id="subdomainTarget" placeholder="example.com" style="min-width: 100px;">
<button class="btn" onclick="subdomainLookup()">Discover</button>
</div>
</div>
<div>
<h3 style="font-size: 0.9rem; color: var(--text-dim); margin-bottom: 0.5rem;">SSL Certificates</h3>
<div class="subhead">SSL Certificates</div>
<div class="scan-form">
<input type="text" id="certTarget" placeholder="example.com" style="min-width: 100px;">
<button class="btn" onclick="certLookup()">Search</button>
<button class="btn" id="btn-certs">Search</button>
</div>
</div>
</div>
</div>
<div class="card">
<h2>Port Scan (active)</h2>
<p class="subhead">
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.
</p>
<div class="scan-form">
<input type="text" id="portsTarget" placeholder="192.168.1.10" style="min-width: 100px;">
<button class="btn" id="btn-ports">Scan Ports</button>
</div>
</div>
<div class="modal" id="lookupModal" style="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;">
<div style="background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; width: 90%; max-width: 700px; max-height: 80vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 id="lookupTitle" style="color: var(--purple);">Lookup Results</h3>
<button class="btn" id="btn-close-lookup">Close</button>
</div>
<div id="lookupContent"><div class="loader"></div> Loading...</div>
</div>
</div>
</div>
<!-- Results Tab -->
@ -256,82 +225,24 @@
<th>Target</th>
<th>Type</th>
<th>Status</th>
<th>Findings</th>
<th>Created</th>
<th>Started</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="historyTable"></tbody>
<tbody id="historyTable"><tr><td colspan="6" class="muted">Loading…</td></tr></tbody>
</table>
</div>
</div>
<!-- Integrations Tab -->
<div class="tab-content" id="tab-integrations">
<!-- Config Tab (read-only — no POST /config endpoint) -->
<div class="tab-content" id="tab-config">
<div class="card">
<h2>API Integrations</h2>
<p style="color: var(--text-dim); margin-bottom: 1rem; font-size: 0.9rem;">
Configure external API integrations to enhance OSINT capabilities.
</p>
<div id="integrationsList"></div>
<h2>Module Configuration</h2>
<div id="configBody" class="muted">Loading…</div>
</div>
</div>
<!-- Settings Tab -->
<div class="tab-content" id="tab-settings">
<div class="card">
<h2>API Keys</h2>
<div class="form-group">
<label>Shodan API Key</label>
<input type="password" id="cfgShodan" placeholder="Enter Shodan API key...">
</div>
<div class="form-group">
<label>Censys API ID</label>
<input type="password" id="cfgCensysId" placeholder="Enter Censys API ID...">
</div>
<div class="form-group">
<label>Censys API Secret</label>
<input type="password" id="cfgCensysSecret" placeholder="Enter Censys API secret...">
</div>
<div class="form-group">
<label>VirusTotal API Key</label>
<input type="password" id="cfgVirusTotal" placeholder="Enter VirusTotal API key...">
</div>
<div class="form-group">
<label>SecurityTrails API Key</label>
<input type="password" id="cfgSecurityTrails" placeholder="Enter SecurityTrails API key...">
</div>
<button class="btn primary" onclick="saveConfig()">Save Configuration</button>
</div>
<div class="card">
<h2>Scan Settings</h2>
<div class="form-group">
<label>Max Concurrent Scans</label>
<input type="number" id="cfgMaxScans" min="1" max="10" value="3">
</div>
<div class="form-group">
<label>Scan Timeout (seconds)</label>
<input type="number" id="cfgTimeout" min="60" max="600" value="300">
</div>
<div class="form-group">
<label>Cache TTL (seconds)</label>
<input type="number" id="cfgCacheTTL" min="60" max="86400" value="3600">
</div>
<button class="btn primary" onclick="saveConfig()">Save Settings</button>
</div>
</div>
<!-- Quick Lookup Modal -->
<div class="modal" id="lookupModal">
<div class="modal-content">
<div class="modal-header">
<h3 id="lookupTitle">Lookup Results</h3>
<button class="close-btn" onclick="closeLookupModal()">&times;</button>
</div>
<div id="lookupContent">
<div class="loader"></div> Loading...
</div>
<h2>Sandbox Tools</h2>
<div class="tool-row" id="toolsBody"><span class="muted">Loading…</span></div>
</div>
</div>
</main>
@ -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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `<span class="badge ${esc(s || 'pending')}">${esc(s || 'pending')}</span>`;
}
function fmtDate(iso) {
if (!iso) return '-';
const d = new Date(iso);
return isNaN(d.getTime()) ? esc(iso) : d.toLocaleString();
}
function renderScanRow(s, withDelete) {
return `<tr>
<td><code>${esc(s.id)}</code></td>
<td><code>${esc(s.target)}</code></td>
<td>${esc(s.type)}</td>
<td>${statusBadge(s.status)}</td>
<td>${fmtDate(s.started_at)}</td>
<td>
<button class="btn scan-act" style="padding:0.25rem 0.5rem;font-size:0.75rem" data-id="${esc(s.id)}" data-op="view">View</button>
${withDelete ? `<button class="btn danger scan-act" style="padding:0.25rem 0.5rem;font-size:0.75rem" data-id="${esc(s.id)}" data-op="delete">Delete</button>` : ''}
</td>
</tr>`;
}
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 = `<tr><td colspan="6" class="err">${esc((d && d.__error) || 'error')}</td></tr>`;
recentEl.innerHTML = msg; histEl.innerHTML = msg;
stopHistoryPoll();
return;
}
const scans = d.scans || [];
recentEl.innerHTML = scans.slice(0, 5).map(s => renderScanRow(s, false)).join('')
|| '<tr><td colspan="5" class="muted">No scans yet</td></tr>';
histEl.innerHTML = scans.map(s => renderScanRow(s, true)).join('')
|| '<tr><td colspan="6" class="muted">No scans yet</td></tr>';
document.getElementById('recentScans').innerHTML = scans.map(s => `
<tr>
<td><code>${s.target}</code></td>
<td>${s.type}</td>
<td><span class="badge ${s.status}">${s.status}</span></td>
<td>${s.findings_count || 0}</td>
<td>${s.created_at ? new Date(s.created_at).toLocaleString() : '-'}</td>
<td>
<button class="btn" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="viewScan('${s.id}')">View</button>
</td>
</tr>
`).join('') || '<tr><td colspan="6" style="text-align: center; color: var(--text-dim);">No scans yet</td></tr>';
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 => `
<tr>
<td><code>${s.id}</code></td>
<td><code>${s.target}</code></td>
<td>${s.type}</td>
<td><span class="badge ${s.status}">${s.status}</span></td>
<td>${s.findings_count || 0}</td>
<td>${s.created_at ? new Date(s.created_at).toLocaleString() : '-'}</td>
<td>
<button class="btn" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="viewScan('${s.id}')">View</button>
<button class="btn danger" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="deleteScan('${s.id}')">Delete</button>
</td>
</tr>
`).join('') || '<tr><td colspan="7" style="text-align: center; color: var(--text-dim);">No scans yet</td></tr>';
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 => `
<div class="integration-card">
<div class="icon">${i.id === 'shodan' ? '--' : i.id === 'censys' ? '--' : i.id === 'virustotal' ? '--' : i.id === 'securitytrails' ? '--' : '--'}</div>
<div class="info">
<div class="name">${i.name}</div>
<div class="desc">${i.description}</div>
</div>
<div class="status ${i.configured ? 'configured' : 'not-configured'}">
${i.configured ? 'Configured' : 'Not Configured'}
</div>
</div>
`).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 = `
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-dark); border-radius: 6px;">
<div><strong>Target:</strong> ${d.target}</div>
<div><strong>Type:</strong> ${d.type}</div>
<div><strong>Status:</strong> <span class="badge ${d.status}">${d.status}</span></div>
<div><strong>Created:</strong> ${d.created_at ? new Date(d.created_at).toLocaleString() : '-'}</div>
</div>
`;
if (d.results) {
// Findings
if (d.results.findings && d.results.findings.length) {
html += '<h3 style="color: var(--purple); margin: 1rem 0 0.5rem;">Findings</h3>';
d.results.findings.forEach(f => {
html += `
<div class="finding-card ${f.severity}">
<div class="title">
<span>${f.title}</span>
<span class="badge ${f.severity}">${f.severity}</span>
</div>
<div class="details">${JSON.stringify({...f, title: undefined, severity: undefined, type: undefined})}</div>
</div>
`;
});
}
// Data sections
if (d.results.data) {
Object.keys(d.results.data).forEach(key => {
html += `
<div class="results-section">
<h3 onclick="toggleSection(this)">${key.toUpperCase()} <span>-</span></h3>
<div class="content">
<pre>${JSON.stringify(d.results.data[key], null, 2)}</pre>
</div>
</div>
`;
});
}
if (!d || d.__error) {
content.innerHTML = `<p class="err">${esc((d && d.__error) || 'failed to load scan')}</p>`;
switchTab('results');
return;
}
html += `
<div style="margin-top: 1rem; display: flex; gap: 0.5rem;">
<button class="btn" onclick="exportScan('${scanId}', 'json')">Export JSON</button>
<button class="btn" onclick="exportScan('${scanId}', 'csv')">Export CSV</button>
const raw = (d.results && d.results.raw) || '';
content.innerHTML = `
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-dark); border-radius: 6px;">
<div><strong>ID:</strong> <code>${esc(d.id)}</code></div>
<div><strong>Target:</strong> ${esc(d.target)}</div>
<div><strong>Type:</strong> ${esc(d.type)}</div>
<div><strong>Status:</strong> ${statusBadge(d.status)}</div>
<div><strong>Started:</strong> ${fmtDate(d.started_at)}</div>
<div><strong>Finished:</strong> ${fmtDate(d.finished_at)}</div>
${d.error ? `<div class="err"><strong>Error:</strong> ${esc(d.error)}</div>` : ''}
</div>
<h3 style="color: var(--purple); margin-bottom: 0.5rem;">Raw Output</h3>
<pre class="log-out">${raw ? esc(raw) : '<span class="muted">(no output)</span>'}</pre>
`;
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 = '<div class="loader"></div> 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 = `<p class="err">External target — use the Scan launcher (Scan tab) with typed authorization to port-scan this target.</p>`;
} else {
content.innerHTML = `<p class="err">${esc(msg)}</p>`;
}
return;
}
const raw = (d.results && d.results.raw) || '';
content.innerHTML = `<div class="muted" style="margin-bottom:.5rem">status: ${esc(d.status || '-')}</div>
<pre class="log-out">${raw ? esc(raw) : '<span class="muted">(no output)</span>'}</pre>`;
}
// 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 = `<pre>${JSON.stringify(d, null, 2)}</pre>`;
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 = `<pre>${JSON.stringify(d, null, 2)}</pre>`;
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 = `<pre>${JSON.stringify(d, null, 2)}</pre>`;
}
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 = `<pre>${JSON.stringify(d, null, 2)}</pre>`;
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 = '<div class="loader"></div> 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 = `<span class="err">${esc((d && d.__error) || 'error')}</span>`; return; }
const integ = d.integrations || {};
const owned = d.owned_domains || [];
if (owned.length) ownedDomains = owned.map(x => String(x).toLowerCase());
el.innerHTML = `
<div style="margin-bottom:.5rem">Enabled: <strong>${d.enabled ? 'yes' : 'no'}</strong></div>
<div style="margin-bottom:.5rem">Owned domains: ${owned.length ? owned.map(o => `<code>${esc(o)}</code>`).join(', ') : '<span class="muted">none</span>'}</div>
<div>Integrations configured: ${Object.keys(integ).length
? Object.keys(integ).map(k => `<span class="pill ${integ[k] ? 'ok' : 'muted'}">${esc(k)}: ${integ[k] ? 'yes' : 'no'}</span>`).join(' ')
: '<span class="muted">none</span>'}</div>
`;
}
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 = `<span class="err">${esc((s && s.__error) || 'error')}</span>`; return; }
const tools = s.tools || {};
const keys = Object.keys(tools);
el.innerHTML = keys.length
? keys.map(k => `<span class="pill ${tools[k] ? 'ok' : 'muted'}">${esc(k)}: ${tools[k] ? 'available' : 'missing'}</span>`).join('')
: '<span class="muted">Sandbox not running</span>';
}
// --- 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();
</script>
</body>
</html>