diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1a79210 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +permissions: + contents: read + +jobs: + test: + name: PHP 8.4 / Test & Analyse + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: curl, mbstring, xml + coverage: xdebug + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run tests with coverage + run: vendor/bin/pest --coverage --min=80 + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress diff --git a/.omc/state/checkpoints/checkpoint-2026-03-10T08-14-41-766Z.json b/.omc/state/checkpoints/checkpoint-2026-03-10T08-14-41-766Z.json new file mode 100644 index 0000000..acc51c1 --- /dev/null +++ b/.omc/state/checkpoints/checkpoint-2026-03-10T08-14-41-766Z.json @@ -0,0 +1,11 @@ +{ + "created_at": "2026-03-10T08:14:41.765Z", + "trigger": "auto", + "active_modes": {}, + "todo_summary": { + "pending": 0, + "in_progress": 0, + "completed": 0 + }, + "wisdom_exported": false +} \ No newline at end of file diff --git a/.omc/state/subagent-tracking.json b/.omc/state/subagent-tracking.json new file mode 100644 index 0000000..f6b7431 --- /dev/null +++ b/.omc/state/subagent-tracking.json @@ -0,0 +1,26 @@ +{ + "agents": [ + { + "agent_id": "a699400d97bee152d", + "agent_type": "Explore", + "started_at": "2026-03-10T08:10:14.616Z", + "parent_mode": "none", + "status": "failed", + "completed_at": "2026-03-10T08:11:31.007Z", + "duration_ms": 76391 + }, + { + "agent_id": "a308486ee44f9c951", + "agent_type": "Explore", + "started_at": "2026-03-10T08:12:45.759Z", + "parent_mode": "none", + "status": "failed", + "completed_at": "2026-03-10T08:14:41.642Z", + "duration_ms": 115883 + } + ], + "total_spawned": 2, + "total_completed": 0, + "total_failed": 2, + "last_updated": "2026-03-10T08:16:10.392Z" +} \ No newline at end of file diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..d6d65cb --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,177 @@ +# plugin_webseer — Backlog + +--- + +### Issue #1: security(sql): parameterise email address in plugin_webseer_update_contacts() +- **Priority**: high +- **Labels**: [security, sql-injection, hardening] +- **Branch**: `security/1-parameterise-contacts-email` +- **Evidence**: `includes/functions.php:319-325` — `db_execute("REPLACE INTO plugin_webseer_contacts ... '" . $u['email_address'] . "'")` +- **Acceptance criteria**: + - [ ] Both `db_execute()` calls replaced with `db_execute_prepared()` using `?` placeholders + - [ ] `$u['id']`, `$cid`, and `$u['email_address']` all bound as parameters + - [ ] Unit test covers email address containing single quote + - [ ] PHPStan level 6 passes +- **Dependencies**: none + +--- + +### Issue #2: security(sql): parameterise rfilter RLIKE clause in list_urls() +- **Priority**: high +- **Labels**: [security, sql-injection, hardening] +- **Branch**: `security/2-parameterise-rfilter-rlike` +- **Evidence**: `webseer.php:744-749` — `'display_name RLIKE \'' . get_request_var('rfilter') . '\''` +- **Acceptance criteria**: + - [ ] All five RLIKE comparisons use `db_fetch_assoc_prepared()` with bound `?` params + - [ ] Or `db_qstr()` wrapping applied as interim measure + - [ ] Regression test with a regex containing a single quote + - [ ] PHPStan passes +- **Dependencies**: none + +--- + +### Issue #3: security(sql): parameterise proxy filter LIKE clause in proxies() +- **Priority**: high +- **Labels**: [security, sql-injection, hardening] +- **Branch**: `security/3-parameterise-proxy-filter` +- **Evidence**: `webseer_proxies.php:255` — `' name LIKE "%' . get_request_var('filter') . '%" OR hostname LIKE ...'` +- **Acceptance criteria**: + - [ ] Filter value bound via prepared statement parameter + - [ ] Test covers filter value with `%`, `_`, `'` characters +- **Dependencies**: none + +--- + +### Issue #4: security(sql): replace manual strip with prepared statement in remote.php SETMASTER +- **Priority**: high +- **Labels**: [security, sql-injection, hardening] +- **Branch**: `security/4-setmaster-prepared` +- **Evidence**: `remote.php:174-176` — `str_replace(array("'", '\\'), '', $_POST['ip'])` then `db_fetch_row("... WHERE ip = '$ip'")` +- **Acceptance criteria**: + - [ ] `db_fetch_row_prepared('... WHERE ip = ?', [$ip])` replaces the concatenated query + - [ ] Manual `str_replace` sanitisation removed + - [ ] Test covers IP containing SQL metacharacters +- **Dependencies**: none + +--- + +### Issue #5: security(ssrf): implement UrlValidator and wire into cURL class +- **Priority**: high +- **Labels**: [security, ssrf, hardening] +- **Branch**: `security/5-ssrf-url-validator` +- **Evidence**: `classes/cURL.php:91,136` — `curl_init($url)` without host validation +- **Acceptance criteria**: + - [ ] `src/Security/UrlValidator.php` validates scheme allowlist (http/https only) + - [ ] Blocks RFC-1918, loopback, link-local, AWS metadata ranges + - [ ] `cURL::get()` calls `UrlValidator::isAllowed()` before `curl_init()`; returns error result on rejection + - [ ] `cURL::post()` calls `UrlValidator::isAllowed()` before `curl_init()` + - [ ] All `->todo()` annotations in `SsrfTest.php` converted to passing tests + - [ ] PHPStan passes +- **Dependencies**: `src/Security/UrlValidator.php` skeleton already present + +--- + +### Issue #6: security(deserialization): replace unserialize with JSON in server sync +- **Priority**: high +- **Labels**: [security, deserialization, hardening] +- **Branch**: `security/6-replace-unserialize-json` +- **Evidence**: `includes/functions.php:75,107` — `unserialize(base64_decode($servers))` / `unserialize(base64_decode($urls))` +- **Acceptance criteria**: + - [ ] `plugin_webseer_refresh_servers()` and `plugin_webseer_refresh_urls()` decode JSON instead of PHP serialised data + - [ ] `remote.php` GETSERVERS/GETURLS actions encode with `json_encode()` instead of `serialize()` + - [ ] If serialised format cannot be changed immediately, at minimum add `['allowed_classes' => false]` to both `unserialize()` calls + - [ ] Test covers malicious serialised object payload being rejected +- **Dependencies**: #11 (remote.php hardening) for protocol change + +--- + +### Issue #7: security(xss): escape URL in history and list view anchor tags +- **Priority**: high +- **Labels**: [security, xss, hardening] +- **Branch**: `security/7-escape-url-anchor-tags` +- **Evidence**: `webseer.php:651`, `webseer_servers.php:434` — `href='" . $row['url'] . "'"` without `html_escape()` +- **Acceptance criteria**: + - [ ] Both `$row['url']` occurrences wrapped with `html_escape()` + - [ ] Scheme validated to be `http` or `https` before rendering as a clickable link; otherwise render as plain text + - [ ] Test covers URL with `javascript:` scheme and with `'; + $escaped = htmlspecialchars($storedUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + expect($escaped)->not->toContain('