mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-30 16:47:14 +09:00
271 lines
10 KiB
PHP
271 lines
10 KiB
PHP
<?php
|
|
/*
|
|
+-------------------------------------------------------------------------+
|
|
| Copyright (C) 2004-2026 The Cacti Group |
|
|
| |
|
|
| This program is free software; you can redistribute it and/or |
|
|
| modify it under the terms of the GNU General Public License |
|
|
| as published by the Free Software Foundation; either version 2 |
|
|
| of the License, or (at your option) any later version. |
|
|
+-------------------------------------------------------------------------+
|
|
| Cacti: The Complete RRDtool-based Graphing Solution |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
/*
|
|
* Regression coverage for the Package signature gate on the import path.
|
|
*
|
|
* import_validate_signature() used to return the Package info array for both a
|
|
* trusted and an untrusted key, recording the real verdict in $info['valid'].
|
|
* The gate in import_read_package_data() tested the array itself, which is
|
|
* always truthy, so a Package self-signed with its author's own key was read
|
|
* and its scripts/ files written to disk. The helper now returns a strict
|
|
* bool and the gate reads that.
|
|
*
|
|
* The 'Automatically Trust Signer' control was the other half of the hole:
|
|
* trust_signer=on inserted the Package's own key into package_public_keys
|
|
* before the gate ran, so even a corrected gate would have passed. The
|
|
* control is gone; a key becomes trusted only through the accept action.
|
|
*
|
|
* The gate compares PEM against PEM, and the two sides do not agree on the
|
|
* trailing newline: a Package carries the key as OpenSSL exported it, the
|
|
* built in keys are written into lib/import.php without one. A strict compare
|
|
* of the raw bytes therefore rejects every Package Cacti signs itself, which is
|
|
* why the shipped Device Packages are exercised here too.
|
|
*/
|
|
|
|
require_once dirname(__DIR__, 3) . '/Helpers/CactiStubs.php';
|
|
require_once dirname(__DIR__, 3) . '/Helpers/FakeMySQLPDO.php';
|
|
require_once dirname(__DIR__, 4) . '/include/global.php';
|
|
require_once dirname(__DIR__, 4) . '/lib/import.php';
|
|
require_once dirname(__DIR__, 4) . '/lib/xml.php';
|
|
|
|
/*
|
|
* Build a Package the way lib/package.php does, but signed with a throwaway
|
|
* key rather than the Cacti key. Returns the .xml.gz path; $public_key
|
|
* receives the PEM that the Package carries.
|
|
*/
|
|
function selfSignedPackage(string &$public_key, bool $names_key = true, int $algorithm = OPENSSL_ALGO_SHA256): string {
|
|
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
|
openssl_pkey_export($key, $private_key);
|
|
$public_key = openssl_pkey_get_details($key)['key'];
|
|
|
|
$payload = "<?php\n// planted by the package\n";
|
|
$file_sig = '';
|
|
$base_sig = '';
|
|
|
|
$xml = "<xml>\n";
|
|
$xml .= " <info>\n";
|
|
$xml .= " <name>Untrusted Package</name>\n";
|
|
$xml .= " <author>Mallory</author>\n";
|
|
$xml .= " <homepage>http://example.invalid</homepage>\n";
|
|
$xml .= " <email>mallory@example.invalid</email>\n";
|
|
$xml .= " </info>\n";
|
|
$xml .= " <directories>\n";
|
|
$xml .= " <directory>scripts</directory>\n";
|
|
$xml .= " </directories>\n";
|
|
$xml .= " <files>\n";
|
|
$xml .= " <file>\n";
|
|
$xml .= " <name>scripts/planted.php</name>\n";
|
|
|
|
openssl_sign($payload, $file_sig, $private_key, $algorithm);
|
|
|
|
$xml .= ' <data>' . base64_encode($payload) . "</data>\n";
|
|
$xml .= ' <filesignature>' . base64_encode($file_sig) . "</filesignature>\n";
|
|
$xml .= " </file>\n";
|
|
$xml .= " </files>\n";
|
|
$xml .= " <publickeyname>Mallory</publickeyname>\n";
|
|
|
|
if ($names_key) {
|
|
$xml .= ' <publickey>' . base64_encode($public_key) . "</publickey>\n";
|
|
}
|
|
|
|
openssl_sign($xml . " <signature></signature>\n</xml>", $base_sig, $private_key, $algorithm);
|
|
|
|
$xml .= ' <signature>' . base64_encode($base_sig) . "</signature>\n</xml>";
|
|
|
|
$path = sys_get_temp_dir() . '/cacti_pkg_trust_' . bin2hex(random_bytes(4)) . '.xml.gz';
|
|
$f = fopen("compress.zlib://$path", 'wb');
|
|
|
|
fwrite($f, $xml, strlen($xml));
|
|
fclose($f);
|
|
|
|
return $path;
|
|
}
|
|
|
|
function importSourceFile(string $name): string {
|
|
$src = file_get_contents(dirname(__DIR__, 4) . "/$name");
|
|
expect($src)->not->toBeFalse("Failed to read $name");
|
|
|
|
return $src;
|
|
}
|
|
|
|
beforeEach(function () {
|
|
$this->package_key = '';
|
|
$this->package = selfSignedPackage($this->package_key);
|
|
});
|
|
|
|
afterEach(function () {
|
|
if (file_exists($this->package)) {
|
|
unlink($this->package);
|
|
}
|
|
});
|
|
|
|
// --- the gate ---
|
|
|
|
test('import_validate_signature returns a strict bool', function () {
|
|
// The array return was the defect: any populated result read as a pass.
|
|
expect((string) (new ReflectionFunction('import_validate_signature'))->getReturnType())->toBe('bool');
|
|
});
|
|
|
|
test('a package signed with its own key is not trusted', function () {
|
|
expect(import_validate_signature($this->package))->toBeFalse();
|
|
});
|
|
|
|
test('a package that names no key is not trusted', function () {
|
|
// import_package_get_public_key() substitutes the built in Cacti key when
|
|
// the element is absent, so the gate must not ask it for the verdict.
|
|
$key = '';
|
|
$package = selfSignedPackage($key, false);
|
|
|
|
try {
|
|
expect(import_get_package_info($package)['pubkey'])->toBe('');
|
|
expect(import_validate_signature($package))->toBeFalse();
|
|
} finally {
|
|
unlink($package);
|
|
}
|
|
});
|
|
|
|
test('the shipped device packages pass the trust gate', function () {
|
|
// Every install/templates Package is signed with the Cacti key and is
|
|
// imported by cli/import_package.php on a fresh install. Rejecting them
|
|
// breaks the install, so the whole set is checked rather than a sample.
|
|
$packages = glob(dirname(__DIR__, 4) . '/install/templates/*.xml.gz');
|
|
|
|
expect($packages)->not->toBeEmpty();
|
|
|
|
foreach ($packages as $package) {
|
|
expect(import_validate_signature($package))->toBeTrue(basename($package) . ' is not trusted');
|
|
}
|
|
});
|
|
|
|
test('a shipped package key differs from the built in key only in trailing whitespace', function () {
|
|
// This is what a strict compare of the raw bytes tripped over.
|
|
$package = dirname(__DIR__, 4) . '/install/templates/Local_Linux_Machine.xml.gz';
|
|
$pubkey = import_get_package_info($package)['pubkey'];
|
|
|
|
expect($pubkey)->not->toBe(get_public_key_sha256());
|
|
expect(trim($pubkey))->toBe(get_public_key_sha256());
|
|
});
|
|
|
|
test('a real import of a self-signed package is refused', function () {
|
|
$public_key = '';
|
|
|
|
expect(import_read_package_data($this->package, $public_key, false))->toBeFalse();
|
|
});
|
|
|
|
test('a preview of an untrusted package is still readable', function () {
|
|
// A preview writes neither files nor rows, so it stays available to let an
|
|
// operator inspect a Package before deciding to trust its signer.
|
|
$public_key = '';
|
|
$data = import_read_package_data($this->package, $public_key, true);
|
|
|
|
expect($data)->toBeArray();
|
|
expect($data['files']['file']['name'])->toBe('scripts/planted.php');
|
|
});
|
|
|
|
test('the gate no longer short circuits before it reads the verdict', function () {
|
|
expect(importSourceFile('lib/import.php'))
|
|
->not->toContain('!import_validate_signature($xmlfile) && !$preview');
|
|
});
|
|
|
|
// --- accepted keys, and why auto-accepting them was the second hole ---
|
|
|
|
test('a key in package_public_keys makes the same package importable', function () {
|
|
global $database_sessions, $database_hostname, $database_port, $database_default;
|
|
|
|
$session = "$database_hostname:$database_port:$database_default";
|
|
$prior = $database_sessions[$session] ?? null;
|
|
$conn = new FakeMySQLPDO();
|
|
|
|
$conn->exec('CREATE TABLE package_public_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, public_key TEXT)');
|
|
|
|
// import_validate_public_key() stores the pubkey off the Package verbatim,
|
|
// so an already accepted row carries the trailing newline.
|
|
expect(str_ends_with($this->package_key, "\n"))->toBeTrue();
|
|
|
|
$insert = $conn->prepare('INSERT INTO package_public_keys (public_key) VALUES (?)');
|
|
$insert->execute([$this->package_key]);
|
|
|
|
$database_sessions[$session] = $conn;
|
|
|
|
try {
|
|
$public_key = '';
|
|
|
|
expect(import_validate_signature($this->package))->toBeTrue();
|
|
expect(import_read_package_data($this->package, $public_key, false))->toBeArray();
|
|
} finally {
|
|
if ($prior === null) {
|
|
unset($database_sessions[$session]);
|
|
} else {
|
|
$database_sessions[$session] = $prior;
|
|
}
|
|
}
|
|
});
|
|
|
|
test('a trusted key cannot make a SHA-1 signed package importable', function () {
|
|
global $database_sessions, $database_hostname, $database_port, $database_default;
|
|
|
|
$publicKey = '';
|
|
$package = selfSignedPackage($publicKey, true, OPENSSL_ALGO_SHA1);
|
|
$session = "$database_hostname:$database_port:$database_default";
|
|
$prior = $database_sessions[$session] ?? null;
|
|
$conn = new FakeMySQLPDO();
|
|
|
|
$conn->exec('CREATE TABLE package_public_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, public_key TEXT)');
|
|
$insert = $conn->prepare('INSERT INTO package_public_keys (public_key) VALUES (?)');
|
|
$insert->execute([$publicKey]);
|
|
$database_sessions[$session] = $conn;
|
|
|
|
try {
|
|
// Trust identifies an accepted signer; it does not override the
|
|
// cryptographic SHA-256 verification performed while reading.
|
|
expect(import_validate_signature($package))->toBeTrue();
|
|
|
|
$validatedKey = '';
|
|
expect(import_read_package_data($package, $validatedKey, false))->toBeFalse();
|
|
} finally {
|
|
unlink($package);
|
|
|
|
if ($prior === null) {
|
|
unset($database_sessions[$session]);
|
|
} else {
|
|
$database_sessions[$session] = $prior;
|
|
}
|
|
}
|
|
});
|
|
|
|
test('trust_signer no longer reaches the import', function () {
|
|
expect(importSourceFile('package_import.php'))->not->toContain('trust_signer');
|
|
expect(importSourceFile('include/global_settings.php'))->not->toContain('trust_signer');
|
|
});
|
|
|
|
test('a key is force trusted only by the accept action', function () {
|
|
$src = importSourceFile('package_import.php');
|
|
$accept = strpos($src, 'function package_accept_key(');
|
|
|
|
expect($accept)->not->toBeFalse();
|
|
|
|
$offset = 0;
|
|
$found = 0;
|
|
|
|
while (($pos = strpos($src, 'import_validate_public_key($xmlfile, true)', $offset)) !== false) {
|
|
expect($pos)->toBeGreaterThan($accept);
|
|
|
|
$found++;
|
|
$offset = $pos + 1;
|
|
}
|
|
|
|
expect($found)->toBeGreaterThan(0);
|
|
});
|