mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
Merge feature/openclaw-lxc-scanner: dedicated-LXC OSINT+nmap scanner (live on gk2)
Some checks are pending
License Headers / check (push) Waiting to run
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:
commit
75722e2a59
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"])]
|
||||
|
||||
|
|
|
|||
1
packages/secubox-openclaw/api/__init__.py
Normal file
1
packages/secubox-openclaw/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# SecuBox OpenClaw API
|
||||
File diff suppressed because it is too large
Load Diff
12
packages/secubox-openclaw/conf/openclaw.toml.example
Normal file
12
packages/secubox-openclaw/conf/openclaw.toml.example
Normal 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 = ""
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
146
packages/secubox-openclaw/sbin/openclawctl
Executable file
146
packages/secubox-openclaw/sbin/openclawctl
Executable 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
|
||||
0
packages/secubox-openclaw/tests/__init__.py
Normal file
0
packages/secubox-openclaw/tests/__init__.py
Normal file
11
packages/secubox-openclaw/tests/conftest.py
Normal file
11
packages/secubox-openclaw/tests/conftest.py
Normal 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"],
|
||||
},
|
||||
}
|
||||
18
packages/secubox-openclaw/tests/test_openclawctl_guards.sh
Executable file
18
packages/secubox-openclaw/tests/test_openclawctl_guards.sh
Executable 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
|
||||
17
packages/secubox-openclaw/tests/test_policy.py
Normal file
17
packages/secubox-openclaw/tests/test_policy.py
Normal 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
|
||||
75
packages/secubox-openclaw/tests/test_scans.py
Normal file
75
packages/secubox-openclaw/tests/test_scans.py
Normal 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"
|
||||
19
packages/secubox-openclaw/tests/test_status_cache.py
Normal file
19
packages/secubox-openclaw/tests/test_status_cache.py
Normal 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
|
||||
|
|
@ -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()">×</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 =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user