Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrity checking system #3159

Merged
merged 21 commits into from
Apr 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@
/uploads/avatars/**
/node_modules/
composer.lock
checksums.json
/.node_cache/
/release/
150 changes: 150 additions & 0 deletions core/classes/Misc/IntegrityChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

class IntegrityChecker {

/**
* Files with relative paths starting with a string in this array are ignored
*/
const IGNORED_PATHS = [
'.git/',
'checksums.json',
'cache/',
'cache/logs/',
'cache/templates_c/',
'templates/', # The default template is included again, below
'modules/', # Default modules are included again, below
'uploads/',
];

/**
* Override a path within an ignored path to be included in the scan
*/
// TODO: Have constants for default template and module names somewhere, so it doesn't have to be hardcoded here (and in other code)
const INCLUDED_PATHS = [
'cache/.htaccess',
'templates/DefaultRevamp',
'modules/Cookie Consent',
'modules/Core',
'modules/Discord Integration',
'modules/Forum',
];

/**
* @return bool If the file should be ignored from integrity checking, according to IGNORED_PATHS and INCLUDED_PATHS.
*/
private static function isIgnored($path): bool {
foreach (self::INCLUDED_PATHS as $include) {
if (str_starts_with($path, $include)) {
return false;
}
}

foreach (self::IGNORED_PATHS as $ignore) {
if (str_starts_with($path, $ignore)) {
return true;
}
}

return false;
}

private static function checksumsPath(): string {
return ROOT_PATH . '/checksums.json';
}

/**
* Generate checksums for files recursively, ignoring files according to IGNORED_PATHS and INCLUDED_PATHS.
*
* @return array An associative array (relative file path as key, checksum as value).
*/
public static function generateChecksums(): array {
$checksums_dict = [];

// Iterate over all files, recursively
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(ROOT_PATH));

foreach ($iterator as $path) {
// Get path relative to Nameless root directory
$relative_path = substr($path, strlen(ROOT_PATH) + 1);

if (self::isIgnored($relative_path)) {
continue;
}

if (is_dir($path)) {
continue;
}

// Calculate SHA-256 hash for file
$hash = hash_file('sha256', $path);

$checksums_dict[$relative_path] = $hash;
}

return $checksums_dict;
}

/**
* Save checksum array to checksums file, in json format.
*
* @param array $checksums An associative array (relative file path as key, checksum as value).
*/
public static function saveChecksums(array $checksums): void {
$json = json_encode($checksums);
file_put_contents(self::checksumsPath(), $json);
}

/**
* Load checksums from checksums.json file into an associative array.
*
* @return array|null An associative array (relative file path as key, checksum as value) or null if the checksum
* file does not exist.
*/
public static function loadChecksums() {
if (!is_file(self::checksumsPath())) {
return null;
}

$json = file_get_contents(self::checksumsPath());
return json_decode($json, true);
}

/**
* Verify code integrity, by calculating checksums for files recursively, and comparing them to checksums in the
* checksums file.
*
* @return array Array of errors strings, empty if no issues were found.
*/
public static function verifyChecksums(): array {
$errors = [];

$expected_checksums = self::loadChecksums();

if ($expected_checksums == null) {
$errors[] = 'Checksums file is missing, integrity cannot be verified';
return $errors;
}

$actual_checksums = self::generateChecksums();

foreach ($actual_checksums as $path => $checksum) {
if (!array_key_exists($path, $expected_checksums)) {
$errors[] = 'Extra file: ' . $path;
continue;
}

if ($checksum !== $expected_checksums[$path]) {
$errors[] = 'Checksum mismatch: ' . $path;
}
}

foreach ($expected_checksums as $path => $checksum) {
if (!self::isIgnored($path) && !array_key_exists($path, $actual_checksums)) {
$errors[] = 'Missing file: ' . $path;
}
}

return $errors;
}

}
7 changes: 7 additions & 0 deletions dev/scripts/generate_checksums.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

const ROOT_PATH = __DIR__ . '/../..';
require ROOT_PATH . '/vendor/autoload.php';

$checksums = IntegrityChecker::generateChecksums();
IntegrityChecker::saveChecksums($checksums);
7 changes: 7 additions & 0 deletions dev/scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,15 @@ def create_archives(archive_path: str, cwd: str = '.'):
# Run npm and composer (production dependencies only)
subprocess.check_call(['npm', 'ci', '-q', '--cache', '.node_cache'],
stdout=PIPE)
subprocess.check_call(['composer', 'update'],
stdout=PIPE)
subprocess.check_call(['composer', 'install', '--no-dev', '--no-interaction'],
stdout=PIPE)

# Generate checksums
subprocess.check_call(['php', 'dev/scripts/generate_checksums.php'],
stdout=PIPE)

create_archives('release/nameless-deps-dist')

# Create archive with files changed since last update
Expand Down
17 changes: 17 additions & 0 deletions dev/scripts/verify_checksums.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

const ROOT_PATH = __DIR__ . '/../..';
require ROOT_PATH . '/vendor/autoload.php';

$errors = IntegrityChecker::verifyChecksums();

if (count($errors) === 0) {
echo "No errors found!\n";
return;
}

foreach ($errors as $error) {
echo $error . "\n";
}

exit(1);