ARGUS WordPress Defence 1.0.0 — first production release
Automatic WordPress security: local firewall, malware and file-integrity scanning, vulnerability protection, quarantine, scheduled backups, an optional page cache, and automatic global threat intelligence. See README.md for installation, update, and uninstall instructions.
This commit is contained in:
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Production release packaging -- docs/design/production-distribution-and-updates.md
|
||||
# sections 1-3, 16, 18. Builds the public download ZIP from a clean git
|
||||
# checkout (git archive -- never the working tree, so uncommitted local
|
||||
# files can never leak into a release), strips comments/whitespace
|
||||
# (not obfuscation -- see strip-comments.php's own header), and audits
|
||||
# the result against an explicit allowlist before packaging, failing
|
||||
# closed on anything unexpected rather than silently shipping it.
|
||||
#
|
||||
# Usage: bin/build-release.sh [git-ref] (defaults to HEAD)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REF="${1:-HEAD}"
|
||||
BUILD_DIR="$(mktemp -d)"
|
||||
STAGE_DIR="${BUILD_DIR}/argus-wordpress-defence"
|
||||
OUT_DIR="${REPO_ROOT}/dist"
|
||||
|
||||
cleanup() { rm -rf "${BUILD_DIR}"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Packaging ${REF} from a clean checkout (git archive, not the working tree)"
|
||||
mkdir -p "${STAGE_DIR}"
|
||||
git -C "${REPO_ROOT}" archive "${REF}" | tar -x -C "${STAGE_DIR}"
|
||||
|
||||
echo "==> Removing dev-only paths not meant for the production artifact"
|
||||
rm -rf \
|
||||
"${STAGE_DIR}/docs" \
|
||||
"${STAGE_DIR}/tests" \
|
||||
"${STAGE_DIR}/README.md" \
|
||||
"${STAGE_DIR}/ARGUS_WORDPRESS_SECURITY_ARCHITECTURE.md" \
|
||||
"${STAGE_DIR}/.gitignore" \
|
||||
"${STAGE_DIR}/bin"
|
||||
|
||||
echo "==> Allowlist audit -- fail closed on anything unexpected"
|
||||
UNEXPECTED=0
|
||||
while IFS= read -r -d '' item; do
|
||||
rel="${item#"${STAGE_DIR}"/}"
|
||||
case "$rel" in
|
||||
argus-wordpress-defence.php|uninstall.php|readme.txt) ;;
|
||||
admin|admin/*|includes|includes/*|mu-loader|mu-loader/*|assets|assets/*|languages|languages/*) ;;
|
||||
*)
|
||||
if [ -f "$item" ]; then
|
||||
echo " UNEXPECTED FILE: ${rel}"
|
||||
UNEXPECTED=1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done < <(find "${STAGE_DIR}" -print0)
|
||||
|
||||
# Reject known-forbidden patterns explicitly, even inside allowlisted dirs.
|
||||
if find "${STAGE_DIR}" \( -name "*.key" -o -name "*.pem" -o -name ".env*" -o -name "*.map" \) | grep -q .; then
|
||||
echo " FORBIDDEN FILE TYPE found (key/pem/.env/source map)"
|
||||
UNEXPECTED=1
|
||||
fi
|
||||
|
||||
if [ "${UNEXPECTED}" -ne 0 ]; then
|
||||
echo "==> BUILD FAILED: allowlist audit found unexpected content. Nothing was packaged."
|
||||
exit 1
|
||||
fi
|
||||
echo " clean -- only allowlisted paths present"
|
||||
|
||||
# Read the version BEFORE stripping -- it lives inside the plugin
|
||||
# header's own DocBlock comment, which php_strip_whitespace() below
|
||||
# would otherwise remove before this ever got a chance to read it.
|
||||
VERSION="$(grep -oP '(?<=Version:)\s*\K\S+' "${STAGE_DIR}/argus-wordpress-defence.php" | head -1)"
|
||||
if [ -z "${VERSION}" ]; then
|
||||
echo "==> BUILD FAILED: could not read plugin version from argus-wordpress-defence.php"
|
||||
exit 1
|
||||
fi
|
||||
echo "==> Packaging version ${VERSION}"
|
||||
|
||||
echo "==> Stripping comments/whitespace (not obfuscation -- see bin/strip-comments.php)"
|
||||
php "${REPO_ROOT}/bin/strip-comments.php" "${STAGE_DIR}"
|
||||
|
||||
mkdir -p "${OUT_DIR}"
|
||||
ZIP_PATH="${OUT_DIR}/argus-wordpress-defence-${VERSION}.zip"
|
||||
rm -f "${ZIP_PATH}"
|
||||
# Prefer the `zip` CLI when present (most CI runners have it); fall
|
||||
# back to PHP's ZipArchive (bin/zip-directory.php) for a host that
|
||||
# only has the PHP extension -- either is a real, complete archive, no
|
||||
# feature difference between the two paths.
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
( cd "${BUILD_DIR}" && zip -rq "${ZIP_PATH}" "argus-wordpress-defence" )
|
||||
else
|
||||
php "${REPO_ROOT}/bin/zip-directory.php" "${STAGE_DIR}" "${ZIP_PATH}" "argus-wordpress-defence"
|
||||
fi
|
||||
|
||||
SHA256="$(sha256sum "${ZIP_PATH}" | cut -d' ' -f1)"
|
||||
echo "${SHA256} $(basename "${ZIP_PATH}")" > "${ZIP_PATH}.sha256"
|
||||
|
||||
echo "==> Done"
|
||||
echo " ${ZIP_PATH}"
|
||||
echo " SHA-256: ${SHA256}"
|
||||
echo " Size: $(du -h "${ZIP_PATH}" | cut -f1)"
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
array_shift( $argv );
|
||||
$zip_path = $argv[0] ?? null;
|
||||
$package_url = $argv[1] ?? null;
|
||||
$critical = in_array( '--critical', $argv, true );
|
||||
|
||||
if ( ! $zip_path || ! $package_url || ! file_exists( $zip_path ) ) {
|
||||
fwrite( STDERR, "Usage: ARGUS_RELEASE_SECRET_KEY=<base64> php generate-manifest.php <zip-path> <package-url> [--critical]\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$secret_b64 = getenv( 'ARGUS_RELEASE_SECRET_KEY' );
|
||||
if ( ! $secret_b64 ) {
|
||||
fwrite( STDERR, "ARGUS_RELEASE_SECRET_KEY environment variable is not set.\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
$secret_key = base64_decode( $secret_b64, true );
|
||||
if ( false === $secret_key || SODIUM_CRYPTO_SIGN_SECRETKEYBYTES !== strlen( $secret_key ) ) {
|
||||
fwrite( STDERR, "ARGUS_RELEASE_SECRET_KEY is not a valid base64-encoded Ed25519 secret key.\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ( true !== $zip->open( $zip_path ) ) {
|
||||
fwrite( STDERR, "Could not open {$zip_path}\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
$header = null;
|
||||
for ( $i = 0; $i < $zip->numFiles; $i++ ) {
|
||||
$name = $zip->getNameIndex( $i );
|
||||
if ( preg_match( '#(^|/)argus-wordpress-defence\.php$#', $name ) ) {
|
||||
$header = $zip->getFromIndex( $i );
|
||||
break;
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
if ( ! $header ) {
|
||||
fwrite( STDERR, "Could not find argus-wordpress-defence.php inside the archive.\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
preg_match( '/Version:\s*([^\r\n]+)/', $header, $m_version );
|
||||
preg_match( '/Requires at least:\s*([^\r\n]+)/', $header, $m_wp );
|
||||
preg_match( '/Requires PHP:\s*([^\r\n]+)/', $header, $m_php );
|
||||
|
||||
$version = trim( $m_version[1] ?? '' );
|
||||
$min_wp = trim( $m_wp[1] ?? '0' );
|
||||
$min_php = trim( $m_php[1] ?? '0' );
|
||||
|
||||
if ( '' === $version ) {
|
||||
fwrite( STDERR, "Could not read Version from the archive's plugin header.\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$sha256 = hash_file( 'sha256', $zip_path );
|
||||
|
||||
$payload = array(
|
||||
'version' => $version,
|
||||
'released_at' => gmdate( 'Y-m-d\TH:i:s\Z' ),
|
||||
'package_url' => $package_url,
|
||||
'sha256' => $sha256,
|
||||
'min_php' => $min_php,
|
||||
'min_wp' => $min_wp,
|
||||
'critical' => $critical,
|
||||
);
|
||||
|
||||
$canonical = wp_json_encode_stable( $payload );
|
||||
$signature = sodium_crypto_sign_detached( $canonical, $secret_key );
|
||||
|
||||
$manifest = $payload;
|
||||
$manifest['signature'] = base64_encode( $signature );
|
||||
|
||||
echo json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n";
|
||||
|
||||
function wp_json_encode_stable( array $payload ) {
|
||||
$ordered = array();
|
||||
foreach ( array( 'version', 'released_at', 'package_url', 'sha256', 'min_php', 'min_wp', 'critical' ) as $key ) {
|
||||
$ordered[ $key ] = $payload[ $key ];
|
||||
}
|
||||
return json_encode( $ordered, JSON_UNESCAPED_SLASHES );
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
$dir = $argv[1] ?? null;
|
||||
if ( ! $dir || ! is_dir( $dir ) ) {
|
||||
fwrite( STDERR, "Usage: php strip-comments.php <directory>\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS )
|
||||
);
|
||||
|
||||
$count = 0;
|
||||
$skipped = 0;
|
||||
foreach ( $iterator as $file ) {
|
||||
if ( 'php' !== strtolower( $file->getExtension() ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( 'argus-wordpress-defence.php' === $file->getFilename() ) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$path = $file->getPathname();
|
||||
$stripped = php_strip_whitespace( $path );
|
||||
if ( '' === trim( (string) $stripped ) ) {
|
||||
fwrite( STDERR, "WARNING: stripping produced empty output for {$path}, leaving original untouched\n" );
|
||||
continue;
|
||||
}
|
||||
file_put_contents( $path, $stripped );
|
||||
$count++;
|
||||
}
|
||||
|
||||
echo "Stripped comments/whitespace from {$count} PHP files ({$skipped} skipped: plugin header file).\n";
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
/**
|
||||
* One-time production-cleanup pass: removes explanatory development
|
||||
* comments from the plugin source, keeping only what WordPress or
|
||||
* PHPCS actually need. Run once against the source tree, not part of
|
||||
* the release build pipeline (that's bin/strip-comments.php, which
|
||||
* does a separate whitespace-minification pass over a packaged copy).
|
||||
*
|
||||
* Kept:
|
||||
* - the main plugin bootstrap file's header DocBlock (Plugin Name: ...),
|
||||
* required for WordPress to recognise the plugin at all.
|
||||
* - single-line `// phpcs:ignore` / `phpcs:disable` / `phpcs:enable`
|
||||
* directives, which suppress specific static-analysis false
|
||||
* positives rather than narrate implementation decisions.
|
||||
* Removed: every other // and /* *\/ comment, including docblocks.
|
||||
*
|
||||
* Usage: php bin/strip-dev-comments.php <file-or-dir> [<file-or-dir> ...]
|
||||
*/
|
||||
|
||||
function argus_should_keep_comment( string $text, bool $is_first_token_in_bootstrap_file ): bool {
|
||||
if ( $is_first_token_in_bootstrap_file && false !== strpos( $text, 'Plugin Name:' ) ) {
|
||||
return true;
|
||||
}
|
||||
$trimmed = ltrim( $text, "/ \t" );
|
||||
return 0 === stripos( $trimmed, 'phpcs:' );
|
||||
}
|
||||
|
||||
function argus_strip_file( string $path ): void {
|
||||
$source = file_get_contents( $path );
|
||||
$tokens = token_get_all( $source );
|
||||
$is_bootstrap = ( 'argus-wordpress-defence.php' === basename( $path ) );
|
||||
|
||||
$out = '';
|
||||
$seen_first_comment = false;
|
||||
foreach ( $tokens as $token ) {
|
||||
if ( is_array( $token ) ) {
|
||||
list( $id, $text ) = $token;
|
||||
if ( T_COMMENT === $id || T_DOC_COMMENT === $id ) {
|
||||
$is_first = $is_bootstrap && ! $seen_first_comment;
|
||||
$seen_first_comment = true;
|
||||
if ( argus_should_keep_comment( $text, $is_first ) ) {
|
||||
$out .= $text;
|
||||
}
|
||||
// else: drop the comment text entirely (whitespace/newlines
|
||||
// around it are separate whitespace tokens, left untouched).
|
||||
continue;
|
||||
}
|
||||
$out .= $text;
|
||||
} else {
|
||||
$out .= $token;
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse runs of 3+ blank lines left behind by removed comment
|
||||
// blocks down to a single blank line, purely cosmetic.
|
||||
$out = preg_replace( "/\n{3,}/", "\n\n", $out );
|
||||
|
||||
file_put_contents( $path, $out );
|
||||
}
|
||||
|
||||
function argus_collect_php_files( array $paths ): array {
|
||||
$files = array();
|
||||
foreach ( $paths as $p ) {
|
||||
if ( is_dir( $p ) ) {
|
||||
$it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $p, FilesystemIterator::SKIP_DOTS ) );
|
||||
foreach ( $it as $f ) {
|
||||
if ( 'php' === strtolower( $f->getExtension() ) ) {
|
||||
$files[] = $f->getPathname();
|
||||
}
|
||||
}
|
||||
} elseif ( is_file( $p ) ) {
|
||||
$files[] = $p;
|
||||
}
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
$args = array_slice( $argv, 1 );
|
||||
if ( empty( $args ) ) {
|
||||
fwrite( STDERR, "Usage: php bin/strip-dev-comments.php <file-or-dir> [...]\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$files = argus_collect_php_files( $args );
|
||||
$count = 0;
|
||||
foreach ( $files as $file ) {
|
||||
argus_strip_file( $file );
|
||||
$count++;
|
||||
}
|
||||
echo "Stripped development comments from {$count} PHP files.\n";
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
array_shift( $argv );
|
||||
list( $source_dir, $dest_zip, $root_name ) = $argv + array( null, null, null );
|
||||
|
||||
if ( ! $source_dir || ! $dest_zip || ! $root_name || ! is_dir( $source_dir ) ) {
|
||||
fwrite( STDERR, "Usage: php zip-directory.php <source-dir> <dest-zip> <root-name-in-zip>\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
if ( ! class_exists( 'ZipArchive' ) ) {
|
||||
fwrite( STDERR, "PHP's zip extension is not available on this host -- cannot build the release archive here.\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ( true !== $zip->open( $dest_zip, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
|
||||
fwrite( STDERR, "Could not create {$dest_zip}\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$source_dir = rtrim( $source_dir, '/' );
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator( $source_dir, FilesystemIterator::SKIP_DOTS ),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ( $iterator as $item ) {
|
||||
$relative = $root_name . '/' . ltrim( str_replace( $source_dir, '', $item->getPathname() ), '/' );
|
||||
if ( $item->isDir() ) {
|
||||
$zip->addEmptyDir( $relative );
|
||||
} else {
|
||||
$zip->addFile( $item->getPathname(), $relative );
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
echo "Wrote {$dest_zip}\n";
|
||||
Reference in New Issue
Block a user