Native PHP 8.3: Автоматизирајте безбедносни скенирања во продукција по распоредувањето
Распоредувањето може да успее, а притоа тивко да ја ослаби страницата што ја испорачува. Промена на прокси може да отстрани безбедносно заглавие, синџирот на сертификати може да е погрешно конфигуриран или нова политика за одговори може да им даде на прелистувачите помала заштита отколку претходното издание. Овие проблеми се надвор од опфатот на единичните тестови бидејќи се појавуваат дури откако јавната HTTPS патека е активна.
Овој туторијал изработува Native PHP 8.3 команда што го повикува Website Security Analyzer по секое продукциско распоредување. Таа врши ограничена, неинвазивна проверка на јавниот HTTPS и безбедносната состојба на прелистувачите, го пресликува резултатот во стабилен доменски објект, запишува артефакт од распоредувањето и враќа реален статус на процесот за автоматизација. Ова е сигнал за безбедносна регресија, а не пенетрационен тест.
Добијте пристап пред да пишувате код за интеграција
- Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
- Отворете ја страницата на услугата Website Security Analyzer. Изберете достапен Free, Plus или Pro план и завршете ја активацијата.
- Отворете ја официјалната документација за услугата. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.
- Зачувајте ја таа вредност во заштитената конфигурација на околината на проектот. Повторното генерирање на сервисниот токен го поништува претходно активниот токен, па ротацијата на токенот мора да ја ажурира продукциската конфигурација пред следното скенирање.
Оваа услуга бара автентикација. Таа прифаќа Bearer токен, заглавие X-API-Token или параметар за барање token. Имплементацијата подолу користи Bearer токен бидејќи акредитивите остануваат надвор од URL-адресите, историите на проксијата и вообичаените дневници за пристап.
Потврдете го API договорот
Точното барање е POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Неговото JSON тело содржи url. Пред да ја изградите командата, направете едно минимално барање од контролиран терминал:
curl --fail-with-body \
--connect-timeout 5 \
--max-time 30 \
--request POST \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Content-Type: application/json" \
--data '{"url":"https://www.example.com"}' \
https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
Не го внесувајте вистинскиот токен во историјата на школката на споделени системи. За продукција, креирајте датотека управувана од распоредувањето надвор од директориумот на изданието, како /var/www/example/shared/.env.production:
WEBSITE_SECURITY_TOKEN="YOUR_SERVICE_TOKEN"
PUBLIC_SITE_URL="https://www.example.com"
SECURITY_REPORT_PATH="/var/www/example/shared/security/latest.json"
Ограничете ја оваа датотека само на сметката за распоредување и никогаш не ја додавајте во репозиториум. Native PHP не вчитува автоматски датотеки со околински променливи, па нашата влезна точка ќе ја анализира оваа датотека изречно со INI_SCANNER_RAW.
Архитектура што одговара на мала продукциска апликација
Дизајнот има четири тесно дефинирани делови: cURL транспорт, API клиент со политика за повторни обиди, доменски пресликувач и конзолна команда. Куката по распоредувањето ја повикува командата дури откако новото издание е јавно достапно.
Извршувањето по распоредувањето значи дека неуспешното скенирање не може искрено да тврди дека изданието било спречено. Наместо тоа, командата завршува со статус различен од нула, ја зачувува распоредената страница и му дозволува на процесот да алармира човек или да започне изречно дизајниран работен тек за враќање назад. Ова избегнува поврзување на јавната достапност со привремен неуспех на анализаторот.
Користете ја оваа компактна структура:
security-scan/
├── composer.json
├── src/
│ ├── Http.php
│ └── SecurityAnalyzer.php
├── bin/
│ └── scan-production.php
└── tests/
└── SecurityAnalyzerClientTest.php
PHPUnit е единствената развојна зависност. Самата апликација користи PHP, JSON и cURL:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit tests"
}
}
Изградете ограничен cURL транспорт
Транспортот е одговорен за мрежната механика, но не знае ништо за толкувањето на оценката. Тој ги зачувува заглавијата на одговорот за клиентот да може да го почитува Retry-After и никогаш не ги запишува токенот или телото на одговорот во дневник.
<?php
// src/Http.php
declare(strict_types=1);
namespace App;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
interface Transport
{
public function send(string $token, array $payload): HttpResponse;
}
final class TransportException extends \RuntimeException {}
final class CurlTransport implements Transport
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
public function send(string $token, array $payload): HttpResponse
{
$headers = [];
$handle = curl_init(self::ENDPOINT);
if ($handle === false) {
throw new TransportException('Could not initialize cURL');
}
$body = json_encode($payload, JSON_THROW_ON_ERROR);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 5_000,
CURLOPT_TIMEOUT_MS => 30_000,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_POSTFIELDS => $body,
CURLOPT_HEADERFUNCTION => static function (
\CurlHandle $handle,
string $line
) use (&$headers): int {
$length = strlen($line);
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return $length;
},
]);
$responseBody = curl_exec($handle);
if ($responseBody === false) {
$message = curl_error($handle);
curl_close($handle);
throw new TransportException('Analyzer transport failed: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $headers, $responseBody);
}
}
Пресликајте го одговорот на границата на апликацијата
Резултатот од услугата вклучува оценка, наоди групирани според сериозност, TLS детали и препораки. Надворешните одговори никогаш не треба да минуваат низ апликацијата без валидација. Пресликувачот прифаќа само очекувани облици и не успева ако задолжителните податоци од договорот се неправилно форматирани.
Намерно ги задржува имињата на групите на наоди и TLS својствата, наместо да измислува затворен сет што доставениот договор не го гарантира.
<?php
// src/SecurityAnalyzer.php
declare(strict_types=1);
namespace App;
final readonly class SecurityReport
{
public function __construct(
public float $score,
public array $findings,
public array $tls,
public array $recommendations,
) {}
public static function fromArray(array $data): self
{
if (!isset($data['score']) || !is_numeric($data['score'])) {
throw new AnalyzerException('contract', 'Missing or invalid score');
}
foreach (['findings', 'tls', 'recommendations'] as $field) {
if (!isset($data[$field]) || !is_array($data[$field])) {
throw new AnalyzerException(
'contract',
'Missing or invalid ' . $field
);
}
}
$findings = [];
foreach ($data['findings'] as $severity => $items) {
if (is_string($severity) && is_array($items)) {
$findings[$severity] = $items;
}
}
$recommendations = array_values(array_filter(
$data['recommendations'],
static fn (mixed $item): bool => is_string($item)
));
return new self(
(float) $data['score'],
$findings,
$data['tls'],
$recommendations,
);
}
public function toArray(): array
{
return [
'score' => $this->score,
'findings' => $this->findings,
'tls' => $this->tls,
'recommendations' => $this->recommendations,
];
}
}
final class AnalyzerException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $httpStatus = null,
) {
parent::__construct($message);
}
}
final class SecurityAnalyzerClient
{
public function __construct(
private readonly Transport $transport,
private readonly \Closure $sleep =
new \Closure(),
) {}
public function analyze(string $url, string $token): SecurityReport
{
if (filter_var($url, FILTER_VALIDATE_URL) === false
|| parse_url($url, PHP_URL_SCHEME) !== 'https') {
throw new AnalyzerException('validation', 'A public HTTPS URL is required');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->send($token, ['url' => $url]);
} catch (TransportException $exception) {
if ($attempt === 3) {
throw new AnalyzerException('transport', $exception->getMessage());
}
($this->sleep)(250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$decoded = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException) {
throw new AnalyzerException('contract', 'Invalid JSON response');
}
if (!is_array($decoded)) {
throw new AnalyzerException('contract', 'Invalid response object');
}
return SecurityReport::fromArray($decoded);
}
$retryable = $response->status === 408
|| $response->status === 429
|| $response->status >= 500;
if ($retryable && $attempt < 3) {
$retryAfter = ctype_digit($response->headers['retry-after'] ?? '')
? (int) $response->headers['retry-after'] * 1_000
: 250 * (2 ** ($attempt - 1));
($this->sleep)(min($retryAfter, 8_000));
continue;
}
$kind = match ($response->status) {
400, 422 => 'validation',
401, 403 => 'authentication',
429 => 'quota',
default => $response->status >= 500 ? 'upstream' : 'http',
};
throw new AnalyzerException(
$kind,
'Analyzer request failed with HTTP ' . $response->status,
$response->status
);
}
throw new AnalyzerException('internal', 'Retry loop ended unexpectedly');
}
}
Заменете го стандардното затворање со место за пополнување во конструкторот со изречен механизам за спиење при составувањето, како што прави командата подолу. Неговото вбризгување ги прави тестовите за повторни обиди моментални и детерминистички. Не се прават повторни обиди за неуспеси при валидација и автентикација; повторувањето невалидна URL-адреса или поништен токен само троши квота и ја одложува дијагнозата.
Креирајте ја продукциската команда
Командата вчитува заштитена конфигурација, го повикува клиентот, го запишува извештајот атомски и емитува еден JSON настан во дневникот. Привремената датотека спречува читателите да забележат делумно запишан артефакт.
<?php
// bin/scan-production.php
declare(strict_types=1);
use App\AnalyzerException;
use App\CurlTransport;
use App\SecurityAnalyzerClient;
require dirname(__DIR__) . '/vendor/autoload.php';
$envFile = $argv[1] ?? '/var/www/example/shared/.env.production';
$values = parse_ini_file($envFile, false, INI_SCANNER_RAW);
if (!is_array($values)) {
fwrite(STDERR, "Could not load production environment\n");
exit(1);
}
$token = $values['WEBSITE_SECURITY_TOKEN'] ?? '';
$url = $values['PUBLIC_SITE_URL'] ?? '';
$reportPath = $values['SECURITY_REPORT_PATH'] ?? '';
if ($token === '' || $url === '' || $reportPath === '') {
fwrite(STDERR, "Required security scan configuration is missing\n");
exit(1);
}
$client = new SecurityAnalyzerClient(
new CurlTransport(),
static fn (int $milliseconds) => usleep($milliseconds * 1_000)
);
try {
$report = $client->analyze($url, $token);
$document = [
'scanned_at' => gmdate(DATE_ATOM),
'url' => $url,
'report' => $report->toArray(),
];
$json = json_encode(
$document,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
);
$temporaryPath = $reportPath . '.tmp';
if (file_put_contents($temporaryPath, $json . PHP_EOL, LOCK_EX) === false
|| !rename($temporaryPath, $reportPath)) {
throw new RuntimeException('Could not persist the scan report');
}
fwrite(STDOUT, json_encode([
'event' => 'website_security_scan_completed',
'url' => $url,
'score' => $report->score,
'finding_groups' => array_map('count', $report->findings),
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(0);
} catch (AnalyzerException $exception) {
fwrite(STDERR, json_encode([
'event' => 'website_security_scan_failed',
'url' => $url,
'kind' => $exception->kind,
'http_status' => $exception->httpStatus,
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(2);
} catch (Throwable $exception) {
fwrite(STDERR, json_encode([
'event' => 'website_security_scan_failed',
'url' => $url,
'kind' => 'local',
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(3);
}
Дневниците намерно ги изоставуваат токенот, телото на одговорот и деталните наоди. Испратете ги структурираните настани до истата локација за дневници како настаните за распоредување, а потоа алармирајте при излез различен од нула или при недостасување на настан за завршување. Чувајте ги деталните извештаи во складиште со контролиран пристап бидејќи наодите може да опишуваат слабости корисни за напаѓач.
Тестирајте повторни обиди и пресликување на одговорите без мрежа
Детерминистички лажен транспорт докажува конструкција на товарот, пресликување и однесување при неуспех без трошење квота или зависност од достапноста на услугата.
<?php
// tests/SecurityAnalyzerClientTest.php
declare(strict_types=1);
namespace Tests;
use App\HttpResponse;
use App\SecurityAnalyzerClient;
use App\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public array $requests = [];
public function __construct(private array $responses) {}
public function send(string $token, array $payload): HttpResponse
{
$this->requests[] = compact('token', 'payload');
return array_shift($this->responses);
}
}
final class SecurityAnalyzerClientTest extends TestCase
{
public function testMapsSuccessfulReport(): void
{
$transport = new FakeTransport([
new HttpResponse(200, [], json_encode([
'score' => 91,
'findings' => ['high' => [], 'low' => [['name' => 'example']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review browser policy.'],
], JSON_THROW_ON_ERROR)),
]);
$client = new SecurityAnalyzerClient($transport, static fn (int $ms) => null);
$report = $client->analyze('https://www.example.com', 'test-token');
self::assertSame(91.0, $report->score);
self::assertCount(1, $report->findings['low']);
self::assertSame(
['url' => 'https://www.example.com'],
$transport->requests[0]['payload']
);
}
public function testRetriesRateLimitThenSucceeds(): void
{
$transport = new FakeTransport([
new HttpResponse(429, ['retry-after' => '1'], ''),
new HttpResponse(200, [], json_encode([
'score' => 80,
'findings' => [],
'tls' => [],
'recommendations' => [],
], JSON_THROW_ON_ERROR)),
]);
$delays = [];
$client = new SecurityAnalyzerClient(
$transport,
static function (int $ms) use (&$delays): void {
$delays[] = $ms;
}
);
$client->analyze('https://www.example.com', 'test-token');
self::assertCount(2, $transport->requests);
self::assertSame([1_000], $delays);
}
}
Извршете composer install, а потоа composer test. Вредностите во фикстурата се синтетички податоци на границата на апликацијата, а не наведени мерења на услугата, и лажниот токен никогаш не може да се автентицира.
Безбедно поврзете го со распоредувањето
По префрлањето на симболичката врска на изданието и завршувањето на проверката за јавна подготвеност, повикајте:
php /var/www/example/current/bin/scan-production.php \
/var/www/example/shared/.env.production
Оставете му на процесот приближно време колку комбинираниот тајмаут и ограничениот прозорец за повторни обиди. Не го стартувајте во ненадгледувана школка во заднина. Забележете ги неговиот излезен код и дневници во системот за распоредување, притоа изречно одлучувајќи дали излезниот код 2 треба да го означи распоредувањето како нестабилно или да активира известување.
Вообичаените неуспеси обично се едноставни:
- HTTP 401 или 403: проверете ја активацијата и заменете поништен или неправилно ограничен токен. Не правете автоматски повторни обиди.
- HTTP 400 или 422: потврдете дека
PUBLIC_SITE_URLе јавниот HTTPS извор, а не внатрешно име на домаќин. - HTTP 429: клиентот почитува ограничен нумерички
Retry-After, но трајното исцрпување на квотата бара преглед на планот или фреквенцијата на распоредување. - Неуспех на транспортот или 5xx: задржете го претходниот извештај, емитувајте го настанот за неуспех и правете повторни обиди само во рамките на ограничената политика.
- Неуспех на договорот: зачувајте го суровиот одговор само во безбеден дијагностички процес; не претворајте молчешкум неправилно форматирани полиња во чист извештај.
Конечна контролна листа за проверка
- Планот за услугата е активен и токенот ограничен на услугата се наоѓа само во заштитената конфигурација на околината.
- Командата испраќа точно едно JSON поле
urlдо документираната POST крајна точка. - Времето за поврзување и вкупниот одговор се ограничени, без повторни обиди за грешки при автентикација или валидација.
- Тестовите поминуваат со детерминистички лажен транспорт и не прават надворешни барања.
- Завршеното распоредување ја извршува командата со вистинската јавна HTTPS URL-адреса.
- Успехот создава атомски извештај и структуриран настан за завршување; неуспехот создава категоризиран резултат различен од нула.
- Операторите разбираат дека излезот е ограничена анализа на состојбата, а не доказ дека веб-страницата поминала пенетрационен тест.
Трајната вредност не е една единствена смирувачка оценка. Таа е навиката да се проверува јавниот систем откако реалноста се променила. Кога секое продукциско распоредување завршува со ограничена, набљудлива безбедносна проверка, регресиите во прелистувачите и TLS стануваат обични инженерски сигнали: видливи, применливи и многу помалку веројатно да останат незабележани.