Anupam MedirattaandClaude Opus 5 de237fc29c fix(qa): bind TEST_PUBKEY as a SQLite parameter instead of interpolating it (#1982)
Closes #1977. Supersedes #1952. Follow-up filed as #1983.

## What §10.2 did

```bash
q="SELECT COUNT(*) FROM transmissions WHERE from_node = '$TEST_PUBKEY';"
qq=$(printf %q "$q")
if ! count=$(ssh_t "docker exec … sqlite3 … $qq" 2>/dev/null); then
  count=$(ssh_t "sqlite3 … $qq" 2>/dev/null || echo "")
fi
```

The injection is not reachable today — `TEST_PUBKEY` is hex-gated and
the
script `exit 2`s before the SQL is built. The problem is that the SQL
layer's
safety rests entirely on that outer gate rather than on the SQL layer
itself.
#1952 proposed doubling embedded quotes; that is string escaping, not
parameterisation, which is why it was withdrawn in favour of this.

## What this does

Per the four points in the sign-off on #1977:

**1. Bind the value.** A constant `SELECT` and a bound `:pubkey`, fed to
sqlite3 on stdin. The SQL no longer crosses the remote shell as a
command
word, so there is no `printf %q` on the query at all any more.

**Why hex rather than `.parameter set :pk '<value>'`.** Dot-command
arguments
are split on whitespace, so a payload containing a space produces too
many
arguments — and sqlite3 responds by printing the `.parameter` help to
**stdout**, exiting **0**, and leaving `:pk` **unbound**. `COUNT(*)`
then
returns 0, which reads exactly like a passing security fix. `-bail` does
not
catch it. Verified on 3.51.0:

```
$ printf ".parameter set :pk '' OR 1=1 --'\nSELECT COUNT(*) FROM transmissions WHERE from_node = :pk;\n" \
    | sqlite3 -bail ptest.db
.parameter CMD ...       Manage SQL parameter bindings     # <- help, on stdout
   …
0                                                          # <- :pk never bound
$ echo $?
0
```

`.parameter set :pk 1+1` also binds the integer `2` — the value is
evaluated
as an SQL expression and only falls back to a text literal when
evaluation
fails. So interpolating into the `.parameter set` line trades one hazard
for
another.

Hex-encoding removes the quoting layer instead of adding one: the value
is
bound as `cast(x'<hex>' as text)`, so its contribution to the SQL text
is
drawn from the alphabet `[0-9a-f]` only. Nothing to quote, no tokenizer
arity
hazard, and it holds for **arbitrary** input rather than only for
hex-gated
input — which is the point.

Verified against a fixture table holding two rows, one of them
`deadbeef`:

| value | result | exit |
|---|---|---|
| `deadbeef`, bound as `cast(x'6465616462656566' as text)` | `1` | 0 |
| `' OR 1=1 --`, bound the same way | `0` | 0 |
| `' OR 1=1 --`, interpolated the current way | `2` (whole table) | 0 |
| query against a DB with no `transmissions` table, `-bail` | `Parse
error … no such table` on **stderr** | **1** |

**2. Probe the capability, not a version.** `resolve_sqlite_runner`
binds
`corescope-probe-ok` and asserts it comes back — a round trip, not a
bare
`.parameter init`, so the positive control runs against the operator's
actual
binary rather than one we pin. If neither the container nor the host
qualifies,
it fails loudly and names what is needed:

```
  ❌ retain-failed: no sqlite3 able to bind a parameter on the target
     tried: docker exec -i corescope-prod sqlite3, then sqlite3 on runner@example
     need:  the sqlite3 CLI reachable over ssh, supporting '.parameter set'
OCI runtime exec failed: exec: "sqlite3": executable file not found in $PATH
bash: line 1: sqlite3: command not found
```

There is deliberately **no** interpolating fallback. That would leave
the
vulnerable path in place under a nicer name.

**3. The hex gate is kept**, with its comment updated to say why: for
the SQL
layer it is now defence in depth rather than the only guard. Redundant
is not
the same as wrong.

**4. The exit status and stderr survive.** `-batch -bail -init /dev/null
-noheader -list` (stop at the first SQL error; ignore the operator's
`~/.sqliterc`, where a stray `.mode` would make the count unparseable;
stdout
is exactly the number). Query stderr is captured and printed on failure
rather
than sent to `/dev/null`, so a broken query is distinguishable from a
legitimately empty result. Probe stderr is collected too, and printed
only if
*both* probes fail — the container miss is the known-normal case, so
surfacing
it on every run would be noise.

## Also fixed

An existing double-count in §10.2: the `TARGET_DB_PATH unset` branch
incremented `$fails` and then left `count=""`, so the generic branch
incremented it a **second** time for the same failure.
`read_retain_count` now
gives §10.2 exactly one increment point. Opportunistic cleanup in a file
already being touched (AGENTS.md line 318).

## Tests

New `qa/scripts/test-blacklist-sql.sh`, wired into the `go-test` job. 24
assertions, modelled on `scripts/staging/test-disk-monitor.sh`.

Both directions are asserted, because a zero from a command that failed
proves
nothing:

- **Positive control** — `deadbeef` still returns its row (`1`, exit 0),
and so
  does `cafebabe`; an absent pubkey returns `0`.
- **Negative** — `' OR 1=1 --` returns `0` while the table demonstrably
holds
2 rows, and the old interpolated form is asserted to leak all `2`. That
last
  assertion is what makes the `0` above worth something.
- **Error surfacing** — the same SQL against a DB with no
`transmissions` table
  exits non-zero with a message on stderr and nothing on stdout.
- **Alphabet** — `sql_hex_literal` output matches `^x'[0-9a-f]*'$` for
the SQL
payloads, a backslash, `$(id)` / backticks, an embedded newline,
`héllo`, and
a 4096-byte repetitive string. That last one is a regression guard for
`od
  -v`: without the flag `od` collapses repeated identical lines to `*`.
- `run_sqlite` with no resolved runner refuses rather than guessing.

Group 2 skips loudly (rather than silently) if `sqlite3` is not on PATH;
group
1 needs no sqlite3 and always runs.

**Mutation-tested** — each of these breaks the suite, so the assertions
have
teeth:

| mutation | caught by |
|---|---|
| restore full interpolation | `injection payload → 0 rows — expected
'0' got '2'` |
| naive `.parameter set '%s'` | `expected '0' got '.parameter CMD ...'`
|
| drop `od -v` | alphabet failure on `*`, plus `expected '8192' got
'33'` |

Commit 1 is a behaviour-neutral refactor that moves the imperative body
into
`main()` behind a `BASH_SOURCE` guard, so the test can source the script
and
exercise individual helpers. Same idiom as
`scripts/staging/disk-monitor.sh:99`.

## Verification

- `bash qa/scripts/test-blacklist-sql.sh` → 24 passed, 0 failed
- `bash -n` on both scripts
- All three runtime paths exercised end to end with PATH shims for
  `ssh`/`docker`/`sqlite3` against a real fixture DB: success
  (`sqlite3 runner: host`, count 2), query failure (classified message +
`Parse error … no such table`, `fails=1`), and no-capability (the loud
block
  above, both probe stderrs, `fails=1` — not 2)
- The new step lands inside `go-test`, which runs when
`changes.outputs.code ==
  'true'`; `qa/scripts/*.sh` does not match that job's
  `^docs/|[.]md$|^LICENSE$` documentation filter, so it is not skipped

## Deliberately out of scope

- **The `docker exec` branch is dead on current images** → filed as
#1983. The
app container has no `sqlite3` at all: `Dockerfile:15` is pure-Go SQLite
with
  no CGO, and the `apk add` installs only `mosquitto mosquitto-clients
supervisor caddy wget`. So the host "fallback" is the only path that has
ever
executed, silently, because both branches discarded stderr. This change
keeps
both branches and merely makes the outcome visible (`sqlite3 runner: …`
on
  every run).
- **`-readonly` on the target DB.** Tempting, and verified compatible
with
  `.parameter` (the binding table lives in the TEMP database), but a WAL
database needing journal recovery can refuse a read-only open. Adding it
here
risks exactly the "trades an unreachable injection for a script that
does not
  run" outcome flagged in the #1952 thread. Worth its own issue.
- **The other `2>/dev/null` sites** in this file, which also sit
awkwardly with
`qa/README.md`'s "Don't silence stderr". Only the §10.2 lines named in
the
  sign-off are touched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:37:40 +02:00
2026-04-05 06:36:03 +00:00

CoreScope

Go Server Coverage Go Ingestor Coverage E2E Tests Frontend Coverage Deploy

High-performance mesh network analyzer powered by Go. Sub-millisecond packet queries, ~300 MB memory for 56K+ packets, real-time WebSocket broadcast, full channel decryption.

Self-hosted, open-source MeshCore packet analyzer. Collects MeshCore packets via MQTT, decodes them in real time, and presents a full web UI with live packet feed, interactive maps, channel chat, packet tracing, and per-node analytics.

⚡ Performance

The Go backend serves all 40+ API endpoints from an in-memory packet store with 5 indexes (hash, txID, obsID, observer, node). SQLite is for persistence only — reads never touch disk.

Metric Value
Packet queries < 1 ms (in-memory)
All API endpoints < 100 ms
Memory (56K packets) ~300 MB (vs 1.3 GB on Node.js)
WebSocket broadcast Real-time to all connected browsers
Channel decryption AES-128-ECB with rainbow table
GOMEMLIMIT (memory-constrained hosts) set to ≥1.5× working set (e.g. 1536 MiB on a 2 GB Pi for a ~1 GB store). Lower values trigger a GC death-spiral. Configure via the GOMEMLIMIT env var or runtime.maxMemoryMB in config.json; env wins. Applies to both server and ingestor. See #1010.

See PERFORMANCE.md for full benchmarks.

✨ Features

📡 Live Trace Map

Real-time animated map with packet route visualization, VCR-style playback controls, and a retro LCD clock. Replay the last 24 hours of mesh activity, scrub through the timeline, or watch packets flow live at up to 4× speed.

Live VCR playback — watch packets flow across the Bay Area mesh

📦 Packet Feed

Filterable real-time packet stream with byte-level breakdown, Excel-like resizable columns, and a detail pane. Toggle "My Nodes" to focus on your mesh.

Packets view

🗺️ Network Overview

At-a-glance mesh stats — node counts, packet volume, observer coverage.

Network overview

📊 Node Analytics

Per-node deep dive with interactive charts: activity timeline, packet type breakdown, SNR distribution, hop count analysis, peer network graph, and hourly heatmap.

Node analytics

💬 Channel Chat

Decoded group messages with sender names, @mentions, timestamps — like reading a Discord channel for your mesh.

Channels

📱 Mobile Ready

Full experience on your phone — proper touch controls, iOS safe area support, and a compact VCR bar.

Live view on iOS

And More

  • 11 Analytics Tabs — RF, topology, channels, hash stats, distance, route patterns, and more
  • Node Directory — searchable list with role tabs, detail panel, QR codes, advert timeline
  • Packet Tracing — follow individual packets across observers with SNR/RSSI timeline
  • Observer Status — health monitoring, packet counts, uptime, per-observer analytics
  • Hash Collision Matrix — detect address collisions across the mesh
  • Channel Key Auto-Derivation — hashtag channels (#channel) keys derived via SHA256
  • Multi-Broker MQTT — connect to multiple brokers with per-source IATA filtering
  • Dark / Light Mode — auto-detects system preference, map tiles swap too
  • Theme Customizer — design your theme in-browser, export as theme.json
  • Global Search — search packets, nodes, and channels (Ctrl+K)
  • Shareable URLs — deep links to packets, channels, and observer detail pages
  • Protobuf API Contract — typed API definitions in proto/
  • Accessible — ARIA patterns, keyboard navigation, screen reader support

Quick Start

No build step required — just run:

docker run -d --name corescope \
  --restart=unless-stopped \
  -p 80:80 -p 1883:1883 \
  -v /your/data:/app/data \
  ghcr.io/kpa-clawbot/corescope:latest

Open http://localhost — done. No config file needed; CoreScope starts with sensible defaults.

For HTTPS with a custom domain, add -p 443:443 and mount your Caddyfile:

docker run -d --name corescope \
  --restart=unless-stopped \
  -p 80:80 -p 443:443 -p 1883:1883 \
  -v /your/data:/app/data \
  -v /your/Caddyfile:/etc/caddy/Caddyfile:ro \
  -v /your/caddy-data:/data/caddy \
  ghcr.io/kpa-clawbot/corescope:latest

Disable built-in services with -e DISABLE_MOSQUITTO=true or -e DISABLE_CADDY=true, or drop a .env file in your data volume. See docs/deployment.md for the full reference.

Build from Source

git clone https://github.com/Kpa-clawbot/CoreScope.git
cd CoreScope
./manage.sh setup

The setup wizard walks you through config, domain, HTTPS, build, and run.

./manage.sh status       # Health check + packet/node counts
./manage.sh logs         # Follow logs
./manage.sh backup       # Backup database
./manage.sh update       # Pull latest + rebuild + restart
./manage.sh mqtt-test    # Check if observer data is flowing
./manage.sh help         # All commands

Configure

Copy config.example.json to config.json and edit:

{
  "port": 3000,
  "mqtt": {
    "broker": "mqtt://localhost:1883",
    "topic": "meshcore/+/+/packets"
  },
  "mqttSources": [
    {
      "name": "remote-feed",
      "broker": "mqtts://remote-broker:8883",
      "topics": ["meshcore/+/+/packets"],
      "username": "user",
      "password": "pass",
      "iataFilter": ["SJC", "SFO", "OAK"]
    }
  ],
  "channelKeys": {
    "public": "8b3387e9c5cdea6ac9e5edbaa115cd72"
  },
  "defaultRegion": "SJC"
}
Field Description
port HTTP server port (default: 3000)
mqtt.broker Local MQTT broker URL ("" to disable)
mqttSources External MQTT broker connections (optional)
channelKeys Channel decryption keys (hex). Hashtag channels auto-derived via SHA256
defaultRegion Default IATA region code for the UI
dbPath SQLite database path (default: data/meshcore.db)

Environment Variables

Variable Description
PORT Override config port
DB_PATH Override SQLite database path

Architecture

                           ┌─────────────────────────────────────────────┐
                           │              Docker Container               │
                           │                                             │
Observer → USB →           │  Mosquitto ──→ Go Ingestor ──→ SQLite DB   │
  meshcoretomqtt → MQTT ──→│                    │                        │
                           │              Go HTTP Server ──→ WebSocket   │
                           │                    │               │        │
                           │              Caddy (HTTPS) ←───────┘        │
                           └────────────────────┼────────────────────────┘
                                                │
                                             Browser

Two-process model: The Go ingestor handles MQTT ingestion and packet decoding. The Go HTTP server loads all packets into an in-memory store on startup (5 indexes for fast lookups) and serves the REST API + WebSocket broadcast. Both are managed by supervisord inside a single container with Caddy for HTTPS and Mosquitto for local MQTT.

MQTT Setup

  1. Flash an observer node with MESH_PACKET_LOGGING=1 build flag
  2. Connect via USB to a host running meshcoretomqtt
  3. Configure meshcoretomqtt with your IATA region code and MQTT broker address
  4. Packets appear on topic meshcore/{IATA}/{PUBKEY}/packets

Or POST raw hex packets to POST /api/packets for manual injection.

Project Structure

corescope/
├── cmd/
│   ├── server/              # Go HTTP server + WebSocket + REST API
│   │   ├── main.go          # Entry point
│   │   ├── routes.go        # 40+ API endpoint handlers
│   │   ├── store.go         # In-memory packet store (5 indexes)
│   │   ├── db.go            # SQLite persistence layer
│   │   ├── decoder.go       # MeshCore packet decoder
│   │   ├── websocket.go     # WebSocket broadcast
│   │   └── *_test.go        # 327 test functions
│   └── ingestor/            # Go MQTT ingestor
│       ├── main.go          # MQTT subscription + packet processing
│       ├── decoder.go       # Packet decoder (shared logic)
│       ├── db.go            # SQLite write path
│       └── *_test.go        # 53 test functions
├── proto/                   # Protobuf API definitions
├── public/                  # Vanilla JS frontend (no build step)
│   ├── index.html           # SPA shell
│   ├── app.js               # Router, WebSocket, utilities
│   ├── packets.js           # Packet feed + hex breakdown
│   ├── map.js               # Leaflet map + route visualization
│   ├── live.js              # Live trace + VCR playback
│   ├── channels.js          # Channel chat
│   ├── nodes.js             # Node directory + detail views
│   ├── analytics.js         # 11-tab analytics dashboard
│   └── style.css            # CSS variable theming (light/dark)
├── docker/
│   ├── supervisord-go.conf  # Process manager (server + ingestor)
│   ├── mosquitto.conf       # MQTT broker config
│   ├── Caddyfile            # Reverse proxy + HTTPS
│   └── entrypoint-go.sh     # Container entrypoint
├── Dockerfile               # Multi-stage Go build + Alpine runtime
├── config.example.json      # Example configuration
├── test-*.js                # Node.js test suite (frontend + legacy)
└── tools/                   # Generators, E2E tests, utilities

For Developers

Test Suite

380 Go tests covering the backend, plus 150+ Node.js tests for the frontend and legacy logic, plus 49 Playwright E2E tests for browser validation.

# Go backend tests
cd cmd/server && go test ./... -v
cd cmd/ingestor && go test ./... -v

# Node.js frontend + integration tests
npm test

# Playwright E2E (requires running server on localhost:3000)
node test-e2e-playwright.js

Generate Test Data

node tools/generate-packets.js --api --count 200

Migrating from Node.js

If you're running an existing Node.js deployment, see docs/go-migration.md for a step-by-step guide. The Go engine reads the same SQLite database and config.json — no data migration needed.

Contributing

Contributions welcome. Please read AGENTS.md for coding conventions, testing requirements, and engineering principles before submitting a PR.

Live instance: analyzer.00id.net — all API endpoints are public, no auth required.

API Documentation: CoreScope auto-generates an OpenAPI 3.0 spec. Browse the interactive Swagger UI at /api/docs or fetch the machine-readable spec at /api/spec.

License

GPL-3.0-or-later

S
Description
No description provided
Readme GPL-3.0
148 MiB
Languages
JavaScript 51.5%
Go 42%
CSS 3.6%
Shell 1.7%
HTML 0.7%
Other 0.3%