Нативен PHP 8.3: Неделни известувања за безбедносниот резултат на веб-страницата за сопственици на мали бизниси
Веб-страницата може да остане онлајн додека тивко станува помалку безбедна. Се менува обновувањето на сертификатот, безбедносно заглавие исчезнува при редизајн или ажурирање на хостингот ги ослабува заштитите на прелистувачот. На сопствениците на мали бизниси ретко им треба уште една контролна табла; им треба јасно предупредување кога нешто се влошило.
Овој туторијал го гради тоа предупредување како Native PHP 8.3 апликација. Еднаш неделно, таа испраќа една јавна HTTPS URL-адреса до Website Security Analyzer, ги мапира вратениот резултат, наодите групирани по сериозност, TLS деталите и препораките, го споредува резултатот со претходниот успешен резултат и му испраќа е-пошта на сопственикот само кога резултатот ќе се намали.
Анализата е ограничена и неинвазивна. Таа ја проценува јавната HTTPS и безбедносната поставеност во прелистувачот; не е пенетрационен тест, искористување на ранливости, автентицирано скенирање или гаранција за безбедност.
Добијте пристап и копирајте го сервисниот токен
Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
- Отворете ја страницата на услугата Website Security Analyzer.
- Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Најдете го панелот Service token и копирајте го токенот со опсег на услугата.
- Веднаш преместете го токенот во конфигурација поддржана од променливи на околината.
Регенерирањето на сервисниот токен го поништува претходно активниот токен, затоа координирајте ја ротацијата со распоредувањето. Ова не е услуга без токен: секое барање мора да се автентицира со Bearer токен, X-API-Token заглавие или документираниот параметар за токен во барањето. Имплементацијата подолу користи Bearer токен бидејќи го задржува акредитивот надвор од URL-адресите и вообичаените дневници за пристап.
Потврдете го API-договорот пред да го изградите распоредувачот
Точното барање е POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Неговото JSON тело содржи url. Тестирајте ја сметката со едно минимално барање, заменувајќи го копираниот токен само во вашата приватна терминалска сесија:
curl --request POST \
--url https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website \
--header 'Accept: application/json' \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--header 'Content-Type: application/json' \
--data '{"url":"https://www.example.com"}'
Не ги вметнувајте одговорот или токенот во тикети, слики од екран, фикстури или контрола на изворниот код. Создадете локална .env датотека, исклучете ја од Git и користете местодржачи во .env.example:
SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_URL=https://www.example.com
[email protected]
[email protected]
STATE_FILE=/srv/site-watch/var/security-score.json
Одржете ја архитектурата мала и експлицитна
На апликацијата ѝ се потребни само четири продукциски одговорности: cURL транспорт, API граница што валидира надворешни податоци, неделен случај на употреба и адаптери за состојба и е-пошта. База на податоци и редица би додале оперативен товар без да подобрат ниту една неделна проверка за една страница.
Датотеката за состојба е соодветна бидејќи само еден закажан процес запишува еден резултат. Заклучувањето на процесот спречува преклопување на извршувањата, додека атомското преименување ги спречува читателите да набљудуваат делумно запишана датотека. Ако неколку апликациски јазли ќе ја извршуваат проверката, заменете ги двата механизми со споделено складиште и дистрибуирано заклучување.
Користете Composer за автоматско вчитување, вчитување на околината и PHPUnit:
{
"name": "example/site-watch",
"require": {
"php": "^8.3",
"ext-curl": "*",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"classmap": ["src/"]
},
"autoload-dev": {
"classmap": ["tests/"]
},
"scripts": {
"test": "phpunit"
}
}
composer install
mkdir -p src bin tests var
chmod 700 var
printf '%s\n' '.env' '/var/' >> .gitignore
composer dump-autoload
Резултирачкиот проект содржи src/SecurityAnalyzer.php, src/WeeklyCheck.php, bin/check-security.php, tests/SecurityAnalyzerTest.php, composer.json и приватната .env.
Изградете одбранбена API граница
Надворешниот JSON е недоверлив влез дури и кога доаѓа од услуга што намерно ја користите. Маперот подолу пребарува ограничена длабочина на вгнездување за документираните полиња на анализата, а потоа ги валидира нивните општи облици без да измислува TLS потполиња или атрибути на наоди. Така се зачувува одговорот на услугата, додека се спречува неправилно форматираните податоци да продрат подлабоко во апликацијата.
<?php
declare(strict_types=1);
namespace App;
use Closure;
use JsonException;
use RuntimeException;
use UnexpectedValueException;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public array $headers = [],
) {}
}
interface HttpTransport
{
public function postJson(string $url, string $token, array $body): HttpResponse;
}
final class TransportException extends RuntimeException {}
final class CurlTransport implements HttpTransport
{
public function postJson(string $url, string $token, array $body): HttpResponse
{
$headers = [];
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 3000,
CURLOPT_TIMEOUT_MS => 15000,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
$bodyText = curl_exec($handle);
if ($bodyText === false) {
throw new TransportException(curl_error($handle));
}
return new HttpResponse(
(int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
$bodyText,
$headers,
);
}
}
final readonly class Analysis
{
public function __construct(
public float $score,
public array $findingsBySeverity,
public array $tls,
public array $recommendations,
) {}
}
interface Analyzer
{
public function analyze(string $url): Analysis;
}
final class AnalyzerException extends RuntimeException
{
public function __construct(
string $message,
public readonly string $kind,
public readonly ?int $status = null,
) {
parent::__construct($message);
}
}
final class SecurityAnalyzerClient implements Analyzer
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
private readonly Closure $sleep;
public function __construct(
private readonly HttpTransport $transport,
private readonly string $token,
?callable $sleep = null,
) {
if ($token === '') {
throw new RuntimeException('SECURITY_ANALYZER_TOKEN is empty');
}
$this->sleep = Closure::fromCallable(
$sleep ?? static fn (int $milliseconds) => usleep($milliseconds * 1000)
);
}
public function analyze(string $url): Analysis
{
if (filter_var($url, FILTER_VALIDATE_URL) === false
|| parse_url($url, PHP_URL_SCHEME) !== 'https') {
throw new RuntimeException('WEBSITE_URL must be a valid HTTPS URL');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->postJson(
self::ENDPOINT,
$this->token,
['url' => $url],
);
} catch (TransportException $exception) {
if ($attempt === 3) {
throw new AnalyzerException(
'Network failure after retries',
'network',
previous: $exception,
);
}
($this->sleep)($this->backoffMilliseconds($attempt, null));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
return $this->map($response->body);
}
$retryable = $response->status === 408
|| $response->status === 429
|| $response->status >= 500;
if ($retryable && $attempt < 3) {
($this->sleep)($this->backoffMilliseconds(
$attempt,
$response->headers['retry-after'] ?? null,
));
continue;
}
$kind = match (true) {
in_array($response->status, [401, 403], true) => 'authentication',
in_array($response->status, [400, 422], true) => 'validation',
$response->status === 429 => 'quota',
$response->status >= 500 => 'upstream',
default => 'http',
};
throw new AnalyzerException(
'Analyzer request failed with HTTP ' . $response->status,
$kind,
$response->status,
);
}
throw new AnalyzerException('Analyzer retry loop exhausted', 'internal');
}
private function backoffMilliseconds(int $attempt, ?string $retryAfter): int
{
if ($retryAfter !== null && ctype_digit($retryAfter)) {
return min((int) $retryAfter, 30) * 1000;
}
return (250 * (2 ** ($attempt - 1))) + random_int(0, 100);
}
private function map(string $body): Analysis
{
try {
$decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new AnalyzerException('Analyzer returned invalid JSON', 'response', previous: $exception);
}
if (!is_array($decoded)) {
throw new AnalyzerException('Analyzer response is not an object', 'response');
}
$data = $this->locateAnalysis($decoded);
if (!is_numeric($data['score'])
|| !is_array($data['findings'])
|| !is_array($data['tls'])
|| !is_array($data['recommendations'])) {
throw new AnalyzerException('Analyzer response has invalid field types', 'response');
}
$groups = [];
foreach ($data['findings'] as $severity => $items) {
if (!is_string($severity) || $severity === '' || !is_array($items)) {
throw new AnalyzerException('Findings are not grouped by severity', 'response');
}
$groups[$severity] = array_values($items);
}
return new Analysis(
(float) $data['score'],
$groups,
$data['tls'],
array_values($data['recommendations']),
);
}
private function locateAnalysis(array $node, int $depth = 0): array
{
$required = ['score', 'findings', 'tls', 'recommendations'];
if (count(array_intersect($required, array_keys($node))) === count($required)) {
return $node;
}
if ($depth < 4) {
foreach ($node as $value) {
if (is_array($value)) {
try {
return $this->locateAnalysis($value, $depth + 1);
} catch (UnexpectedValueException) {
}
}
}
}
throw new UnexpectedValueException('Analysis fields were not found');
}
}
Политиката за повторни обиди е намерно тесна. Неуспесите на поврзувањето, истекувањата на времето, HTTP 408, HTTP 429 и неуспесите на серверот може да бидат привремени. Неуспесите при валидација и автентикација не се повторуваат слепо. Нумеричка вредност Retry-After се почитува, но е ограничена на 30 секунди за еден закажан процес да не може да застане на неодредено време.
Споредете ги резултатите и испратете една корисна е-пошта
Случајот на употреба го зачувува новиот резултат по успешно известување. Ако испораката на е-пошта не успее, стариот резултат останува на место, овозможувајќи следното извршување повторно да се обиде. Овој редослед зависи од заклучувањето со еден процес на ниво на распоредување, прикажано подоцна.
<?php
declare(strict_types=1);
namespace App;
use RuntimeException;
interface ScoreStore
{
public function load(string $url): ?float;
public function save(string $url, float $score): void;
}
final readonly class FileScoreStore implements ScoreStore
{
public function __construct(private string $path) {}
public function load(string $url): ?float
{
if (!is_file($this->path)) {
return null;
}
$data = json_decode((string) file_get_contents($this->path), true);
return is_array($data)
&& ($data['url'] ?? null) === $url
&& is_numeric($data['score'] ?? null)
? (float) $data['score']
: null;
}
public function save(string $url, float $score): void
{
$directory = dirname($this->path);
$temporary = tempnam($directory, 'score-');
if ($temporary === false) {
throw new RuntimeException('Cannot create temporary state file');
}
try {
$json = json_encode([
'url' => $url,
'score' => $score,
'checked_at' => gmdate(DATE_ATOM),
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
if (file_put_contents($temporary, $json, LOCK_EX) === false) {
throw new RuntimeException('Cannot write score state');
}
chmod($temporary, 0600);
if (!rename($temporary, $this->path)) {
throw new RuntimeException('Cannot replace score state');
}
} finally {
if (is_file($temporary)) {
unlink($temporary);
}
}
}
}
interface OwnerMailer
{
public function scoreDropped(
string $recipient,
string $url,
float $previous,
Analysis $analysis,
): void;
}
final readonly class NativeOwnerMailer implements OwnerMailer
{
public function __construct(private string $from) {}
public function scoreDropped(
string $recipient,
string $url,
float $previous,
Analysis $analysis,
): void {
foreach ([$recipient, $this->from] as $address) {
if (filter_var($address, FILTER_VALIDATE_EMAIL) === false
|| preg_match('/[\r\n]/', $address)) {
throw new RuntimeException('Invalid email configuration');
}
}
$counts = [];
foreach ($analysis->findingsBySeverity as $severity => $items) {
$counts[] = $severity . ': ' . count($items);
}
$message = implode("\n", [
'The website security score dropped.',
'Website: ' . $url,
'Previous score: ' . $previous,
'Current score: ' . $analysis->score,
'Finding counts: ' . ($counts ? implode(', ', $counts) : 'none'),
'Review the analyzer recommendations before making changes.',
'',
'This is a bounded public HTTPS posture analysis, not a penetration test.',
]);
if (!mail(
$recipient,
'Website security score dropped',
$message,
['From' => $this->from, 'Content-Type' => 'text/plain; charset=UTF-8'],
)) {
throw new RuntimeException('Mail handoff failed');
}
}
}
final readonly class WeeklyCheck
{
public function __construct(
private Analyzer $analyzer,
private ScoreStore $store,
private OwnerMailer $mailer,
) {}
public function run(string $url, string $owner): string
{
$analysis = $this->analyzer->analyze($url);
$previous = $this->store->load($url);
if ($previous !== null && $analysis->score < $previous) {
$this->mailer->scoreDropped($owner, $url, $previous, $analysis);
$result = 'drop_notified';
} else {
$result = $previous === null ? 'baseline_created' : 'no_drop';
}
$this->store->save($url, $analysis->score);
return $result;
}
}
PHP mail() известува само дали локалниот систем за пошта ја прифатил пораката; не докажува испорака. Затоа продукциските хостови имаат потреба од конфигуриран MTA или relay со следење на повратни пораки. Границата OwnerMailer исто така овозможува едноставна замена со одобрен адаптер за трансакциска е-пошта подоцна, без промена на логиката за резултатите.
Поврзете ја извршната датотека и структурираните дневници
<?php
declare(strict_types=1);
use App\CurlTransport;
use App\FileScoreStore;
use App\NativeOwnerMailer;
use App\SecurityAnalyzerClient;
use App\WeeklyCheck;
use Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
$required = static function (string $key): string {
$value = $_ENV[$key] ?? $_SERVER[$key] ?? null;
if (!is_string($value) || $value === '') {
throw new RuntimeException("Missing environment value: {$key}");
}
return $value;
};
try {
$url = $required('WEBSITE_URL');
$check = new WeeklyCheck(
new SecurityAnalyzerClient(
new CurlTransport(),
$required('SECURITY_ANALYZER_TOKEN'),
),
new FileScoreStore($required('STATE_FILE')),
new NativeOwnerMailer($required('MAIL_FROM')),
);
$result = $check->run($url, $required('OWNER_EMAIL'));
fwrite(STDOUT, json_encode([
'event' => 'security_check_completed',
'url_host' => parse_url($url, PHP_URL_HOST),
'result' => $result,
'time' => gmdate(DATE_ATOM),
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(0);
} catch (Throwable $exception) {
fwrite(STDERR, json_encode([
'event' => 'security_check_failed',
'exception' => $exception::class,
'message' => $exception->getMessage(),
'time' => gmdate(DATE_ATOM),
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(1);
}
Дневниците го содржат хостот, исходот, класата на исклучок и времето, но никогаш токенот, заглавието за авторизација, телото на одговорот или целосните наоди. Оперативно алармирајте при излези различни од нула или при недостиг на неделни настани за завршување. Е-поштата за безбедносниот резултат не треба да служи и како следење на неуспеси.
Тестирајте ги повторните обиди и правилото за пад без мрежа или е-пошта
Детерминистички лажен транспорт го прави однесувањето при повторни обиди тестираливо без чекање или трошење квота. Додадете го овој репрезентативен PHPUnit тест:
<?php
declare(strict_types=1);
use App\AnalyzerException;
use App\HttpResponse;
use App\HttpTransport;
use App\SecurityAnalyzerClient;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements HttpTransport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function postJson(string $url, string $token, array $body): HttpResponse
{
$response = $this->responses[$this->calls++] ?? null;
if (!$response instanceof HttpResponse) {
throw new RuntimeException('Fake response queue exhausted');
}
return $response;
}
}
final class SecurityAnalyzerTest extends TestCase
{
public function testRetriesRateLimitThenMapsAnalysis(): void
{
$transport = new FakeTransport([
new HttpResponse(429, '{}', ['retry-after' => '1']),
new HttpResponse(200, json_encode([
'score' => 82,
'findings' => ['high' => [], 'medium' => [['id' => 'example']]],
'tls' => ['present' => true],
'recommendations' => ['Review the reported medium finding'],
], JSON_THROW_ON_ERROR)),
]);
$delays = [];
$client = new SecurityAnalyzerClient(
$transport,
'test-token',
static function (int $milliseconds) use (&$delays): void {
$delays[] = $milliseconds;
},
);
$analysis = $client->analyze('https://www.example.com');
self::assertSame(2, $transport->calls);
self::assertSame([1000], $delays);
self::assertSame(82.0, $analysis->score);
self::assertCount(1, $analysis->findingsBySeverity['medium']);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$transport = new FakeTransport([new HttpResponse(401, '{}')]);
$client = new SecurityAnalyzerClient($transport, 'test-token', static fn () => null);
try {
$client->analyze('https://www.example.com');
self::fail('Expected AnalyzerException');
} catch (AnalyzerException $exception) {
self::assertSame('authentication', $exception->kind);
self::assertSame(1, $transport->calls);
}
}
}
vendor/bin/phpunit --testdox
php bin/check-security.php
Додадете посебни WeeklyCheck единечни случаи со имплементации во меморија на Analyzer, ScoreStore и OwnerMailer. Потврдете дека првиот резултат создава основна вредност без е-пошта, еднаков или повисок резултат не испраќа ништо, понизок резултат испраќа точно една порака и исклучок при испраќање пошта го остава претходниот резултат непроменет.
Распоредете еден заклучен неделен процес
Распоредете со composer install --no-dev --classmap-authoritative, чувајте ја .env надвор од контролата на изворниот код со режим 0600 и дајте му на сервисниот корисник пристап за запишување само до var. Потврдете дека хостот им верува на тековните сертификациски авторитети, дозволува излезен HTTPS и има функционална испорака на пошта.
systemd тајмер обезбедува трајно распоредување и дневници во журналот. Тајмерот подолу се извршува секој понеделник наутро во конфигурираната временска зона на системскиот менаџер, додава мало случајно доцнење, ги надоместува пропуштените извршувања по прекин и користи flock за да спречи преклопување:
# /etc/systemd/system/site-watch.service
[Unit]
Description=Weekly website security score check
After=network-online.target
[Service]
Type=oneshot
User=site-watch
WorkingDirectory=/srv/site-watch
EnvironmentFile=/srv/site-watch/.env
ExecStart=/usr/bin/flock -n /srv/site-watch/var/process.lock /usr/bin/php /srv/site-watch/bin/check-security.php
# /etc/systemd/system/site-watch.timer
[Unit]
Description=Run the website security score check weekly
[Timer]
OnCalendar=Mon *-*-* 08:00:00
RandomizedDelaySec=15m
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now site-watch.timer
sudo systemctl start site-watch.service
sudo systemctl status site-watch.service
sudo journalctl -u site-watch.service
Препознајте ги вообичаените продукциски неуспеси
- HTTP 401 или 403: потврдете ја активацијата на планот и распоредувањето на токенот. Ако токенот бил регенериран, заменете ја поништената вредност насекаде.
- HTTP 400 или 422: потврдете дека
WEBSITE_URLе јавна HTTPS URL-адреса. Не повторувајте непроменето барање. - HTTP 429: почитувајте го ограниченото доцнење за повторен обид, а потоа третирајте го продолженото одбивање како неуспех поради квота. Потврдете дека избраниот план го поддржува предвидениот распоред.
- Истекувања на времето или неуспеси на серверот: дозволете ги трите ограничени обиди, задржете го претходниот резултат, излезете со вредност различна од нула и алармирајте преку оперативен мониторинг.
- Нема е-пошта: проверете го журналот на услугата и локалните дневници за пошта. Успешното предавање преку
mail()не е доказ дека провајдерот на сопственикот ја прифатил пораката. - Основната вредност неочекувано е повторно создадена: проверете ги сопственоста на датотеката за состојба, трајноста, JSON интегритетот и дали
WEBSITE_URLсе променил.
Конечна контролна листа за верификација
- Планот на услугата е активен и неговиот токен со ограничен опсег постои само во заштитената конфигурација на околината.
- Точната POST крајна точка успева со JSON тело што содржи
url. - Конфигурираната цел користи јавен HTTPS.
- Почетното извршување запишува заштитена основна вредност и не испраќа известување за пад.
- Тестовите докажуваат повторен обид за привремен неуспех, неповторување при автентикација и одбранбено мапирање на одговорот.
- Контролиран тест со понизок резултат испраќа една е-пошта пред да ја замени зачуваната состојба.
- Тајмерот е овозможен, преклопувањето е заклучено, неуспесите излегуваат со вредност различна од нула и дневниците не откриваат акредитиви.
- Сопственикот разбира дека извештајот ја покрива јавната HTTPS поставеност и не е пенетрационен тест.
Најдобрата автоматизација за мал бизнис често е тивка: едно ограничено барање, една трајна споредба и една порака само кога вниманието е оправдано. Со строга валидација на границите, внимателни повторни обиди, заштитена состојба и набљудливо распоредување, неделниот резултат станува повеќе од број. Станува сигнал за рано предупредување што сопственикот навистина може да го користи.