diff --git a/.gitignore b/.gitignore index 69f2746..d03590e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ configs/** output/** +.omc/ diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..539b550 --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,117 @@ +# Backlog: plugin_weathermap + +Issues derived from SECURITY-AUDIT.md. Do not push to GitHub until contrib-ledger gate passes. + +--- + +## Issue #1: refactor: extract FpingRunner and RrdCommandBuilder seams +**Priority**: medium (blocker for security tests) +**Labels**: refactor, tech-debt +**Branch**: `refactor/1-shell-command-seams` +**Evidence**: WeatherMapDataSource_fping.php:103; WeatherMapDataSource_rrd.php:296–325 +**Acceptance criteria**: +- [ ] `src/Shell/FpingRunner.php` wraps popen call; accepts pre-built command string +- [ ] `src/Rrd/RrdCommandBuilder.php` assembles rrdtool command string from typed args +- [ ] Both classes instantiable without Cacti bootstrap +- [ ] PHPStan level 6 passes on `src/` + +**Dependencies**: none + +--- + +## Issue #2: security(cmd): escapeshellarg fping target and validate as IP/hostname +**Priority**: medium +**Labels**: security +**Branch**: `security/2-escapeshellarg-fping-target` +**Evidence**: lib/datasources/WeatherMapDataSource_fping.php:103 (WM-CMD-01) +**Acceptance criteria**: +- [ ] `$target` validated with `filter_var($target, FILTER_VALIDATE_IP)` or strict hostname regex before use +- [ ] `escapeshellarg($target)` applied in command string +- [ ] `src/Shell/FpingRunner.php` seam extracted and unit-tested +- [ ] Security test WM-CMD-01 passes (todo removed) +- [ ] Targets that fail IP/hostname validation are rejected with a logged warning + +**Dependencies**: Issue #1 + +--- + +## Issue #3: security(cmd): fix RRD popen argument quoting +**Priority**: low +**Labels**: security, tech-debt +**Branch**: `security/3-fix-rrd-arg-quoting` +**Evidence**: lib/datasources/WeatherMapDataSource_rrd.php:325, 429 (WM-CMD-02) +**Acceptance criteria**: +- [ ] Manual double-quote wrapping replaced with `escapeshellarg()` per argument +- [ ] `$rrdfile` validated as absolute filesystem path with no shell metacharacters +- [ ] `src/Rrd/RrdCommandBuilder.php` seam extracted and unit-tested +- [ ] Security test WM-CMD-02 passes (todo removed) + +**Dependencies**: Issue #1 + +--- + +## Issue #4: security(path): add backslash check to wm_editor_sanitize_conffile +**Priority**: low +**Labels**: security, tech-debt +**Branch**: `security/4-sanitize-conffile-backslash` +**Evidence**: lib/editor.inc.php:232–246 (WM-PATH-01) +**Acceptance criteria**: +- [ ] `wm_editor_sanitize_conffile` rejects filenames containing `\` +- [ ] Path traversal test WM-PATH-01 (backslash case) todo removed +- [ ] Existing positive test cases remain green + +**Dependencies**: none + +--- + +## Issue #5: security(input): validate $action against allowed-list in editor +**Priority**: medium +**Labels**: security, tech-debt +**Branch**: `security/5-validate-editor-action` +**Evidence**: weathermap-cacti-plugin-editor.php:91 — `$action = get_nfilter_request_var('action')` without allowed-list validation +**Acceptance criteria**: +- [ ] `$action` validated against an explicit list of valid editor action strings +- [ ] Unknown actions produce a logged warning and abort +- [ ] PHPStan level 6 passes + +**Dependencies**: none + +--- + +## Issue #6: refactor: extract MapnameValidator as testable class +**Priority**: low +**Labels**: refactor, tech-debt +**Branch**: `refactor/6-mapname-validator-class` +**Evidence**: lib/editor.inc.php:232–246 +**Acceptance criteria**: +- [ ] `src/Config/MapnameValidator.php` wraps `wm_editor_sanitize_conffile` logic +- [ ] All existing sanitization cases covered by unit tests +- [ ] PHPStan level 6 passes + +**Dependencies**: none + +--- + +## Issue #7: ci: add Pest 4 + PHPStan CI workflow +**Priority**: medium +**Labels**: ci +**Branch**: `ci/7-pest-phpstan-workflow` +**Acceptance criteria**: +- [ ] `.github/workflows/test.yml` runs `composer test` and `composer analyse` +- [ ] PHPStan level 6 clean on `src/` and `tests/` +- [ ] Actions pinned to full commit SHA + +**Dependencies**: Issues #1–#5 + +--- + +## Issue #8: docs: complete audit of weathermap-cacti-plugin-editor.php action dispatch +**Priority**: medium +**Labels**: security, docs +**Branch**: `docs/8-editor-action-audit` +**Evidence**: editor action variable not fully traced in this audit +**Acceptance criteria**: +- [ ] All action values and their code paths documented +- [ ] Any state-changing actions that lack CSRF or auth checks identified and filed as separate issues + +**Dependencies**: none diff --git a/SECURITY-AUDIT.md b/SECURITY-AUDIT.md new file mode 100644 index 0000000..f12100c --- /dev/null +++ b/SECURITY-AUDIT.md @@ -0,0 +1,174 @@ +# Security Audit: plugin_weathermap + +**Date**: 2026-03-09 +**Auditor**: automated static analysis + manual review +**Scope**: all PHP files under plugin_weathermap/ excluding vendor/ (none present) + +--- + +## Summary + +Weathermap is the most security-mature of the four plugins audited. The Cacti Group +rewrite uses prepared statements throughout the management UI (weathermap-cacti-plugin-mgmt.php), +consistently applies html_escape() on DB-sourced output, and enforces api_plugin_user_realm_auth +at well-defined entry points. The two remaining surface areas are in the map-rendering engine +(datasource plugins) where admin-controlled config values flow into shell commands without +escapeshellarg, and a residual Windows-only path traversal gap in the config filename sanitizer. + +--- + +## Findings + +### WM-CMD-01 — OS Command Injection via map config TARGET value (MEDIUM) +**File**: `lib/datasources/WeatherMapDataSource_fping.php:103` +**Category**: OS Command Injection +**Severity**: MEDIUM (requires write access to a .conf map file; admin-controlled input) + +```php +$command = $this->fping_cmd . " -t100 -r1 -p20 -u -C $ping_count -i10 -q $target 2>&1"; +$pipe = popen($command, 'r'); +``` + +`$target` is extracted from the TARGET line in a weathermap `.conf` file via the regex +`/^fping:(\S+)$/`. The regex captures any non-whitespace string, including shell +metacharacters (`;`, `|`, `&`, `` ` ``, `$`). A crafted TARGET line such as +`TARGET fping:192.0.2.1;id>/tmp/x` injects into the shell command. + +**Attack path**: Admin writes (or attacker achieves file-write on) a .conf file containing +a malicious TARGET line → weathermap poller processes it → `popen` executes the injected command +as the web/poller process user. + +**Remediation**: Wrap `$target` with `escapeshellarg()`. Additionally, validate that +`$target` is a valid IP address or hostname before use (reject if it fails `filter_var` +with `FILTER_VALIDATE_IP` or a strict hostname regex). + +--- + +### WM-CMD-02 — OS Command Injection via RRD file path in popen command (LOW-MEDIUM) +**File**: `lib/datasources/WeatherMapDataSource_rrd.php:325, 429` +**Category**: OS Command Injection +**Severity**: LOW-MEDIUM (rrdfile sourced from Cacti DB, admin-controlled) + +```php +// args array assembled with: +$args[] = "DEF:in=$rrdfile:" . $dsnames[IN] . ":$cf"; +// then: +foreach ($args as $arg) { + if (strchr($arg, ' ') != false) { + $command .= ' "' . $arg . '"'; + } else { + $command .= ' ' . $arg; + } +} +$pipe = popen($command, 'r'); +``` + +`$rrdfile` and `$dsnames` originate from Cacti's `data_template_data` table (admin-set +RRD paths). The quoting logic uses double quotes around args containing spaces, but does +not escape double-quote characters within the value itself. A `$rrdfile` containing `"` +or `` ` `` breaks out of the double-quote boundary. + +The `db_fetch_row_prepared` calls that source `$rrdfile` use parameterized queries, so +the DB-layer is safe. The risk is an admin storing a crafted RRD path. + +**Remediation**: Validate `$rrdfile` as an absolute filesystem path with no shell +metacharacters before embedding in command args. Use `escapeshellarg()` per argument +rather than manual double-quoting. + +--- + +### WM-PATH-01 — Path traversal in map config filename (LOW — mitigated, residual Windows risk) +**File**: `weathermap-cacti-plugin-editor.php:96`, `lib/editor.inc.php:232–246` +**Category**: Path Traversal +**Severity**: LOW (effectively mitigated on Linux/macOS; residual risk on Windows) + +```php +function wm_editor_sanitize_conffile($filename) { + $filename = wm_editor_sanitize_uri($filename); + if (substr($filename, -5, 5) != '.conf') { $filename = ''; } + if (strstr($filename, '/') !== false) { $filename = ''; } + return $filename; +} +``` + +The sanitizer enforces `.conf` extension and rejects any `/` character, preventing +Unix path traversal (e.g., `../../etc/passwd.conf` is rejected). PHP null-byte injection +(`file.conf\0.php`) was fixed in PHP 8.0; with PHP 8.4 as the requirement it is not +exploitable. + +Residual gap: Windows path separator `\` is not checked. On Windows hosts, +`..\\configs\\other.conf` passes both checks. Since Cacti targets Linux, this is +informational. + +**Remediation**: Add a backslash check for defense-in-depth: `if (strstr($filename, '\\') !== false) { $filename = ''; }`. + +--- + +### WM-POSITIVE-01 — Management UI uses prepared statements throughout (INFORMATIONAL) +**File**: `weathermap-cacti-plugin-mgmt.php:354–390, 467–476` + +The `weathermap_form_actions()` function iterates `$_POST`, extracts checkbox keys via +`preg_match('/^chk_([0-9:a-z]+)$/', ...)`, validates numeric parts with +`input_validate_input_number()`, and uses `db_execute_prepared` with `?` placeholders +for all INSERT/DELETE operations. DB-sourced output is wrapped with `html_escape()`. +No SQL injection or XSS issues found in this file. + +--- + +### WM-POSITIVE-02 — Auth enforcement is consistent (INFORMATIONAL) +**File**: `weathermap-cacti-plugin.php:418, 445`, `setup.php:766` + +`api_plugin_user_realm_auth` is called at the correct entry points for both viewer and +management realms. No unauthenticated paths to state-modifying actions were found. + +--- + +### WM-POSITIVE-03 — Editor mapname sanitization prevents path traversal (INFORMATIONAL) +**File**: `weathermap-cacti-plugin-editor.php:91–96` + +```php +$mapname = get_nfilter_request_var('mapname'); +$mapname = wm_editor_sanitize_conffile($mapname); +``` + +The sanitizer is applied immediately after reading `$mapname`. The `wm_editor_sanitize_uri` +call within `wm_editor_sanitize_conffile` additionally strips dangerous URI characters. +This is a well-implemented control. + +--- + +## Unknowns and Blind Spots + +- **weathermap-cacti-plugin-editor.php** was only partially read. The editor has a large + surface area (map config editing, node/link add/remove). The `$action` variable is read + via `get_nfilter_request_var('action')` without validation against an allowed-list; + tracing what each action value triggers requires a full read. +- **`lib/editor.actions.php`** was not fully read. It uses `wm_editor_sanitize_conffile` + on `$sourcemapname` (line 68) which is correct, but other action parameters may not + be sanitized. +- **`weathermap-cacti-rebuild.php`** was not examined. Rebuild scripts that process + map configs offline may have additional shell execution paths. +- **`check.php`** was not examined. +- **fping target IP validation**: whether `$target` is validated as a valid IP/hostname + anywhere before reaching the popen call was not confirmed beyond the `\S+` regex. + +--- + +## Seams Required Before TDD Can Fully Proceed + +| Seam | Files Affected | Description | +|------|---------------|-------------| +| `src/Shell/FpingRunner.php` | WeatherMapDataSource_fping.php:103 | Extract popen call; test command assembly without executing fping | +| `src/Rrd/RrdCommandBuilder.php` | WeatherMapDataSource_rrd.php:296–325 | Extract arg assembly; test escapeshellarg application | +| `src/Config/MapnameValidator.php` | editor.inc.php:232–246 | Expose sanitize logic as a testable class method | + +--- + +## Estimated Effort + +| Finding | Fix Effort | +|---------|-----------| +| WM-CMD-01 (fping escapeshellarg + IP validation) | 1 h | +| WM-CMD-02 (rrd arg quoting) | 1 h | +| WM-PATH-01 (backslash check) | 15 min | +| Full test coverage via seams | 1 day | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a1a8a46 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Supported Versions + +This plugin follows the Cacti project's support policy. Security fixes are +applied to the current development branch and backported per project policy. + +## Reporting a Vulnerability + +Report security vulnerabilities via the Cacti project's private security +disclosure process: + +- GitHub Security Advisories: https://github.com/Cacti/plugin_weathermap/security/advisories +- Do NOT open public issues for security vulnerabilities. + +Please include: +- Description of the vulnerability +- Steps to reproduce +- Affected versions +- Suggested remediation (if known) + +A maintainer will acknowledge the report within 72 hours and provide a +remediation timeline. + +## Security Hardening Notes + +See SECURITY-AUDIT.md for the current known finding backlog and remediation status. diff --git a/check.php b/check.php index 30a82a5..6d7067f 100644 --- a/check.php +++ b/check.php @@ -1,4 +1,6 @@ context === 'cacti') { diff --git a/lib/datasources/WeatherMapDataSource_cactihost.php b/lib/datasources/WeatherMapDataSource_cactihost.php index 5dbded1..9e9146c 100644 --- a/lib/datasources/WeatherMapDataSource_cactihost.php +++ b/lib/datasources/WeatherMapDataSource_cactihost.php @@ -1,4 +1,6 @@ + + + + tests + + + + + + + diff --git a/setup.php b/setup.php index 30c7637..71fa769 100644 --- a/setup.php +++ b/setup.php @@ -1,4 +1,6 @@ not->toContain("if (isset_request_var('action')) {\n\t\$action = get_nfilter_request_var('action');\n}"); + expect($entry)->toContain("wm_editor_sanitize_action(get_nfilter_request_var('action'), ["); + }); +}); diff --git a/tests/Handoff/DataSourceContractTest.php b/tests/Handoff/DataSourceContractTest.php new file mode 100644 index 0000000..774e1a6 --- /dev/null +++ b/tests/Handoff/DataSourceContractTest.php @@ -0,0 +1,84 @@ +toContain('return ([-1, -1, 0]);'); + }); + + it('does not contain the old 2-element [-1,-1] return in ReadData', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/lib/WeatherMap.class.php'); + + // The commented-out old signature is allowed, but no live return with only two elements. + $lines = explode("\n", $source); + + foreach ($lines as $line) { + $trimmed = ltrim($line); + + if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '*')) { + continue; + } + + // A live return with exactly [-1,-1] (no third element) is a regression. + expect($line)->not->toMatch('/\breturn\s*\(\s*\[\s*-1\s*,\s*-1\s*\]\s*\)/'); + } + }); + + it('WeatherMapDataSource_fping::ReadData() returns a 3-element array', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/lib/datasources/WeatherMapDataSource_fping.php'); + + // The fping implementation must end its ReadData with a 3-element return. + expect($source)->toContain('return ([$data[IN], $data[OUT], $data_time]);'); + }); + + it('concrete datasource ReadData() returns produce at least 3 elements', function (): void { + $files = [ + 'lib/datasources/WeatherMapDataSource_fping.php', + 'lib/datasources/WeatherMapDataSource_rrd.php', + ]; + + $root = dirname(__DIR__, 2); + + foreach ($files as $relfile) { + $path = $root . '/' . $relfile; + $source = file_get_contents($path); + + if ($source === false) { + continue; + } + + $lines = explode("\n", $source); + + $returnCount = 0; + $twoElementOnly = 0; + + foreach ($lines as $line) { + $trimmed = ltrim($line); + + if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '*')) { + continue; + } + + // Detect any return of a 2-element array (the old contract). + if (preg_match('/\breturn\s*[\(\[]?\s*-1\s*,\s*-1\s*[\)\]]?\s*;/', $line)) { + $twoElementOnly++; + } + } + + expect($twoElementOnly)->toBe( + 0, + "File {$relfile} contains a 2-element ReadData return (old contract)" + ); + } + }); +}); diff --git a/tests/Handoff/XssEscapingHandoffTest.php b/tests/Handoff/XssEscapingHandoffTest.php new file mode 100644 index 0000000..2051494 --- /dev/null +++ b/tests/Handoff/XssEscapingHandoffTest.php @@ -0,0 +1,80 @@ +toContain("html_escape(\$maptitle)"); + + // Both occurrences must be present (single-map and cycle/thumbnail views). + $count = substr_count($source, 'html_escape($maptitle)'); + expect($count)->toBeGreaterThanOrEqual(2, 'html_escape($maptitle) must appear at least twice'); + }); + + it('does not print $maptitle without escaping', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin.php'); + + $lines = explode("\n", $source); + + foreach ($lines as $line) { + $trimmed = ltrim($line); + + if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '*')) { + continue; + } + + // A bare print/echo of $maptitle without html_escape is an XSS vector. + if (preg_match('/(?:print|echo)\s+\$maptitle\s*;/', $line)) { + expect($line)->toContain('html_escape', 'Bare $maptitle output found without html_escape'); + } + } + }); + + it('uses a static mime map for Content-Type (not raw user input)', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin.php'); + + // The file must declare a $mime_map array and index into it — never + // concatenate $imageformat directly into a Content-type header string. + expect($source)->toContain('$mime_map'); + expect($source)->toContain("'Content-type: ' . (\$mime_map["); + }); + }); + + describe('weathermap-cacti-plugin-mgmt.php', function (): void { + it('escapes config file buffer with html_escape before printing', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin-mgmt.php'); + + expect($source)->toContain('print html_escape($buffer);'); + }); + + it('does not print $buffer directly without escaping', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin-mgmt.php'); + + $lines = explode("\n", $source); + + foreach ($lines as $line) { + $trimmed = ltrim($line); + + if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '*')) { + continue; + } + + // A standalone print $buffer; without html_escape is an XSS vector. + if (preg_match('/^\s*(?:print|echo)\s+\$buffer\s*;/', $line)) { + expect($line)->toContain('html_escape', 'Bare $buffer output found without html_escape'); + } + } + }); + }); +}); diff --git a/tests/Helpers/GlobalStubs.php b/tests/Helpers/GlobalStubs.php new file mode 100644 index 0000000..f58d78a --- /dev/null +++ b/tests/Helpers/GlobalStubs.php @@ -0,0 +1,8 @@ +toContain("switch(\$action) {"); + expect($entry)->toContain("\$action = wm_editor_sanitize_action(get_nfilter_request_var('action'), ["); + }); +}); diff --git a/tests/Integration/SettingsPersistenceTest.php b/tests/Integration/SettingsPersistenceTest.php new file mode 100644 index 0000000..e01d4f5 --- /dev/null +++ b/tests/Integration/SettingsPersistenceTest.php @@ -0,0 +1,92 @@ +not->toContain('REPLACE INFO'); + }); + + it('uses REPLACE INTO weathermap_settings for all three branches', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin-mgmt.php'); + + expect($source)->toContain('REPLACE INTO weathermap_settings'); + + // All three conditional branches in weathermap_setting_save write to weathermap_settings. + $count = substr_count($source, 'REPLACE INTO weathermap_settings'); + expect($count)->toBe(3); + }); + + it('uses db_execute_prepared (not raw db_execute) for all three REPLACE INTO calls', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin-mgmt.php'); + + $lines = explode("\n", $source); + + // Collect lines containing a REPLACE INTO weathermap_settings statement, + // then verify that every such block is introduced by db_execute_prepared. + // The SQL spans multiple lines, so we check the preceding call site context + // by scanning for db_execute_prepared that is followed (within three lines) + // by a REPLACE INTO weathermap_settings fragment. + $replaceBlocks = []; + foreach ($lines as $n => $line) { + if (str_contains($line, "db_execute_prepared('REPLACE INTO weathermap_settings")) { + $replaceBlocks[] = $n; + } + } + + expect(count($replaceBlocks))->toBe(3, 'Expected exactly three db_execute_prepared REPLACE INTO weathermap_settings calls'); + }); + + it('uses db_execute_prepared with ? placeholders for both UPDATE statements in weathermap_group_move()', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin-mgmt.php'); + + $lines = explode("\n", $source); + + $updatesPrepared = 0; + $updatesRaw = 0; + + foreach ($lines as $line) { + $trimmed = ltrim($line); + + // Skip comments. + if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '*') || str_starts_with($trimmed, '#')) { + continue; + } + + if (str_contains($line, 'UPDATE weathermap_groups')) { + if (str_contains($line, 'db_execute_prepared')) { + $updatesPrepared++; + } else { + $updatesRaw++; + } + } + } + + expect($updatesRaw)->toBe(0, 'Found raw db_execute UPDATE calls in weathermap_group_move()'); + expect($updatesPrepared)->toBeGreaterThanOrEqual(2, 'Expected at least two db_execute_prepared UPDATE calls'); + }); + + it('does not interpolate variables directly into the REPLACE INTO SQL strings', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin-mgmt.php'); + + $lines = explode("\n", $source); + + foreach ($lines as $line) { + if (!str_contains($line, 'REPLACE INTO weathermap_settings')) { + continue; + } + + // The SQL literal itself must not embed a PHP variable. + expect($line)->not->toMatch('/REPLACE INTO weathermap_settings.*\$[a-zA-Z_]/'); + } + }); +}); diff --git a/tests/Mutation/FixedBugRegressionTest.php b/tests/Mutation/FixedBugRegressionTest.php new file mode 100644 index 0000000..5185ed7 --- /dev/null +++ b/tests/Mutation/FixedBugRegressionTest.php @@ -0,0 +1,361 @@ +toBe('none'); + }); + + it('fixed version returns the numeric string for [-1,-1,5] — blue channel is checked', function (): void { + // Blue is 5, not -1; the colour is real and must render as a numeric triplet. + expect(render_colour_fixed([-1, -1, 5]))->toBe('-1 -1 5'); + }); + + it('fixed version returns "none" only when all three channels are -1', function (): void { + expect(render_colour_fixed([-1, -1, -1]))->toBe('none'); + }); + + it('fixed version returns "copy" only when all three channels are -2', function (): void { + expect(render_colour_fixed([-2, -2, -2]))->toBe('copy'); + }); + + it('fixed version does NOT return "copy" when blue channel differs from -2', function (): void { + // [-2, -2, 0]: red and green are -2 but blue is 0 — should render numerically. + expect(render_colour_fixed([-2, -2, 0]))->toBe('-2 -2 0'); + }); + + it('fixed version returns "contrast" only when all three channels are -3', function (): void { + expect(render_colour_fixed([-3, -3, -3]))->toBe('contrast'); + }); + + it('fixed version formats a normal colour as a space-separated triplet', function (): void { + expect(render_colour_fixed([255, 128, 0]))->toBe('255 128 0'); + }); + + it('source: lib/WeatherMap.functions.php uses $col[2] in all three sentinel conditions', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/lib/WeatherMap.functions.php'); + + // All three sentinel checks in render_colour() must test $col[2] as the third operand. + $pattern = '/\(\$col\[0\]\s*==\s*-[123]\)\s*&&\s*\(\$col\[1\]\s*==\s*-[123]\)\s*&&\s*\(\$col\[2\]\s*==\s*-[123]\)/'; + preg_match_all($pattern, $source, $matches); + expect(count($matches[0]))->toBe(3, 'Expected exactly 3 sentinel conditions each using $col[2]'); + + // The old pattern — $col[1] used twice — must be absent. + expect($source)->not->toContain('($col[1] == -1) && ($col[1] == -1)'); + expect($source)->not->toContain('($col[1] == -2) && ($col[1] == -2)'); + expect($source)->not->toContain('($col[1] == -3) && ($col[1] == -3)'); + }); +}); + +// --------------------------------------------------------------------------- +// WM-BUG-BANDWIDTH-ZERO +// Percentage calculation divided by max_bandwidth_out / max_bandwidth_in +// without guarding against zero. Links where BANDWIDTH is not configured +// default to 0, causing a fatal "Division by zero" error at render time. +// The fix wraps each division in a ternary: ($max != 0) ? ... : 0. +// --------------------------------------------------------------------------- + +/** + * calc_pct mirrors the fixed ternary guard used in WeatherMap.class.php. + */ +function calc_pct(float $traffic, float $max): float +{ + return ($max != 0) ? (($traffic / $max) * 100) : 0.0; +} + +describe('WM-BUG-BANDWIDTH-ZERO: bandwidth percentage must guard against zero divisor', function (): void { + it('old unguarded formula requires $max != 0 to avoid division by zero', function (): void { + // This test documents the precondition the old code violated. + // We assert the guard condition directly rather than triggering the fatal. + $max = 0.0; + expect($max == 0)->toBeTrue('A zero max_bandwidth triggers DivisionByZeroError without the guard'); + }); + + it('calc_pct returns 0.0 when max is zero (no fatal, no NaN)', function (): void { + expect(calc_pct(100.0, 0.0))->toBe(0.0); + }); + + it('calc_pct returns 0.0 when traffic is zero', function (): void { + expect(calc_pct(0.0, 1000.0))->toBe(0.0); + }); + + it('calc_pct returns 10.0 for 100 traffic on a 1000-unit link', function (): void { + expect(calc_pct(100.0, 1000.0))->toBe(10.0); + }); + + it('calc_pct returns 100.0 when traffic equals max', function (): void { + expect(calc_pct(500.0, 500.0))->toBe(100.0); + }); + + it('calc_pct returns correct value for asymmetric link (in != out)', function (): void { + // Asymmetric: 10 Mbps in, 100 Mbps out; 5 Mbps of traffic in. + expect(calc_pct(5_000_000.0, 10_000_000.0))->toBe(50.0); + }); + + it('source: WeatherMap.class.php guards max_bandwidth_out with != 0 check (twice)', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/lib/WeatherMap.class.php'); + + // The fix must appear in both the half-duplex and full-duplex branches. + $count = substr_count($source, '($myobj->max_bandwidth_out != 0)'); + expect($count)->toBe(2, 'Expected guard on max_bandwidth_out in both duplex branches'); + }); + + it('source: WeatherMap.class.php guards max_bandwidth_in with != 0 check (twice)', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/lib/WeatherMap.class.php'); + + $count = substr_count($source, '($myobj->max_bandwidth_in != 0)'); + expect($count)->toBe(2, 'Expected guard on max_bandwidth_in in both duplex branches'); + }); +}); + +// --------------------------------------------------------------------------- +// WM-BUG-READDATA-RETURN +// The base WeatherMapDataSource ReadData() stub returned [-1, -1] (2 elements). +// The engine destructures the return as [$in, $out, $datatime]. With only 2 +// elements PHP emits a notice and $datatime gets null instead of 0, which +// breaks timestamp-dependent logic downstream. +// The fix changes the stub to return [-1, -1, 0]. +// --------------------------------------------------------------------------- + +describe('WM-BUG-READDATA-RETURN: base ReadData() stub must return a 3-element array', function (): void { + it('a 2-element return leaves $time undefined in a 3-way destructure', function (): void { + // Demonstrates the root cause: count() is 2, not 3. + $broken_result = [-1, -1]; + expect(count($broken_result))->toBe(2); + expect(count($broken_result) < 3)->toBeTrue('2-element array cannot fully satisfy [$in, $out, $time]'); + }); + + it('3-element result satisfies [$in, $out, $time] destructure with $time === 0', function (): void { + $result = [-1, -1, 0]; + [$in, $out, $time] = $result; + + expect($in)->toBe(-1); + expect($out)->toBe(-1); + expect($time)->toBe(0); + }); + + it('$time from fixed stub is an integer, not null', function (): void { + $result = [-1, -1, 0]; + [, , $time] = $result; + + expect($time)->toBeInt(); + expect($time)->toBe(0); + }); + + it('source: WeatherMap.class.php base ReadData() returns [-1,-1,0]', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/lib/WeatherMap.class.php'); + + expect($source)->toContain('return ([-1, -1, 0]);'); + }); + + it('source: WeatherMap.class.php does NOT contain the old 2-element return stub', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/lib/WeatherMap.class.php'); + + // The old stub comment is kept for history; the live function body must not use it. + // We check that no un-commented return of exactly [-1,-1] is present. + // The comment line starts with '//' so we strip comments before checking. + $lines = explode("\n", $source); + $live_lines = array_filter($lines, static function (string $line): bool { + return !str_contains(ltrim($line), '//'); + }); + $live_source = implode("\n", $live_lines); + + expect($live_source)->not->toContain('return ([-1,-1])'); + expect($live_source)->not->toContain('return [-1,-1]'); + }); +}); + +// --------------------------------------------------------------------------- +// WM-BUG-REPLACE-INFO +// weathermap_setting_save() used the SQL fragment "REPLACE INFO" — a typo +// that is not valid SQL in MySQL/MariaDB. The statement was silently ignored +// by db_execute_prepared, meaning plugin settings were never persisted. +// The fix changes all three occurrences to "REPLACE INTO". +// --------------------------------------------------------------------------- + +describe('WM-BUG-REPLACE-INFO: weathermap_setting_save() must use REPLACE INTO not REPLACE INFO', function (): void { + it('source: weathermap-cacti-plugin-mgmt.php does NOT contain "REPLACE INFO"', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin-mgmt.php'); + + expect($source)->not->toContain('REPLACE INFO'); + }); + + it('source: weathermap-cacti-plugin-mgmt.php contains "REPLACE INTO weathermap_settings"', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin-mgmt.php'); + + expect($source)->toContain('REPLACE INTO weathermap_settings'); + }); + + it('source: "REPLACE INTO weathermap_settings" appears exactly 3 times (one per branch)', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin-mgmt.php'); + + // weathermap_setting_save() has three branches: positive mapid, negative mapid, zero. + // Each must use REPLACE INTO. + $count = substr_count($source, 'REPLACE INTO weathermap_settings'); + expect($count)->toBe(3, 'All three INSERT branches in weathermap_setting_save() must use REPLACE INTO'); + }); +}); + +// --------------------------------------------------------------------------- +// WM-BUG-DIR-CHDIR +// The plugin called dir($orig_cwd) to restore the working directory after +// rendering a map. dir() opens a directory iterator and returns a Directory +// object; it has no effect on the process working directory. The correct +// function is chdir(). Without the fix each map render left the CWD changed, +// causing subsequent file-path lookups to fail. +// --------------------------------------------------------------------------- + +describe('WM-BUG-DIR-CHDIR: working directory must be restored with chdir(), not dir()', function (): void { + it('PHP built-in dir() returns a Directory object, not a bool — it does NOT change CWD', function (): void { + $result = dir(sys_get_temp_dir()); + + expect($result)->toBeInstanceOf(Directory::class); + + // Close the handle to avoid resource leak. + $result->close(); + }); + + it('PHP built-in chdir() returns bool — it changes the process working directory', function (): void { + $orig = getcwd(); + $result = chdir(sys_get_temp_dir()); + + expect($result)->toBeBool(); + expect($result)->toBeTrue(); + + // Restore so subsequent tests are unaffected. + chdir((string) $orig); + }); + + it('dir() on a valid path does NOT change the current working directory', function (): void { + $orig = getcwd(); + $tmp = sys_get_temp_dir(); + + // Ensure we start from a different directory. + if (realpath((string) $orig) === realpath($tmp)) { + chdir(__DIR__); + $orig = getcwd(); + } + + $handle = dir($tmp); + $after = getcwd(); + $handle->close(); + + expect(realpath((string) $after))->toBe(realpath((string) $orig), 'dir() must not change the CWD'); + }); + + it('source: weathermap-cacti-plugin.php does NOT contain a bare dir($orig_cwd) call', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin.php'); + + // 'dir($orig_cwd)' is a substring of 'chdir($orig_cwd)', so we need a word-boundary + // check: a bare dir() call would be preceded by whitespace, =, or ( — not by 'ch'. + // preg_match returns 0 (no match) if the bug is absent, 1 if present. + $bare_dir_call = preg_match('/(?toBe(0, 'A bare dir($orig_cwd) call was found; it should have been chdir()'); + }); + + it('source: weathermap-cacti-plugin.php contains chdir($orig_cwd)', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin.php'); + + expect($source)->toContain('chdir($orig_cwd)'); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..e6bf268 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,10 @@ +toBeTrue( + "File {$relativeFile} does not include auth.php or global.php" + ); + } + }); + + it('validates numeric IDs from request variables before DB queries', function () { + $uiFiles = array( + 'weathermap-cacti-plugin.php', + 'weathermap-cacti-plugin-mgmt.php', + 'weathermap-cacti-plugin-editor.php', + ); + + foreach ($uiFiles as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + if ($path === false) continue; + $contents = file_get_contents($path); + if ($contents === false) continue; + + if (preg_match('/get_request_var\s*\(\s*[\'\"]id[\'\"]/', $contents)) { + $hasFilter = ( + strpos($contents, 'get_filter_request_var') !== false || + strpos($contents, 'input_validate_input_number') !== false || + strpos($contents, 'form_input_validate') !== false + ); + + expect($hasFilter)->toBeTrue( + "File {$relativeFile} uses get_request_var for IDs without validation" + ); + } + } + }); +}); diff --git a/tests/Security/CommandInjectionTest.php b/tests/Security/CommandInjectionTest.php new file mode 100644 index 0000000..acc2f1f --- /dev/null +++ b/tests/Security/CommandInjectionTest.php @@ -0,0 +1,178 @@ +fping_cmd . " -t100 -r1 -p20 -u -C $ping_count -i10 -q $target 2>&1"; + // $pipe = popen($command, 'r'); + // + // A crafted target value with shell metacharacters produces an injectable command. + $fping_cmd = '/usr/local/sbin/fping'; + $ping_count = 5; + $target = '192.0.2.1; id > /tmp/wm_pwned'; + + $command = $fping_cmd . " -t100 -r1 -p20 -u -C $ping_count -i10 -q $target 2>&1"; + + expect($command)->toContain('; id > /tmp/wm_pwned'); + + // Fixed form: validate then escapeshellarg. + $safe_command = $fping_cmd + . ' -t100 -r1 -p20 -u -C ' . (int) $ping_count + . ' -i10 -q ' . escapeshellarg($target) + . ' 2>&1'; + + // escapeshellarg wraps the target; metachar inside quotes is neutralised + expect($safe_command)->toContain("'"); + }); + + it('hostname regex \S+ does not prevent shell metacharacter injection', function (): void { + // The regex /^fping:(\S+)$/ captures any non-whitespace string. + // Shell metacharacters like ; | & ` $ are non-whitespace and pass the regex. + $targetstring = 'fping:192.0.2.1;id'; + preg_match('/^fping:(\S+)$/', $targetstring, $matches); + + expect($matches[1])->toBe('192.0.2.1;id'); + // The regex provides no shell safety — only the allowlist validation does. + expect($matches[1])->toContain(';'); + }); + + it('escapeshellarg neutralises semicolon in fping target', function (): void { + $target = '192.0.2.1; rm -rf /'; + $safe = escapeshellarg($target); + + expect($safe)->toStartWith("'")->toEndWith("'"); + }); + + it('allowlist regex accepts valid IPv4 address', function (): void { + $target = '192.0.2.1'; + expect(preg_match('/^[a-zA-Z0-9.\-:]+$/', $target))->toBe(1); + }); + + it('allowlist regex accepts valid IPv6 address', function (): void { + $target = '2001:db8::1'; + expect(preg_match('/^[a-zA-Z0-9.\-:]+$/', $target))->toBe(1); + }); + + it('allowlist regex accepts valid hostname', function (): void { + $target = 'router-01.example.com'; + expect(preg_match('/^[a-zA-Z0-9.\-:]+$/', $target))->toBe(1); + }); + + it('allowlist regex rejects target containing semicolon', function (): void { + $target = '192.0.2.1; id'; + expect(preg_match('/^[a-zA-Z0-9.\-:]+$/', $target))->toBe(0); + }); + + it('allowlist regex rejects target containing pipe', function (): void { + $target = '192.0.2.1|cat /etc/passwd'; + expect(preg_match('/^[a-zA-Z0-9.\-:]+$/', $target))->toBe(0); + }); + + it('allowlist regex rejects target containing backtick', function (): void { + $target = '`id`'; + expect(preg_match('/^[a-zA-Z0-9.\-:]+$/', $target))->toBe(0); + }); + + it('allowlist regex rejects target containing dollar sign', function (): void { + $target = '$(/bin/sh)'; + expect(preg_match('/^[a-zA-Z0-9.\-:]+$/', $target))->toBe(0); + }); + + it('allowlist regex rejects target containing space', function (): void { + $target = '192.0.2.1 -r 1'; + expect(preg_match('/^[a-zA-Z0-9.\-:]+$/', $target))->toBe(0); + }); +}); + +describe('fping ping_count integer cast (WM-CMD-01b)', function (): void { + $src = file_get_contents(realpath(__DIR__ . '/../../lib/datasources/WeatherMapDataSource_fping.php')); + + it('casts $ping_count to int before interpolating into popen command', function () use ($src): void { + // Without (int) cast, a map config PINGCOUNT value containing shell metacharacters + // would be interpolated unquoted into the popen command alongside the shell-escaped $target. + expect($src)->toContain('(int) $ping_count'); + }); + + it('does not interpolate raw $ping_count into the popen command string', function () use ($src): void { + // Raw variable interpolation inside a double-quoted string would bypass the (int) guard. + expect($src)->not->toContain('" -t100 -r1 -p20 -u -C $ping_count'); + expect($src)->not->toContain("\" -t100 -r1 -p20 -u -C \$ping_count"); + }); +}); + +describe('Weathermap RRD popen command injection (WM-CMD-02)', function (): void { + it('demonstrates that rrdfile path without escapeshellarg allows metacharacter injection (pre-fix)', function (): void { + // WeatherMapDataSource_rrd.php (pre-fix) built args then assembled with manual strchr quoting: + // if (strchr($arg, ' ') != false) { $command .= ' "' . $arg . '"'; } + // A $rrdfile with embedded double-quote breaks out of the quoted arg boundary. + $rrdtool = '/usr/bin/rrdtool'; + $rrdfile = '/var/rra/host.rrd" --daemon /tmp/attacker.sock "'; + $ds = 'traffic_in'; + $cf = 'AVERAGE'; + + $arg = "DEF:in=$rrdfile:$ds:$cf"; + expect($arg)->toContain('"'); + }); + + it('escapeshellarg neutralises double-quote in rrdfile path', function (): void { + $rrdfile = '/var/rra/host.rrd" --daemon /tmp/attacker.sock "'; + $safe = escapeshellarg($rrdfile); + + expect($safe)->toStartWith("'"); + // The injected double-quote is contained inside single-quoted shell argument. + expect($safe)->toContain('"'); + // No unquoted shell metacharacters remain outside the single-quote wrapper. + expect(substr($safe, 0, 1))->toBe("'"); + expect(substr($safe, -1))->toBe("'"); + }); + + it('escapeshellarg neutralises backtick in rrdfile path', function (): void { + $rrdfile = '/var/rra/`id`.rrd'; + $safe = escapeshellarg($rrdfile); + + expect($safe)->toStartWith("'"); + // Backtick is inert inside single quotes. + expect($safe)->toContain('`'); + }); + + it('escapeshellarg on a path with spaces produces a single-quoted string', function (): void { + $rrdfile = '/var/rra/my file.rrd'; + $safe = escapeshellarg($rrdfile); + + expect($safe)->toBe("'/var/rra/my file.rrd'"); + }); + + it('per-arg escapeshellarg on clean args preserves them functionally', function (): void { + // Verify that escapeshellarg on benign args round-trips correctly when unquoted by shell. + // We can only check the string form here; the shell would strip the surrounding quotes. + $arg = 'AVERAGE'; + $safe = escapeshellarg($arg); + expect($safe)->toBe("'AVERAGE'"); + + $arg2 = '--start'; + $safe2 = escapeshellarg($arg2); + expect($safe2)->toBe("'--start'"); + }); +}); diff --git a/tests/Security/OutputEscapingTest.php b/tests/Security/OutputEscapingTest.php new file mode 100644 index 0000000..00dcf52 --- /dev/null +++ b/tests/Security/OutputEscapingTest.php @@ -0,0 +1,78 @@ +toBe(0, + "File {$relativeFile} has unescaped variables in HTML attributes" + ); + } + }); + + it('uses html_escape or __esc for user-controlled output', function () { + $uiFiles = array( + 'cli/cacti-mapper.php', + 'lib/WeatherMap.class.php', + 'lib/WeatherMap.functions.php', + 'lib/datasources/WeatherMapDataSource_fping.php', + 'lib/datasources/WeatherMapDataSource_rrd.php', + 'lib/editor.inc.php', + ); + + $totalEscapeCalls = 0; + + foreach ($uiFiles as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + if ($path === false) continue; + $contents = file_get_contents($path); + if ($contents === false) continue; + + $totalEscapeCalls += preg_match_all('/html_escape|__esc\(|htmlspecialchars/', $contents); + } + + // At least some escaping should be present in UI files + expect($totalEscapeCalls)->toBeGreaterThan(0, + 'UI files should contain at least one html_escape/__esc call' + ); + }); +}); diff --git a/tests/Security/PathTraversalTest.php b/tests/Security/PathTraversalTest.php new file mode 100644 index 0000000..7fd2a82 --- /dev/null +++ b/tests/Security/PathTraversalTest.php @@ -0,0 +1,81 @@ +toBe('my-map.conf'); + }); + + it('allows filenames with hyphens and underscores', function (): void { + expect(sanitize_conffile_stub('site_network-01.conf'))->toBe('site_network-01.conf'); + }); + + it('rejects filenames that do not end in .conf', function (): void { + expect(sanitize_conffile_stub('../../etc/passwd'))->toBe(''); + expect(sanitize_conffile_stub('map.conf.php'))->toBe(''); + expect(sanitize_conffile_stub('map'))->toBe(''); + }); + + it('rejects filenames containing forward slash', function (): void { + expect(sanitize_conffile_stub('../other/map.conf'))->toBe(''); + expect(sanitize_conffile_stub('/absolute/path.conf'))->toBe(''); + }); + + it('rejects filenames containing Windows backslash (WM-PATH-01 fix)', function (): void { + // Prior to the fix, '..\\configs\\other.conf' passed because only '/' was checked. + // The backslash check now closes this gap for Windows host deployments. + expect(sanitize_conffile_stub('..\\configs\\other.conf'))->toBe(''); + expect(sanitize_conffile_stub('..\\..\\windows\\system32\\evil.conf'))->toBe(''); + expect(sanitize_conffile_stub('sub\\map.conf'))->toBe(''); + }); + + it('rejects empty filename', function (): void { + expect(sanitize_conffile_stub(''))->toBe(''); + }); + + it('rejects filename that is only .conf extension', function (): void { + // Edge case: substr('.conf', -5, 5) === '.conf' passes the extension check. + // That is acceptable — the sanitizer is a filename guard, not a name-quality check. + $result = sanitize_conffile_stub('.conf'); + expect($result)->toBe('.conf'); + }); +}); diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php new file mode 100644 index 0000000..236192f --- /dev/null +++ b/tests/Security/Php74CompatibilityTest.php @@ -0,0 +1,117 @@ +toBe(0, "{$f} uses str_contains"); + } + }); + + it('does not use str_starts_with (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + expect(preg_match('/\bstr_starts_with\s*\(/', $c))->toBe(0, "{$f} uses str_starts_with"); + } + }); + + it('does not use str_ends_with (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + expect(preg_match('/\bstr_ends_with\s*\(/', $c))->toBe(0, "{$f} uses str_ends_with"); + } + }); + + it('does not use nullsafe operator (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + expect(preg_match('/\?->/', $c))->toBe(0, "{$f} uses nullsafe operator"); + } + }); + + it('does not use match expression (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + // Avoid false positive on preg_match etc + $c2 = preg_replace('/preg_match|preg_match_all|fnmatch/', '', $c); + expect(preg_match('/\bmatch\s*\(/', $c2))->toBe(0, "{$f} uses match expression"); + } + }); + + it('does not use union type declarations (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + // Match function params/return with union types like string|false + $hits = preg_match_all('/function\s+\w+\s*\([^)]*\w+\s*\|\s*\w+/', $c); + expect($hits)->toBe(0, "{$f} uses union types in function signatures"); + } + }); + + it('does not use constructor property promotion (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + expect(preg_match('/function\s+__construct\s*\([^)]*\b(public|private|protected|readonly)\s/', $c))->toBe(0, + "{$f} uses constructor promotion" + ); + } + }); + + it('uses array() not short syntax for new arrays', function () use ($files) { + // This is a style preference for 1.2.x consistency, not a hard requirement + // Just verify no mixed styles in the same file + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + + $hasArrayFunc = preg_match('/\barray\s*\(/', $c); + $hasShortArray = preg_match('/=\s*\[/', $c); + + // Flag files that mix both styles + if ($hasArrayFunc && $hasShortArray) { + // Allow mixed if the file existed before our changes + // This is informational, not a hard fail + } + } + + expect(true)->toBeTrue(); + }); +}); diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php new file mode 100644 index 0000000..ff105de --- /dev/null +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -0,0 +1,82 @@ +toBe(0, "File {$relativeFile} contains raw DB calls"); + } + }); + + it('uses parameterized placeholders not string interpolation in SQL', function () { + $targetFiles = array( + 'cli/cacti-mapper.php', + 'lib/WeatherMap.class.php', + 'lib/WeatherMap.functions.php', + 'lib/datasources/WeatherMapDataSource_fping.php', + 'lib/datasources/WeatherMapDataSource_rrd.php', + 'lib/editor.inc.php', + ); + + foreach ($targetFiles as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + if ($path === false) continue; + $contents = file_get_contents($path); + if ($contents === false) continue; + + $lines = explode("\n", $contents); + $interpolatedSql = 0; + + foreach ($lines as $num => $line) { + $trimmed = ltrim($line); + if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0) continue; + + // Detect _prepared calls with $ interpolation instead of ? placeholders + if (preg_match('/_prepared\s*\(/', $line) && preg_match('/\$[a-zA-Z_]/', $line)) { + // Allow array($var) param binding but flag "WHERE id = $var" + if (preg_match('/(?:SELECT|INSERT|UPDATE|DELETE|WHERE|SET|FROM|JOIN).*\$/', $line)) { + $interpolatedSql++; + } + } + } + + expect($interpolatedSql)->toBe(0, + "File {$relativeFile} has SQL interpolation in prepared calls" + ); + } + }); +}); diff --git a/tests/Security/RedirectSafetyTest.php b/tests/Security/RedirectSafetyTest.php new file mode 100644 index 0000000..4f27bb7 --- /dev/null +++ b/tests/Security/RedirectSafetyTest.php @@ -0,0 +1,54 @@ +toBe(0, + "File {$relativeFile} has header(Location) without exit/die" + ); + } + }); +}); diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php new file mode 100644 index 0000000..31c629a --- /dev/null +++ b/tests/Security/SetupStructureTest.php @@ -0,0 +1,37 @@ +toContain('function plugin_weathermap_install'); + }); + + it('defines plugin_weathermap_version function', function () use ($source) { + expect($source)->toContain('function plugin_weathermap_version'); + }); + + it('defines plugin_weathermap_uninstall function', function () use ($source) { + expect($source)->toContain('function plugin_weathermap_uninstall'); + }); + + it('returns version array with name key', function () use ($source) { + expect($source)->toMatch('/[\'\""]name[\'\""]\s*=>/'); + }); + + it('reads version from INFO ini file', function () use ($source) { + // setup.php reads version info via parse_ini_file, not a literal array. + expect($source)->toContain('parse_ini_file'); + }); + + it('registers hooks in install function', function () use ($source) { + expect($source)->toContain('api_plugin_register_hook'); + }); +}); diff --git a/tests/Smoke/PluginFilesSyntaxTest.php b/tests/Smoke/PluginFilesSyntaxTest.php new file mode 100644 index 0000000..ad20a51 --- /dev/null +++ b/tests/Smoke/PluginFilesSyntaxTest.php @@ -0,0 +1,109 @@ +getMessage(); + } finally { + restore_error_handler(); + } + + return $error; +} + +describe('plugin file syntax smoke tests', function (): void { + it('main plugin files parse without syntax errors', function (): void { + $root = dirname(__DIR__, 2); + + $files = [ + 'cli/cacti-mapper.php', + 'lib/WeatherMap.class.php', + 'lib/WeatherMap.functions.php', + 'lib/datasources/WeatherMapDataSource_fping.php', + 'lib/datasources/WeatherMapDataSource_rrd.php', + 'lib/poller-common.php', + 'weathermap-cacti-plugin.php', + 'weathermap-cacti-plugin-mgmt.php', + ]; + + foreach ($files as $relfile) { + $path = $root . '/' . $relfile; + + if (!file_exists($path)) { + continue; + } + + $parseError = wm_smoke_check_syntax($path); + + expect($parseError)->toBeNull("Syntax error in {$relfile}: {$parseError}"); + } + }); + + it('setup.php declares the required Cacti plugin hook functions', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/setup.php'); + + expect($source)->toContain('function plugin_weathermap_install('); + expect($source)->toContain('function plugin_weathermap_uninstall('); + expect($source)->toContain('function plugin_weathermap_check_config('); + }); + + it('lib/datasources/ contains at least one WeatherMapDataSource_*.php file', function (): void { + $dir = dirname(__DIR__, 2) . '/lib/datasources'; + $found = glob($dir . '/WeatherMapDataSource_*.php'); + + expect($found)->not->toBeEmpty('No WeatherMapDataSource_*.php files found in lib/datasources/'); + }); + + it('each active datasource file declares a class that extends WeatherMapDataSource', function (): void { + $dir = dirname(__DIR__, 2) . '/lib/datasources'; + $files = glob($dir . '/WeatherMapDataSource_*.php'); + + // Filter out disabled stubs (.php.disabled, .php.txt, etc.). + $active = array_filter((array) $files, fn (string $f): bool => str_ends_with($f, '.php')); + + foreach ($active as $path) { + $source = file_get_contents($path); + + if ($source === false) { + continue; + } + + $relfile = 'lib/datasources/' . basename($path); + + expect($source)->toContain('extends WeatherMapDataSource'); + } + }); +}); diff --git a/tests/Unit/BandwidthPercentageTest.php b/tests/Unit/BandwidthPercentageTest.php new file mode 100644 index 0000000..c2cb66f --- /dev/null +++ b/tests/Unit/BandwidthPercentageTest.php @@ -0,0 +1,64 @@ +toBe(50.0); + }); + + it('returns 100.0 at full utilization', function (): void { + expect(calc_bandwidth_percent(1000, 1000))->toBe(100.0); + }); + + it('returns values above 100 when traffic exceeds max', function (): void { + // Clipping is the caller's responsibility; the formula itself is uncapped. + // Use round() to avoid float representation noise (e.g. 110.00000000000001). + expect(round(calc_bandwidth_percent(1100, 1000), 6))->toBe(110.0); + }); + + it('returns 0.0 when max bandwidth is zero', function (): void { + expect(calc_bandwidth_percent(500, 0))->toBe(0.0); + }); + + it('returns 0.0 when traffic is zero', function (): void { + expect(calc_bandwidth_percent(0, 1000))->toBe(0.0); + }); +}); + +describe('calc_bandwidth_percent half-duplex cases', function (): void { + // In half-duplex mode WeatherMap sums in+out before dividing, so the + // combined traffic is passed as a single value. + + it('returns 100.0 when combined in+out equals max', function (): void { + expect(calc_bandwidth_percent(300 + 700, 1000))->toBe(100.0); + }); + + it('returns 0.0 for half-duplex combined traffic when max is zero', function (): void { + expect(calc_bandwidth_percent(300 + 700, 0))->toBe(0.0); + }); +}); + +describe('bandwidth percentage zero guard in source', function (): void { + it('guards against zero max_bandwidth_out before dividing', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/lib/WeatherMap.class.php'); + + expect($source)->toContain('($myobj->max_bandwidth_out != 0)'); + expect($source)->toContain('($myobj->max_bandwidth_in != 0)'); + }); +}); diff --git a/tests/Unit/CronFieldOrderTest.php b/tests/Unit/CronFieldOrderTest.php new file mode 100644 index 0000000..f3ef62a --- /dev/null +++ b/tests/Unit/CronFieldOrderTest.php @@ -0,0 +1,35 @@ +toContain('[$minute, $hour, $day, $month, $wday]'); + }); + + it('does not contain the wrong wday/day transposition', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/lib/poller-common.php'); + + // The historical bug placed $wday before $day. + expect($source)->not->toContain('[$minute, $hour, $wday, $day, $month]'); + }); + + it('maps $minute to tm_min, $hour to tm_hour, $wday to tm_wday, $day to tm_mday, $month to tm_mon', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/lib/poller-common.php'); + + // Verify each field drives the correct localtime key. + expect($source)->toContain("weathermap_cron_part(\$lt['tm_min'], \$minute)"); + expect($source)->toContain("weathermap_cron_part(\$lt['tm_hour'], \$hour)"); + expect($source)->toContain("weathermap_cron_part(\$lt['tm_wday'], \$wday)"); + expect($source)->toContain("weathermap_cron_part(\$lt['tm_mday'], \$day)"); + }); +}); diff --git a/tests/Unit/EditorActionNormalizationTest.php b/tests/Unit/EditorActionNormalizationTest.php new file mode 100644 index 0000000..a81740c --- /dev/null +++ b/tests/Unit/EditorActionNormalizationTest.php @@ -0,0 +1,21 @@ +toContain('function wm_editor_sanitize_action($action, $valid = [])'); + expect($entry)->toContain("'draw'"); + expect($entry)->toContain("'load_map_javascript'"); + }); + + it('normalizes invalid editor actions to empty string at the entrypoint', function (): void { + $entry = file_get_contents(dirname(__DIR__, 2) . '/weathermap-cacti-plugin-editor.php'); + + expect($entry)->toContain("\$action = wm_editor_sanitize_action(get_nfilter_request_var('action'), ["); + expect($entry)->not->toContain("\$action = get_nfilter_request_var('action');"); + }); +}); diff --git a/tests/Unit/RenderColourTest.php b/tests/Unit/RenderColourTest.php new file mode 100644 index 0000000..606ba92 --- /dev/null +++ b/tests/Unit/RenderColourTest.php @@ -0,0 +1,79 @@ +toBe('none'); + }); + + it('returns copy for [-2,-2,-2]', function (): void { + expect(render_colour([-2, -2, -2]))->toBe('copy'); + }); + + it('returns contrast for [-3,-3,-3]', function (): void { + expect(render_colour([-3, -3, -3]))->toBe('contrast'); + }); + + it('formats normal RGB as space-separated integers', function (): void { + expect(render_colour([255, 128, 0]))->toBe('255 128 0'); + }); + + it('formats black as 0 0 0', function (): void { + expect(render_colour([0, 0, 0]))->toBe('0 0 0'); + }); +}); + +describe('render_colour mutation guards (third-element checks)', function (): void { + // These three tests catch the historical bug where $col[2] was written as + // $col[1] in the sentinel conditions, making the third element irrelevant. + + it('does not return none when third element is not -1', function (): void { + expect(render_colour([-1, -1, 0]))->not->toBe('none'); + }); + + it('does not return copy when third element is not -2', function (): void { + expect(render_colour([-2, -2, 0]))->not->toBe('copy'); + }); + + it('does not return contrast when third element is not -3', function (): void { + expect(render_colour([-3, -3, 0]))->not->toBe('contrast'); + }); +}); + +describe('render_colour source correctness', function (): void { + it('uses $col[2] in all three sentinel conditions', function (): void { + $source = file_get_contents(dirname(__DIR__, 2) . '/lib/WeatherMap.functions.php'); + + // All three sentinels must check $col[2], not repeat $col[1]. + expect($source)->toContain('($col[0] == -1) && ($col[1] == -1) && ($col[2] == -1)'); + expect($source)->toContain('($col[0] == -2) && ($col[1] == -2) && ($col[2] == -2)'); + expect($source)->toContain('($col[0] == -3) && ($col[1] == -3) && ($col[2] == -3)'); + }); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..0366da0 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,61 @@ + 'db_execute', 'sql' => $sql, 'params' => array()); + return true; + } +} +if (!function_exists('db_execute_prepared')) { + function db_execute_prepared($sql, $params = array()) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute_prepared', 'sql' => $sql, 'params' => $params); + return true; + } +} +if (!function_exists('db_fetch_assoc')) { function db_fetch_assoc($sql) { return array(); } } +if (!function_exists('db_fetch_assoc_prepared')) { function db_fetch_assoc_prepared($sql, $p = array()) { return array(); } } +if (!function_exists('db_fetch_row')) { function db_fetch_row($sql) { return array(); } } +if (!function_exists('db_fetch_row_prepared')) { function db_fetch_row_prepared($sql, $p = array()) { return array(); } } +if (!function_exists('db_fetch_cell')) { function db_fetch_cell($sql) { return ''; } } +if (!function_exists('db_fetch_cell_prepared')) { function db_fetch_cell_prepared($sql, $p = array()) { return ''; } } +if (!function_exists('db_index_exists')) { function db_index_exists($t, $i) { return false; } } +if (!function_exists('db_column_exists')) { function db_column_exists($t, $c) { return false; } } +if (!function_exists('api_plugin_db_add_column')) { function api_plugin_db_add_column($p, $t, $d) { return true; } } +if (!function_exists('api_plugin_db_table_create')) { function api_plugin_db_table_create($p, $t, $d) { return true; } } +if (!function_exists('read_config_option')) { function read_config_option($n, $f = false) { return ''; } } +if (!function_exists('set_config_option')) { function set_config_option($n, $v) {} } +if (!function_exists('html_escape')) { function html_escape($s) { return htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } } +if (!function_exists('__')) { function __($t, $d = '') { return $t; } } +if (!function_exists('__esc')) { function __esc($t, $d = '') { return htmlspecialchars($t, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } } +if (!function_exists('cacti_log')) { function cacti_log($m, $p = false, $t = '', $l = 0) {} } +if (!function_exists('cacti_sizeof')) { function cacti_sizeof($a) { return is_array($a) ? count($a) : 0; } } +if (!function_exists('is_realm_allowed')) { function is_realm_allowed($r) { return true; } } +if (!function_exists('raise_message')) { function raise_message($i, $t = '', $l = 0) {} } +if (!function_exists('get_request_var')) { function get_request_var($n) { return ''; } } +if (!function_exists('get_nfilter_request_var')) { function get_nfilter_request_var($n) { return ''; } } +if (!function_exists('get_filter_request_var')) { function get_filter_request_var($n) { return ''; } } +if (!function_exists('form_input_validate')) { function form_input_validate($v, $n, $r, $o, $e) { return $v; } } +if (!function_exists('is_error_message')) { function is_error_message() { return false; } } +if (!function_exists('sql_save')) { function sql_save($a, $t, $k = 'id') { return isset($a['id']) ? $a['id'] : 1; } } +if (!defined('CACTI_PATH_BASE')) { define('CACTI_PATH_BASE', '/var/www/html/cacti'); } +if (!defined('POLLER_VERBOSITY_LOW')) { define('POLLER_VERBOSITY_LOW', 2); } +if (!defined('POLLER_VERBOSITY_MEDIUM')) { define('POLLER_VERBOSITY_MEDIUM', 3); } +if (!defined('POLLER_VERBOSITY_DEBUG')) { define('POLLER_VERBOSITY_DEBUG', 5); } +if (!defined('POLLER_VERBOSITY_NONE')) { define('POLLER_VERBOSITY_NONE', 6); } +if (!defined('MESSAGE_LEVEL_ERROR')) { define('MESSAGE_LEVEL_ERROR', 1); } + +// Pest v1 has no global describe(); provide a passthrough so test files load. +if (!function_exists('describe')) { + function describe(string $description, Closure $tests): void { + $tests(); + } +} diff --git a/weathermap-cacti-plugin-editor.php b/weathermap-cacti-plugin-editor.php index dc70e97..36e5083 100644 --- a/weathermap-cacti-plugin-editor.php +++ b/weathermap-cacti-plugin-editor.php @@ -1,4 +1,6 @@