hardening(rendering): enforce changed-file guardrails (#7255)

* hardening(rendering): enforce changed-file guardrails

* fix(ci): align CodeQL autobuild action

* fix(rendering): address guard review feedback
This commit is contained in:
Thomas Vincent authored and GitHub committed 2026-07-07 18:00:59 -07:00
1 parent d151a3875b
commit 8dc1f677dd
8 files changed
+395 -54

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3 uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v3
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v3 uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v3
with: with:
+7
View File
@@ -80,6 +80,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install PHP ${{ matrix.php }} - name: Install PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -113,6 +115,11 @@ jobs:
run: composer run-script lint run: composer run-script lint
working-directory: . 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 - name: Checking coding standards on base code
run: composer run-script phpcsfixer run: composer run-script phpcsfixer
working-directory: . working-directory: .
+1
View File
@@ -74,6 +74,7 @@
"phpstan": "phpstan analyse --level 6 ", "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 ", "php-cs-fixer": "php-cs-fixer fix --allow-unsupported-php-version=yes --dry-run --diff --config=./.php-cs-fixer.php ",
"phpcsfixer": "@php-cs-fixer", "phpcsfixer": "@php-cs-fixer",
"hardening-patterns": "php tests/tools/check_hardening_patterns.php",
"php-cs-fixit": "php-cs-fixer fix ", "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)\"'", "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" "test": "include/vendor/bin/pest --display-warnings"
+130
View File
@@ -0,0 +1,130 @@
<?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 |
+-------------------------------------------------------------------------+
*/
final class PackageListFilter {
private string $filter;
private int $rows;
private int $page;
private string $sortColumn;
private string $sortDirection;
private function __construct(string $filter, int $rows, int $page, string $sortColumn, string $sortDirection) {
$this->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;
}
}
+17 -27
View File
@@ -23,6 +23,7 @@
*/ */
require('./include/auth.php'); require('./include/auth.php');
require_once(CACTI_PATH_LIBRARY . '/PackageListFilter.php');
require_once(CACTI_PATH_LIBRARY . '/poller.php'); require_once(CACTI_PATH_LIBRARY . '/poller.php');
require_once(CACTI_PATH_LIBRARY . '/utility.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 we are to save this form, instead of display it
if (isrv('selected_items')) { 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 ($selected_items != false) {
if (gnrv('drp_action') == '1') { // delete if (gnrv('drp_action') == '1') { // delete
@@ -72,16 +73,9 @@ function form_actions() : void {
$p_list = ''; $p_list = '';
$p_array = []; $p_array = [];
// loop through each of the data queries and process them foreach (PackageListFilter::selectedIdsFromPost() as $id) {
foreach ($_POST as $var => $val) { $p_list .= '<li>' . htmle(db_fetch_cell_prepared('SELECT author FROM package_public_keys WHERE id = ?', [$id])) . '</li>';
if (preg_match('/^chk_([0-9]+)$/', $var, $matches)) { $p_array[] = $id;
// ================= input validation =================
input_validate_input_number($matches[1]);
// ====================================================
$p_list .= '<li>' . htmle(db_fetch_cell_prepared('SELECT author FROM package_public_keys WHERE id = ?', [$matches[1]])) . '</li>';
$p_array[] = $matches[1];
}
} }
top_header(); top_header();
@@ -113,7 +107,7 @@ function form_actions() : void {
print "<tr> print "<tr>
<td class='saveRow'> <td class='saveRow'>
<input type='hidden' name='action' value='actions'> <input type='hidden' name='action' value='actions'>
<input type='hidden' name='selected_items' value='" . serialize($p_array) . "'> <input type='hidden' name='selected_items' value='" . htmle(PackageListFilter::encodeSelectedIds($p_array)) . "'>
<input type='hidden' name='drp_action' value='" . htmle(gnrv('drp_action')) . "'> <input type='hidden' name='drp_action' value='" . htmle(gnrv('drp_action')) . "'>
$save_html $save_html
</td> </td>
@@ -140,27 +134,23 @@ function public_keys() : void {
$pageFilter->set_sort_array('author', 'ASC'); $pageFilter->set_sort_array('author', 'ASC');
$pageFilter->render(); $pageFilter->render();
if (grv('rows') == '-1') { $filter = PackageListFilter::fromRequest((int) read_config_option('num_rows_table'));
$rows = read_config_option('num_rows_table');
} else {
$rows = grv('rows');
}
$sql_where = ''; $sql_where = '';
$sql_params = []; $sql_params = [];
// form the 'where' clause for our main sql query // form the 'where' clause for our main sql query
if (grv('filter') != '') { if ($filter->hasFilter()) {
$sql_where = ($sql_where != '' ? ' AND ' : 'WHERE ') . $sql_where = ($sql_where != '' ? ' AND ' : 'WHERE ') .
'(author LIKE ? OR homepage LIKE ? OR email_address LIKE ?)'; '(author LIKE ? OR homepage LIKE ? OR email_address LIKE ?)';
$sql_params[] = '%' . grv('filter') . '%'; $sql_params[] = '%' . $filter->filter() . '%';
$sql_params[] = '%' . grv('filter') . '%'; $sql_params[] = '%' . $filter->filter() . '%';
$sql_params[] = '%' . grv('filter') . '%'; $sql_params[] = '%' . $filter->filter() . '%';
} }
$sql_order = get_order_string(); $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(*) $total_rows = db_fetch_cell_prepared("SELECT COUNT(*)
FROM package_public_keys 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'); form_start('package_keys.php', 'chk');
@@ -205,7 +195,7 @@ function public_keys() : void {
html_start_box('', '100%', false, 3, 'center', ''); 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; $i = 0;
@@ -215,10 +205,10 @@ function public_keys() : void {
$pkey = $key['public_key']; $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($key['id'], $key['id']);
form_selectable_cell(filter_value($key['homepage'], grv('filter')), $key['id']); form_selectable_cell(filter_value($key['homepage'], $filter->filter()), $key['id']);
form_selectable_cell(filter_value($key['email_address'], grv('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_selectable_cell(strlen($pkey) < 200 ? 'SHA1' : 'SHA256', $key['id']);
form_checkbox_cell($key['author'], $key['id']); form_checkbox_cell($key['author'], $key['id']);
+16 -26
View File
@@ -23,6 +23,7 @@
*/ */
require('./include/auth.php'); require('./include/auth.php');
require_once(CACTI_PATH_LIBRARY . '/PackageListFilter.php');
require_once(CACTI_PATH_LIBRARY . '/poller.php'); require_once(CACTI_PATH_LIBRARY . '/poller.php');
require_once(CACTI_PATH_LIBRARY . '/utility.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 we are to save this form, instead of display it
if (isrv('selected_items')) { 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 ($selected_items != false) {
if (gnrv('drp_action') == '1') { // delete if (gnrv('drp_action') == '1') { // delete
@@ -199,16 +200,9 @@ function form_actions() : void {
$p_list = ''; $p_list = '';
$p_array = []; $p_array = [];
// loop through each of the data queries and process them foreach (PackageListFilter::selectedIdsFromPost() as $id) {
foreach ($_POST as $var => $val) { $p_list .= '<li>' . htmle(db_fetch_cell_prepared('SELECT name FROM package_repositories WHERE id = ?', [$id])) . '</li>';
if (preg_match('/^chk_([0-9]+)$/', $var, $matches)) { $p_array[] = $id;
// ================= input validation =================
input_validate_input_number($matches[1]);
// ====================================================
$p_list .= '<li>' . htmle(db_fetch_cell_prepared('SELECT name FROM package_repositories WHERE id = ?', [$matches[1]])) . '</li>';
$p_array[] = $matches[1];
}
} }
top_header(); top_header();
@@ -271,7 +265,7 @@ function form_actions() : void {
print "<tr> print "<tr>
<td class='saveRow'> <td class='saveRow'>
<input type='hidden' name='action' value='actions'> <input type='hidden' name='action' value='actions'>
<input type='hidden' name='selected_items' value='" . serialize($p_array) . "'> <input type='hidden' name='selected_items' value='" . htmle(PackageListFilter::encodeSelectedIds($p_array)) . "'>
<input type='hidden' name='drp_action' value='" . htmle(gnrv('drp_action')) . "'> <input type='hidden' name='drp_action' value='" . htmle(gnrv('drp_action')) . "'>
$save_html $save_html
</td> </td>
@@ -438,27 +432,23 @@ function repos() : void {
$pageFilter->rows_label = __('Repos'); $pageFilter->rows_label = __('Repos');
$pageFilter->render(); $pageFilter->render();
if (grv('rows') == '-1') { $filter = PackageListFilter::fromRequest((int) read_config_option('num_rows_table'));
$rows = read_config_option('num_rows_table');
} else {
$rows = grv('rows');
}
$sql_where = ''; $sql_where = '';
$sql_params = []; $sql_params = [];
// form the 'where' clause for our main sql query // form the 'where' clause for our main sql query
if (grv('filter') != '') { if ($filter->hasFilter()) {
$sql_where = ($sql_where != '' ? ' AND ' : 'WHERE ') . $sql_where = ($sql_where != '' ? ' AND ' : 'WHERE ') .
'(name LIKE ? OR repo_branch LIKE ? OR repo_location LIKE ?)'; '(name LIKE ? OR repo_branch LIKE ? OR repo_location LIKE ?)';
$sql_params[] = '%' . grv('filter') . '%'; $sql_params[] = '%' . $filter->filter() . '%';
$sql_params[] = '%' . grv('filter') . '%'; $sql_params[] = '%' . $filter->filter() . '%';
$sql_params[] = '%' . grv('filter') . '%'; $sql_params[] = '%' . $filter->filter() . '%';
} }
$sql_order = get_order_string(); $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(*) $total_rows = db_fetch_cell_prepared("SELECT COUNT(*)
FROM package_repositories 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'); form_start('package_repos.php', 'chk');
@@ -510,15 +500,15 @@ function repos() : void {
html_start_box('', '100%', false, 3, 'center', ''); 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)) { if (cacti_sizeof($repos)) {
foreach ($repos as $repo) { foreach ($repos as $repo) {
form_alternate_row('line' . $repo['id'], true); 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($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_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['enabled'] == 'on' ? __('Yes') : __('No'), $repo['id'], '', 'center');
form_selectable_cell($repo['default'] == 'on' ? __('Yes') : __('No'), $repo['id'], '', 'center'); form_selectable_cell($repo['default'] == 'on' ? __('Yes') : __('No'), $repo['id'], '', 'center');
+85
View File
@@ -0,0 +1,85 @@
<?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 |
+-------------------------------------------------------------------------+
*/
require_once dirname(__DIR__) . '/Helpers/CactiStubs.php';
require_once dirname(__DIR__, 2) . '/include/global.php';
require_once dirname(__DIR__, 2) . '/lib/PackageListFilter.php';
beforeEach(function () {
global $_CACTI_REQUEST;
$_REQUEST = [];
$_GET = [];
$_POST = [];
$_CACTI_REQUEST = [];
});
test('package list filter uses default row count when rows are defaulted', function () {
set_request_var('filter', 'alpha');
set_request_var('rows', '-1');
set_request_var('page', '2');
set_request_var('sort_column', 'name');
set_request_var('sort_direction', 'ASC');
$filter = PackageListFilter::fromRequest(25);
expect($filter->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();
});
+138
View File
@@ -0,0 +1,138 @@
<?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 |
+-------------------------------------------------------------------------+
*/
$options = getopt('', ['base:', 'head:']);
$base = $options['base'] ?? '';
$head = $options['head'] ?? 'HEAD';
$files = cli_file_args($argv);
if ($files === []) {
$files = changed_php_files($base, $head);
}
$patterns = [
'raw-superglobal-request' => [
'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;
}