Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 25 additions & 17 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,26 @@ When generating code for this repository:
## Project Structure

```
gpsmap/ # Repository root (install to plugins/gpsmap/ in Cacti)
├── class/ # Supporting PHP classes
gpsmap/ # Repository root (install to plugins/gpsmap/ in Cacti)
├── class/ # Supporting PHP classes
├── includes/
│ ├── setup/
│ │ ├── tabs.php # top_header_tabs / top_graph_header_tabs callback
│ │ ├── settings.php # config_arrays/config_settings/draw_navigation_text/api_device_save
│ │ └── database.php # gpsmap_setup_database() / gpsmap_upgrade_database()
│ └── polling.php # poller_bottom callback, writes map XML/KML/HTML
├── images/ # Marker icons (Green/Orange/Red)
├── js/ # GPSMaps.js, infobubble.js
├── locales/ # Internationalization files
├── tests/ # Test suite
├── XML/ # Poller-generated map artifacts (all.xml, all.kml, all-top.html)
├── gpsmap.php # Main map view page
│ │ ├── tabs.php # top_header_tabs / top_graph_header_tabs callback
│ │ ├── settings.php # config_arrays/config_settings/draw_navigation_text/api_device_save
│ │ └── database.php # gpsmap_setup_database() / gpsmap_upgrade_database()
│ └── polling.php # poller_bottom callback, writes map XML/KML/HTML
├── images/ # Marker icons (Green/Orange/Red)
├── js/ # GPSMaps.js, infobubble.js
├── locales/ # Internationalization files
├── tests/ # Test suite
├── XML/ # Poller-generated map artifacts (all.xml, all.kml, all-top.html)
├── gpsmap.php # Main map view page
├── gpsmap_security.php # Access/permission helpers
├── gpstemplates.php # Map template administration
├── print.php # Print-friendly map view
├── INFO # Plugin metadata (name, version, compat)
├── gpstemplates.php # Map template administration
├── print.php # Print-friendly map view
├── INFO # Plugin metadata (name, version, compat)
├── README.md
└── setup.php # Plugin install/uninstall/upgrade hooks
└── setup.php # Plugin install/uninstall/upgrade hooks
```

## Naming Conventions
Expand Down Expand Up @@ -134,7 +134,7 @@ function gpsmap_check_upgrade() {
$old = read_config_option('plugin_gpsmap_version', TRUE);

if ($current != $old) {
include_once($config['base_path'] . '/plugins/gpsmap/includes/setup/database.php');
require_once($config['base_path'] . '/plugins/gpsmap/includes/setup/database.php');
gpsmap_upgrade_database();
}
}
Expand Down Expand Up @@ -244,6 +244,10 @@ existing code or adding new code, not just in dedicated cleanup passes:
- **i18n text domain.** Every `__()`/`__esc()` call must include this plugin's text domain as the
final argument, except when deliberately comparing against a literal, untranslated Cacti-core
label.
- **File inclusion uses `require`/`require_once`.** Always use `require`/`require_once` (never
`include`/`include_once`) so a missing dependency fails fast and loudly. This plugin already keeps
Comment thread
TheWitness marked this conversation as resolved.
its library/helper code under `includes/` (schema in `includes/setup/database.php`); reference
plugin files from there.
- **Plugin table-creation API.** Use `api_plugin_db_table_create()`/`api_plugin_db_add_column()`
(from Cacti core's `lib/plugins.php`) instead of raw `CREATE TABLE`/`ALTER TABLE ... ADD COLUMN`.
Both are idempotent (safe no-ops when already applied), so the same call can run unconditionally
Expand All @@ -252,3 +256,7 @@ existing code or adding new code, not just in dedicated cleanup passes:
line, `@param` lines, a blank comment line, then `@return`. Infer parameter/return types from
actual usage; don't change the function's real type-hints in the same pass (let static analysis
flag mismatches separately). Skip vendored third-party library files.

## File manifest & upgrade pruning

The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `phpunit.xml`, `.git*`, `.md*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `gpsmap_prune_files()` deletes the tombstoned paths, the dev-only `tests/` tree, and the `phpunit.xml` test config, leaves `whitelist`, `.git*`, and `.md*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change.
3 changes: 3 additions & 0 deletions .github/workflows/plugin-ci-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ jobs:
- name: Check PHP version
run: php -v

- name: Validate plugin manifest (expected-file drift)
run: php cacti/plugins/gpsmap/tests/bin/validate-manifest.php

- name: Run apt-get update
run: sudo apt-get update

Expand Down
3 changes: 0 additions & 3 deletions .mdlrc

This file was deleted.

1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## Changelog
--- develop ---
* refactor: Switch every file inclusion from include/include_once to require/require_once for fail-fast consistency (schema already lives in includes/setup/database.php via the plugin table API, so no schema changes were needed)
* dev: Measure CI coverage with xdebug instead of pcov so the plugin's own sources are instrumented (pcov auto-scopes to the Composer root and skipped cacti/plugins/, leaving the patch-coverage gate with nothing to measure)
* dev: Enforce patch coverage of changed lines in CI and remove the inert COMPOSER_ROOT_VERSION env from the Pest step
* security: Add a version-safe CSP nonce (`plugin_gpsmap_csp_nonce()`) to every inline `<script>` tag, route the plugin's own external JavaScript through Cacti's `get_md5_include_js()` helper, and nonce the Google Maps API tag, so pages stay compatible with Cacti's Content-Security-Policy nonce enforcement while falling back cleanly on older Cacti releases that lack the `CactiSecureHeaders` class
Expand Down
2 changes: 1 addition & 1 deletion INFO
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ longname = Maps Plugin
author = Andrew Aloia, Wixiweb, The Cacti Group
email =
homepage = http://www.cacti.net
compat = 1.2.15
compat = 1.2.29
capabilities = online_view:1, online_mgmt:0, offline_view:0, offline_mgmt:0, remote_collect:0
10 changes: 5 additions & 5 deletions gpsmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@
*/

chdir('../../');
include('./include/auth.php');
require('./include/auth.php');
require_once('./plugins/gpsmap/gpsmap_security.php');
include_once('./plugins/gpsmap/includes/setup/show.php');
include_once('./plugins/gpsmap/includes/setup/gpsmapinitial.php');
require_once('./plugins/gpsmap/includes/setup/show.php');
require_once('./plugins/gpsmap/includes/setup/gpsmapinitial.php');
$body = '';

general_header();
Expand Down Expand Up @@ -87,8 +87,8 @@
gpsmap.downloadURL = <?php print json_encode($config['url_path'] . 'plugins/gpsmap/XML/' . $parameter . '.xml', JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_UNESCAPED_SLASHES); ?>;
gpsmap.t_error = <?php print is_finite((float) $terror) ? json_encode((float) $terror) : '0'; ?>;

<?php include_once('plugins/gpsmap/includes/icons.php'); ?>
<?php include_once('plugins/gpsmap/includes/customicons.php'); ?>
<?php require_once('plugins/gpsmap/includes/icons.php'); ?>
<?php require_once('plugins/gpsmap/includes/customicons.php'); ?>

window.onresize = gpsmap.resize;

Expand Down
4 changes: 2 additions & 2 deletions gpstemplates.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
*/

chdir('../../');
include_once('./include/auth.php');
include_once('./plugins/gpsmap/gpsmap_security.php');
require_once('./include/auth.php');
require_once('./plugins/gpsmap/gpsmap_security.php');

$ds_actions = [
1 => __('Delete')
Expand Down
5 changes: 3 additions & 2 deletions includes/setup/database.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@
function gpsmap_upgrade_database(string $old = '', bool $force = false): void {
global $config;

include_once($config['library_path'] . '/database.php');
require_once($config['library_path'] . '/database.php');


$v = plugin_gpsmap_version();

Expand Down Expand Up @@ -102,7 +103,7 @@ function gpsmap_upgrade_database(string $old = '', bool $force = false): void {
}
}

include_once($config['base_path'] . '/plugins/gpsmap/setup.php');
require_once($config['base_path'] . '/plugins/gpsmap/setup.php');

/* Recorded last, and only when every migration reported success.
* gpsmap_check_upgrade() gates on this option, so writing it earlier would
Expand Down
24 changes: 24 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"tombstones": [],
"expected": [
"CHANGELOG.md",
"INFO",
"LICENSE",
"README.md",
"XML/",
"class/",
"gpsmap.php",
"gpsmap_dns.php",
"gpsmap_security.php",
"gpstemplates.php",
"images/",
"includes/",
"index.php",
"js/",
"locales/",
"manifest.json",
"print.php",
"setup.php"
],
"whitelist": []
}
175 changes: 173 additions & 2 deletions setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ function plugin_gpsmap_install() {
api_plugin_register_realm('gpsmap', 'gpstemplates.php,gpstemplates_add.php',__('Configure Maps', 'gpsmap'), 1);
api_plugin_register_realm('gpsmap', 'gpsmap.php', __('View Maps', 'gpsmap'), 1);

include_once($config['base_path'] . '/plugins/gpsmap/includes/setup/database.php');
require_once($config['base_path'] . '/plugins/gpsmap/includes/setup/database.php');

if (gpsmap_setup_database()) {
$info = plugin_gpsmap_version();
Expand Down Expand Up @@ -183,8 +183,9 @@ function gpsmap_check_upgrade(bool $force = false) {
$old = read_config_option('plugin_gpsmap_version', true);

if ($current != $old) {
include_once($config['base_path'] . '/plugins/gpsmap/includes/setup/database.php');
require_once($config['base_path'] . '/plugins/gpsmap/includes/setup/database.php');
gpsmap_upgrade_database((string) $old, $force);
gpsmap_prune_files();
}

/* Migrate the misspelled 'gpsmap_latutude' key to 'gpsmap_latitude'.
Expand Down Expand Up @@ -361,3 +362,173 @@ function gpsmap_config_form() {

$fields_host_edit = $fields_host_edit3;
}

/**
* Removes files and directories that a previous version of this plugin
* shipped but that have since moved or been deleted, using the tombstone
* and whitelist lists in manifest.json. Whitelisted (user-data) paths and
* any VCS metadata (.git*) are never touched; the dev-only tests/ tree is
* removed. Any path that resolves outside the plugin directory (a tampered
* manifest.json) is refused, and any file/directory that cannot be removed
* (e.g. read-only) is reported to the Cacti log. Any top-level entry that is
* neither expected nor a tombstone nor whitelisted is logged to the Cacti
* log and left in place. Called on a plugin version change.
*
* @return void
*
* @global array $config Cacti global configuration array; used to resolve
* the plugin directory.
*/
function gpsmap_prune_files(): void {
global $config;

$plugin_dir = $config['base_path'] . '/plugins/gpsmap';
$manifest_path = $plugin_dir . '/manifest.json';

if (!is_readable($manifest_path)) {
return;
}

$manifest = json_decode((string) file_get_contents($manifest_path), true);

if (!is_array($manifest)) {
cacti_log('WARNING: gpsmap manifest.json could not be parsed; skipping file prune', false, 'GPSMAP');

return;
}

$tombstones = isset($manifest['tombstones']) && is_array($manifest['tombstones']) ? $manifest['tombstones'] : [];
$expected = isset($manifest['expected']) && is_array($manifest['expected']) ? $manifest['expected'] : [];
$whitelist = isset($manifest['whitelist']) && is_array($manifest['whitelist']) ? $manifest['whitelist'] : [];

$protected = function (string $rel) use ($whitelist): bool {
if (strncmp($rel, '.git', 4) === 0 || strncmp($rel, '.md', 3) === 0) {
return true;
}

foreach ($whitelist as $entry) {
$entry = trim((string) $entry, '/');

if ($entry !== '' && ($rel === $entry
|| strncmp($rel, $entry . '/', strlen($entry) + 1) === 0
|| strncmp($entry, $rel . '/', strlen($rel) + 1) === 0)) {
return true;
}
}

return false;
};

// Security: resolve the plugin directory so a tampered manifest.json
// cannot steer the prune outside of it.
$plugin_real = realpath($plugin_dir);

// Remove tombstoned (moved/deleted) paths plus the dev-only tests/
// tree and the phpunit.xml test configuration.
$remove = $tombstones;
$remove[] = 'tests/';
$remove[] = 'phpunit.xml';

foreach ($remove as $rel) {
$rel = trim((string) $rel, '/');

if ($rel === '' || $protected($rel)) {
continue;
}

// A tombstone must never contain '.'/'..' segments; a tampered manifest
// could use them to escape the plugin directory or target its root.
$segments = explode('/', $rel);

if (in_array('.', $segments, true) || in_array('..', $segments, true)) {
cacti_log(sprintf('WARNING: gpsmap prune refused to remove %s: path contains a traversal segment (tampered manifest.json?)', $rel), false, 'GPSMAP');

continue;
}

$path = $plugin_dir . '/' . $rel;

if (!is_link($path) && !file_exists($path)) {
continue;
}

// Refuse any path that, after resolving symlinks and ../ segments,
// escapes the plugin directory (protects user data from a tampered
// manifest.json).
$anchor = is_link($path) ? dirname($path) : $path;
$real = realpath($anchor);

if ($real === false || ($real !== $plugin_real && strncmp($real, $plugin_real . DIRECTORY_SEPARATOR, strlen((string) $plugin_real) + 1) !== 0)) {
cacti_log(sprintf('WARNING: gpsmap prune refused to remove %s: path resolves outside the plugin directory (tampered manifest.json?)', $rel), false, 'GPSMAP');

continue;
}

if (is_dir($path) && !is_link($path)) {
$removed = gpsmap_rmtree($path);
} else {
$removed = @unlink($path);
}

if (!$removed) {
cacti_log(sprintf('WARNING: gpsmap upgrade could not remove %s (check file/directory permissions)', $rel), false, 'GPSMAP');
}
}

// Surface any top-level entry the manifest does not account for.
$known = [];

foreach (array_merge($expected, $tombstones) as $entry) {
$top = explode('/', trim((string) $entry, '/'))[0];

if ($top !== '') {
$known[$top] = true;
}
}

$entries = scandir($plugin_dir);

foreach (($entries !== false ? $entries : []) as $entry) {
if ($entry === '.' || $entry === '..' || $entry === 'tests' || $entry === 'phpunit.xml' || $protected($entry) || isset($known[$entry])) {
continue;
}

cacti_log(sprintf('WARNING: gpsmap upgrade found a file/directory not described in manifest.json: %s (left in place)', $entry), false, 'GPSMAP');
}
}

/**
* Recursively deletes a directory and its contents. Symlinks are removed
* without being followed. Helper for gpsmap_prune_files().
*
* @param string $dir Absolute path to the directory to remove.
*
* @return bool True if the directory and everything under it was removed;
* false if any entry could not be deleted.
*/
function gpsmap_rmtree(string $dir): bool {
$entries = scandir($dir);
$ok = true;

foreach (($entries !== false ? $entries : []) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}

$path = $dir . '/' . $entry;

if (is_dir($path) && !is_link($path)) {
if (!gpsmap_rmtree($path)) {
$ok = false;
}
} elseif (!@unlink($path)) {
$ok = false;
}
}

if (!@rmdir($dir)) {
$ok = false;
}

return $ok;
}
6 changes: 3 additions & 3 deletions tests/Security/IconsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
$cwd = getcwd();
chdir(gpsmap_test_tmpdir());
ob_start();
include __DIR__ . '/../../includes/icons.php';
require __DIR__ . '/../../includes/icons.php';
$this->js = ob_get_clean();
chdir($cwd);
});
Expand Down Expand Up @@ -86,7 +86,7 @@
chdir(sys_get_temp_dir());
$empty = gpsmap_test_silence(function () {
ob_start();
include __DIR__ . '/../../includes/icons.php';
require __DIR__ . '/../../includes/icons.php';

return ob_get_clean();
});
Expand Down Expand Up @@ -153,7 +153,7 @@
);

ob_start();
include __DIR__ . '/../../includes/customicons.php';
require __DIR__ . '/../../includes/customicons.php';
$custom = ob_get_clean();

it('maps a good icon', function () use ($custom) {
Expand Down
Loading
Loading