diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 875623a5f..f30698331 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -40,7 +40,7 @@ jobs:
with:
languages: ${{ matrix.language }}
- name: Autobuild
- uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3
+ uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v3
with:
diff --git a/.github/workflows/syntax.yml b/.github/workflows/syntax.yml
index dcb546b65..a8657873f 100644
--- a/.github/workflows/syntax.yml
+++ b/.github/workflows/syntax.yml
@@ -80,6 +80,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
- name: Install PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
@@ -113,6 +115,11 @@ jobs:
run: composer run-script lint
working-directory: .
+ - name: Run hardening pattern guard
+ if: github.event_name == 'pull_request'
+ run: composer run-script hardening-patterns -- --base="${{ github.event.pull_request.base.sha }}" --head=HEAD
+ working-directory: .
+
- name: Checking coding standards on base code
run: composer run-script phpcsfixer
working-directory: .
diff --git a/composer.json b/composer.json
index 71ddcfa14..03a334233 100644
--- a/composer.json
+++ b/composer.json
@@ -74,6 +74,7 @@
"phpstan": "phpstan analyse --level 6 ",
"php-cs-fixer": "php-cs-fixer fix --allow-unsupported-php-version=yes --dry-run --diff --config=./.php-cs-fixer.php ",
"phpcsfixer": "@php-cs-fixer",
+ "hardening-patterns": "php tests/tools/check_hardening_patterns.php",
"php-cs-fixit": "php-cs-fixer fix ",
"install-hooks": "bash -c 'install -m 755 tests/tools/pre-commit \"$(git rev-parse --git-path hooks/pre-commit)\"'",
"test": "include/vendor/bin/pest --display-warnings"
diff --git a/lib/PackageListFilter.php b/lib/PackageListFilter.php
new file mode 100644
index 000000000..6c3eb6cf9
--- /dev/null
+++ b/lib/PackageListFilter.php
@@ -0,0 +1,130 @@
+filter = $filter;
+ $this->rows = $rows;
+ $this->page = max(1, $page);
+ $this->sortColumn = $sortColumn;
+ $this->sortDirection = $sortDirection;
+ }
+
+ public static function fromRequest(int $defaultRows) : self {
+ $rows = (string) grv('rows');
+ $rows = $rows === '-1' ? $defaultRows : (int) $rows;
+
+ if ($rows < 1) {
+ $rows = $defaultRows;
+ }
+
+ return new self(
+ (string) grv('filter'),
+ $rows,
+ (int) grv('page'),
+ (string) grv('sort_column'),
+ (string) grv('sort_direction')
+ );
+ }
+
+ public function filter() : string {
+ return $this->filter;
+ }
+
+ public function hasFilter() : bool {
+ return $this->filter !== '';
+ }
+
+ public function rows() : int {
+ return $this->rows;
+ }
+
+ public function page() : int {
+ return $this->page;
+ }
+
+ public function sortColumn() : string {
+ return $this->sortColumn;
+ }
+
+ public function sortDirection() : string {
+ return $this->sortDirection;
+ }
+
+ public function offset() : int {
+ return $this->rows * ($this->page - 1);
+ }
+
+ public function paginationUrl(string $path) : string {
+ if ($this->filter === '') {
+ return $path;
+ }
+
+ return $path . '?' . http_build_query(['filter' => $this->filter], '', '&', PHP_QUERY_RFC3986);
+ }
+
+ public static function selectedIdsFromPost() : array {
+ return self::selectedIdsFromInput($_POST);
+ }
+
+ public static function selectedIdsFromInput(array $input) : array {
+ $selected = [];
+
+ foreach ($input as $var => $value) {
+ if (preg_match('/^chk_([0-9]+)$/', (string) $var, $matches)) {
+ $selected[] = (int) $matches[1];
+ }
+ }
+
+ return $selected;
+ }
+
+ public static function encodeSelectedIds(array $ids) : string {
+ return json_encode(array_values(array_map('intval', $ids)), JSON_THROW_ON_ERROR);
+ }
+
+ public static function decodeSelectedIds(mixed $ids) : array|false {
+ if (!is_string($ids) || $ids === '') {
+ return false;
+ }
+
+ try {
+ $decoded = json_decode(stripslashes($ids), true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ return false;
+ }
+
+ if (!is_array($decoded)) {
+ return false;
+ }
+
+ $selected = [];
+
+ foreach ($decoded as $id) {
+ if (!is_int($id) && !(is_string($id) && preg_match('/^[0-9]+$/', $id))) {
+ return false;
+ }
+
+ $selected[] = (int) $id;
+ }
+
+ return $selected;
+ }
+}
diff --git a/package_keys.php b/package_keys.php
index 43d16c573..b7b1b645f 100644
--- a/package_keys.php
+++ b/package_keys.php
@@ -23,6 +23,7 @@
*/
require('./include/auth.php');
+require_once(CACTI_PATH_LIBRARY . '/PackageListFilter.php');
require_once(CACTI_PATH_LIBRARY . '/poller.php');
require_once(CACTI_PATH_LIBRARY . '/utility.php');
@@ -53,7 +54,7 @@ function form_actions() : void {
// if we are to save this form, instead of display it
if (isrv('selected_items')) {
- $selected_items = sanitize_unserialize_selected_items(gnrv('selected_items'));
+ $selected_items = PackageListFilter::decodeSelectedIds(gnrv('selected_items'));
if ($selected_items != false) {
if (gnrv('drp_action') == '1') { // delete
@@ -72,16 +73,9 @@ function form_actions() : void {
$p_list = '';
$p_array = [];
- // loop through each of the data queries and process them
- foreach ($_POST as $var => $val) {
- if (preg_match('/^chk_([0-9]+)$/', $var, $matches)) {
- // ================= input validation =================
- input_validate_input_number($matches[1]);
- // ====================================================
-
- $p_list .= '
' . htmle(db_fetch_cell_prepared('SELECT author FROM package_public_keys WHERE id = ?', [$matches[1]])) . '';
- $p_array[] = $matches[1];
- }
+ foreach (PackageListFilter::selectedIdsFromPost() as $id) {
+ $p_list .= '' . htmle(db_fetch_cell_prepared('SELECT author FROM package_public_keys WHERE id = ?', [$id])) . '';
+ $p_array[] = $id;
}
top_header();
@@ -113,7 +107,7 @@ function form_actions() : void {
print "
|
-
+
$save_html
|
@@ -140,27 +134,23 @@ function public_keys() : void {
$pageFilter->set_sort_array('author', 'ASC');
$pageFilter->render();
- if (grv('rows') == '-1') {
- $rows = read_config_option('num_rows_table');
- } else {
- $rows = grv('rows');
- }
+ $filter = PackageListFilter::fromRequest((int) read_config_option('num_rows_table'));
$sql_where = '';
$sql_params = [];
// form the 'where' clause for our main sql query
- if (grv('filter') != '') {
+ if ($filter->hasFilter()) {
$sql_where = ($sql_where != '' ? ' AND ' : 'WHERE ') .
'(author LIKE ? OR homepage LIKE ? OR email_address LIKE ?)';
- $sql_params[] = '%' . grv('filter') . '%';
- $sql_params[] = '%' . grv('filter') . '%';
- $sql_params[] = '%' . grv('filter') . '%';
+ $sql_params[] = '%' . $filter->filter() . '%';
+ $sql_params[] = '%' . $filter->filter() . '%';
+ $sql_params[] = '%' . $filter->filter() . '%';
}
$sql_order = get_order_string();
- $sql_limit = ' LIMIT ' . ($rows * (grv('page') - 1)) . ',' . $rows;
+ $sql_limit = ' LIMIT ' . $filter->offset() . ',' . $filter->rows();
$total_rows = db_fetch_cell_prepared("SELECT COUNT(*)
FROM package_public_keys
@@ -197,7 +187,7 @@ function public_keys() : void {
],
];
- $nav = html_nav_bar('package_keys.php?filter=' . grv('filter'), MAX_DISPLAY_PAGES, grv('page'), $rows, $total_rows, sizeof($display_text) + 1, __('Package Public Keys'), 'page', 'main');
+ $nav = html_nav_bar($filter->paginationUrl('package_keys.php'), MAX_DISPLAY_PAGES, $filter->page(), $filter->rows(), $total_rows, cacti_sizeof($display_text) + 1, __('Package Public Keys'), 'page', 'main');
form_start('package_keys.php', 'chk');
@@ -205,7 +195,7 @@ function public_keys() : void {
html_start_box('', '100%', false, 3, 'center', '');
- html_header_sort_checkbox($display_text, grv('sort_column'), grv('sort_direction'), false);
+ html_header_sort_checkbox($display_text, $filter->sortColumn(), $filter->sortDirection(), false);
$i = 0;
@@ -215,10 +205,10 @@ function public_keys() : void {
$pkey = $key['public_key'];
- form_selectable_cell(filter_value($key['author'], grv('filter')), $key['id']);
+ form_selectable_cell(filter_value($key['author'], $filter->filter()), $key['id']);
form_selectable_cell($key['id'], $key['id']);
- form_selectable_cell(filter_value($key['homepage'], grv('filter')), $key['id']);
- form_selectable_cell(filter_value($key['email_address'], grv('filter')), $key['id']);
+ form_selectable_cell(filter_value($key['homepage'], $filter->filter()), $key['id']);
+ form_selectable_cell(filter_value($key['email_address'], $filter->filter()), $key['id']);
form_selectable_cell(strlen($pkey) < 200 ? 'SHA1' : 'SHA256', $key['id']);
form_checkbox_cell($key['author'], $key['id']);
diff --git a/package_repos.php b/package_repos.php
index e754797ac..86bf5042b 100644
--- a/package_repos.php
+++ b/package_repos.php
@@ -23,6 +23,7 @@
*/
require('./include/auth.php');
+require_once(CACTI_PATH_LIBRARY . '/PackageListFilter.php');
require_once(CACTI_PATH_LIBRARY . '/poller.php');
require_once(CACTI_PATH_LIBRARY . '/utility.php');
@@ -164,7 +165,7 @@ function form_actions() : void {
// if we are to save this form, instead of display it
if (isrv('selected_items')) {
- $selected_items = sanitize_unserialize_selected_items(gnrv('selected_items'));
+ $selected_items = PackageListFilter::decodeSelectedIds(gnrv('selected_items'));
if ($selected_items != false) {
if (gnrv('drp_action') == '1') { // delete
@@ -199,16 +200,9 @@ function form_actions() : void {
$p_list = '';
$p_array = [];
- // loop through each of the data queries and process them
- foreach ($_POST as $var => $val) {
- if (preg_match('/^chk_([0-9]+)$/', $var, $matches)) {
- // ================= input validation =================
- input_validate_input_number($matches[1]);
- // ====================================================
-
- $p_list .= '' . htmle(db_fetch_cell_prepared('SELECT name FROM package_repositories WHERE id = ?', [$matches[1]])) . '';
- $p_array[] = $matches[1];
- }
+ foreach (PackageListFilter::selectedIdsFromPost() as $id) {
+ $p_list .= '' . htmle(db_fetch_cell_prepared('SELECT name FROM package_repositories WHERE id = ?', [$id])) . '';
+ $p_array[] = $id;
}
top_header();
@@ -271,7 +265,7 @@ function form_actions() : void {
print "
|
-
+
$save_html
|
@@ -438,27 +432,23 @@ function repos() : void {
$pageFilter->rows_label = __('Repos');
$pageFilter->render();
- if (grv('rows') == '-1') {
- $rows = read_config_option('num_rows_table');
- } else {
- $rows = grv('rows');
- }
+ $filter = PackageListFilter::fromRequest((int) read_config_option('num_rows_table'));
$sql_where = '';
$sql_params = [];
// form the 'where' clause for our main sql query
- if (grv('filter') != '') {
+ if ($filter->hasFilter()) {
$sql_where = ($sql_where != '' ? ' AND ' : 'WHERE ') .
'(name LIKE ? OR repo_branch LIKE ? OR repo_location LIKE ?)';
- $sql_params[] = '%' . grv('filter') . '%';
- $sql_params[] = '%' . grv('filter') . '%';
- $sql_params[] = '%' . grv('filter') . '%';
+ $sql_params[] = '%' . $filter->filter() . '%';
+ $sql_params[] = '%' . $filter->filter() . '%';
+ $sql_params[] = '%' . $filter->filter() . '%';
}
$sql_order = get_order_string();
- $sql_limit = ' LIMIT ' . ($rows * (grv('page') - 1)) . ',' . $rows;
+ $sql_limit = ' LIMIT ' . $filter->offset() . ',' . $filter->rows();
$total_rows = db_fetch_cell_prepared("SELECT COUNT(*)
FROM package_repositories
@@ -502,7 +492,7 @@ function repos() : void {
],
];
- $nav = html_nav_bar('package_repos.php?filter=' . grv('filter'), MAX_DISPLAY_PAGES, grv('page'), $rows, $total_rows, sizeof($display_text) + 1, __('Package Repositories'), 'page', 'main');
+ $nav = html_nav_bar($filter->paginationUrl('package_repos.php'), MAX_DISPLAY_PAGES, $filter->page(), $filter->rows(), $total_rows, cacti_sizeof($display_text) + 1, __('Package Repositories'), 'page', 'main');
form_start('package_repos.php', 'chk');
@@ -510,15 +500,15 @@ function repos() : void {
html_start_box('', '100%', false, 3, 'center', '');
- html_header_sort_checkbox($display_text, grv('sort_column'), grv('sort_direction'), false);
+ html_header_sort_checkbox($display_text, $filter->sortColumn(), $filter->sortDirection(), false);
if (cacti_sizeof($repos)) {
foreach ($repos as $repo) {
form_alternate_row('line' . $repo['id'], true);
- form_selectable_cell(filter_value($repo['name'], grv('filter'), 'package_repos.php?action=edit&id=' . $repo['id']), $repo['id']);
+ form_selectable_cell(filter_value($repo['name'], $filter->filter(), 'package_repos.php?action=edit&id=' . $repo['id']), $repo['id']);
form_selectable_cell($types[$repo['repo_type']], $repo['id']);
- form_selectable_cell(filter_value($repo['repo_location'], grv('filter')), $repo['id']);
+ form_selectable_cell(filter_value($repo['repo_location'], $filter->filter()), $repo['id']);
form_selectable_ecell($repo['repo_type'] == 0 ? ($repo['repo_branch'] != '' ? $repo['repo_branch'] : __('default')) : __('N/A'), $repo['id'], '', 'center');
form_selectable_cell($repo['enabled'] == 'on' ? __('Yes') : __('No'), $repo['id'], '', 'center');
form_selectable_cell($repo['default'] == 'on' ? __('Yes') : __('No'), $repo['id'], '', 'center');
diff --git a/tests/Unit/PackageListFilterTest.php b/tests/Unit/PackageListFilterTest.php
new file mode 100644
index 000000000..30f285d8e
--- /dev/null
+++ b/tests/Unit/PackageListFilterTest.php
@@ -0,0 +1,85 @@
+filter())->toBe('alpha')
+ ->and($filter->rows())->toBe(25)
+ ->and($filter->page())->toBe(2)
+ ->and($filter->offset())->toBe(25);
+});
+
+test('package list filter clamps non-positive rows to the default row count', function () {
+ set_request_var('filter', 'alpha');
+ set_request_var('rows', '0');
+ set_request_var('page', '2');
+ set_request_var('sort_column', 'name');
+ set_request_var('sort_direction', 'ASC');
+
+ $filter = PackageListFilter::fromRequest(25);
+
+ expect($filter->rows())->toBe(25)
+ ->and($filter->offset())->toBe(25);
+});
+
+test('package list filter encodes pagination query values', function () {
+ set_request_var('filter', 'a b&c');
+ set_request_var('rows', '10');
+ set_request_var('page', '1');
+ set_request_var('sort_column', 'name');
+ set_request_var('sort_direction', 'ASC');
+
+ $filter = PackageListFilter::fromRequest(25);
+
+ expect($filter->paginationUrl('package_repos.php'))->toBe('package_repos.php?filter=a%20b%26c');
+});
+
+test('package list filter extracts selected checkbox ids', function () {
+ $selected = PackageListFilter::selectedIdsFromInput([
+ 'chk_12' => 'on',
+ 'chk_x' => 'on',
+ 'other' => 'on',
+ 'chk_34' => 'on',
+ ]);
+
+ expect($selected)->toBe([12, 34]);
+});
+
+test('package list filter decodes only numeric selected ids', function () {
+ $encoded = PackageListFilter::encodeSelectedIds([3, '7']);
+
+ expect(PackageListFilter::decodeSelectedIds($encoded))->toBe([3, 7])
+ ->and(PackageListFilter::decodeSelectedIds('["3","bad"]'))->toBeFalse()
+ ->and(PackageListFilter::decodeSelectedIds('not json'))->toBeFalse();
+});
diff --git a/tests/tools/check_hardening_patterns.php b/tests/tools/check_hardening_patterns.php
new file mode 100644
index 000000000..0b2ef7d86
--- /dev/null
+++ b/tests/tools/check_hardening_patterns.php
@@ -0,0 +1,138 @@
+ [
+ 'pattern' => '/\$_(?:GET|POST|REQUEST)\s*\[/',
+ 'message' => 'Use Cacti request helpers or typed filter objects instead of raw request superglobals.',
+ 'allow' => [
+ 'lib/PackageListFilter.php',
+ ]
+ ],
+ 'manual-request-query-url' => [
+ 'pattern' => '/[?&][A-Za-z0-9_-]+=\s*[\'"]?\s*\.\s*g(?:r|fr|nr)v\s*\(/',
+ 'message' => 'Use a URL builder or typed page-state URL method instead of concatenating request values into query strings.'
+ ],
+ 'inline-js-php-string' => [
+ 'pattern' => '/(?:var|let|const)\s+[A-Za-z0-9_$]+\s*=\s*[\'"][^\'"]*<\?php\s+print\s+/',
+ 'message' => 'Use JSON encoding for PHP values injected into JavaScript.'
+ ],
+ 'raw-request-value-attribute' => [
+ 'pattern' => '/value\s*=\s*[\'"][^\'"]*<\?php\s+print\s+g(?:r|fr|nr)v\s*\(/',
+ 'message' => 'Use form rendering helpers for request-backed input values.'
+ ],
+ 'serialized-hidden-state' => [
+ 'pattern' => '/(?:type\s*=\s*[\'"]hidden[\'"].*serialize\s*\(|html_hidden_input\s*\([^;]*serialize\s*\()/',
+ 'message' => 'Do not round-trip serialized PHP state through hidden fields.'
+ ],
+];
+
+$violations = [];
+
+foreach ($files as $file) {
+ if (!is_file($file) || pathinfo($file, PATHINFO_EXTENSION) !== 'php') {
+ continue;
+ }
+
+ $lines = file($file, FILE_IGNORE_NEW_LINES);
+
+ if ($lines === false) {
+ continue;
+ }
+
+ foreach ($lines as $index => $line) {
+ foreach ($patterns as $name => $rule) {
+ if (isset($rule['allow']) && in_array($file, $rule['allow'], true)) {
+ continue;
+ }
+
+ if (preg_match($rule['pattern'], $line) === 1) {
+ $violations[] = sprintf('%s:%d: %s: %s', $file, $index + 1, $name, $rule['message']);
+ }
+ }
+ }
+}
+
+if ($violations !== []) {
+ fwrite(STDERR, "Hardening pattern guard found unsafe changed-file patterns:\n");
+ fwrite(STDERR, implode("\n", $violations) . "\n");
+
+ exit(1);
+}
+
+exit(0);
+
+function changed_php_files(string $base, string $head) : array {
+ if ($base === '') {
+ $base = trim((string) shell_exec('git merge-base HEAD upstream/develop 2>/dev/null'));
+ }
+
+ if ($base === '') {
+ $base = trim((string) shell_exec('git merge-base HEAD origin/develop 2>/dev/null'));
+ }
+
+ if ($base === '') {
+ return [];
+ }
+
+ $command = sprintf(
+ 'git diff --name-only --diff-filter=ACMR %s...%s -- %s 2>/dev/null',
+ escapeshellarg($base),
+ escapeshellarg($head),
+ escapeshellarg('*.php')
+ );
+
+ $output = shell_exec($command);
+
+ if (!is_string($output) || trim($output) === '') {
+ return [];
+ }
+
+ return preg_split('/\R/', trim($output)) ?: [];
+}
+
+function cli_file_args(array $argv) : array {
+ $files = [];
+
+ for ($i = 1; $i < count($argv); $i++) {
+ $arg = $argv[$i];
+
+ if ($arg === '--base' || $arg === '--head') {
+ $i++;
+
+ continue;
+ }
+
+ if (str_starts_with($arg, '--base=') || str_starts_with($arg, '--head=')) {
+ continue;
+ }
+
+ if (str_starts_with($arg, '--')) {
+ continue;
+ }
+
+ $files[] = $arg;
+ }
+
+ return $files;
+}