Symfony: Автоматизирајте безбедносни скенирања на веб-страницата по распоредување со анализа со ВИ
Распоредувањето може да биде технички успешно, а сепак тивко да ја ослаби страницата што ја испорачало. Промена на прокси може да отстрани безбедносно заглавие, синџирот на сертификати може да биде нецелосен или новововедена политика за ресурси може да му остави на прелистувачот помала заштита од очекуваната.
Практичниот одговор е надворешната безбедносна проверка да стане дел од самото распоредување. Во ова упатство, ќе изградиме Symfony команда што ја испраќа продукциската URL-адреса до Website Security Analyzer, го мапира резултатот во стабилен доменски објект, повторува само поправливи неуспеси и создава структурирани излезни податоци погодни за CI логови.
Анализаторот врши ограничена, неинвазивна анализа на јавна HTTPS крајна точка и нејзината безбедносна поставеност во прелистувачот. Корисен е за потврдување на распоредувањата, но не е пенетрационен тест и никогаш не треба да се претставува како таков.
Добијте пристап до Website Security Analyzer
За оваа услуга е потребна автентикација; во оваа интеграција не постои режим без токен. Завршете го следниот процес на воведување пред да пишувате апликациски код:
- Регистрирајте се на https://ai.mihajlo.mk/register или најавете се преку https://ai.mihajlo.mk/login.
- Отворете ја страницата на услугата Website Security Analyzer.
- Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Најдете го панелот Service token и копирајте го токенот ограничен на услугата што е прикажан таму.
Повторното генерирање на овој токен го поништува претходно активниот токен. Третирајте ја ротацијата како координирана промена при распоредување: инсталирајте ја новата вредност во продукциското складиште за тајни, повторно распоредете го секој потрошувач, потврдете го скенирањето и дури потоа отстранете каква било застарена CI конфигурација.
API-то прифаќа Bearer токен, заглавие X-API-Token или параметар за токен во барањето. Ќе го користиме заглавието Bearer бидејќи ги задржува ингеренциите надвор од URL-адресите, историите на прокси-серверите и вообичаените логови за пристап.
Најпрво потврдете го HTTP договорот
Точното барање е POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Неговото JSON тело содржи една задолжителна вредност, url.
Направете минимално барање од контролиран терминал пред да го интегрирате Symfony:
curl --request POST \
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website' \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--header 'Content-Type: application/json' \
--data '{"url":"https://www.example.com"}'
Одговорот содржи резултат, наоди групирани според сериозност, TLS детали и препораки. Нашата апликација ќе ги потврди тие делови без да претпоставува недокументирани полиња во поединечен наод или TLS запис.
Чувајте ги локалните ингеренции во .env.local, кој мора да остане надвор од контрола на верзии:
WEBSITE_SECURITY_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_SECURITY_TARGET_URL=https://www.example.com
Во продукција, поставете ги истите имиња на променливи на околината преку хостинг-платформата или складиштето за CI тајни, наместо да копирате локална датотека со околина на серверот.
Архитектура и предуслови за проектот
Оваа имплементација бара PHP 8.3 или понова верзија, постоечка Symfony апликација, Composer, излезен HTTPS пристап до API-то и јавно достапна продукциска HTTPS URL-адреса. Инсталирајте го HTTP клиентот од прва страна и вообичаената поддршка за тестирање ако проектот веќе ги нема:
composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack
Проектот користи четири фокусирани компоненти:
SecurityReportе сопственик на граничната валидација и мапирањето на одговорот на доменско ниво.AnalyzerExceptionизложува структурирани категории на неуспех без да открива тела на одговори.WebsiteSecurityAnalyzerе сопственик на автентикацијата, HTTP тајмаутите, политиката за повторување и логирањето.WebsiteSecurityScanCommandја фиксира целната URL-адреса преку конфигурација и му дава на цевководот за распоредување детерминистички излезен код.
Messenger би додал инфраструктура за редици без да помогне за овој конкретен работен тек. За пост-распоредувачка потврда е потребен непосреден резултат, па синхроната конзолна команда е појасниот компромис. API повикот останува доволно изолиран за подоцна да се премести зад Messenger ако скенирањата станат закажана работа во заднина.
Мапирајте го одговорот на границата на апликацијата
Надворешниот JSON не треба да тече низ апликацијата без валидација. Следниот непроменлив DTO ги бара четирите документирани делови, додека намерно ја зачувува нивната вгнездена содржина:
<?php
// src/Security/SecurityReport.php
namespace App\Security;
use JsonSerializable;
use UnexpectedValueException;
final readonly class SecurityReport implements JsonSerializable
{
public function __construct(
public float $score,
public array $findings,
public array $tls,
public array $recommendations,
) {
}
public static function fromPayload(array $payload): self
{
if (!array_key_exists('score', $payload) || !is_numeric($payload['score'])) {
throw new UnexpectedValueException('The analyzer response has no numeric score.');
}
foreach (['findings', 'tls', 'recommendations'] as $section) {
if (!isset($payload[$section]) || !is_array($payload[$section])) {
throw new UnexpectedValueException(
sprintf('The analyzer response has no valid %s section.', $section)
);
}
}
foreach ($payload['findings'] as $severity => $items) {
if (!is_string($severity) || !is_array($items)) {
throw new UnexpectedValueException(
'The findings section is not grouped by severity.'
);
}
}
return new self(
(float) $payload['score'],
$payload['findings'],
$payload['tls'],
$payload['recommendations'],
);
}
public function jsonSerialize(): array
{
return [
'score' => $this->score,
'findings' => $this->findings,
'tls' => $this->tls,
'recommendations' => $this->recommendations,
];
}
}
Овој мапер не измислува полиња како што се имиња на издавачи на сертификати, идентификатори на заглавија или URL-адреси за отстранување на проблеми. Потрошувачите можат подоцна да додадат типизирани вгнездени објекти кога тие структури ќе бидат потврдени според официјалната документација и реални фикстури.
Претставете ги оперативните неуспеси одделно од наодите од анализата:
<?php
// src/Security/AnalyzerException.php
namespace App\Security;
use RuntimeException;
use Throwable;
final class AnalyzerException extends RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Изградете ограничен API клиент со поддршка за повторување
Клиентот дозволува вкупно три обиди. Повторува при неуспеси на транспортот, HTTP 429 одговори и неуспеси на страната на серверот. Автентикацијата и другите вообичаени грешки на клиентот веднаш не успеваат бидејќи повторените идентични барања нема да поправат неважечки токен или барање.
<?php
// src/Security/WebsiteSecurityAnalyzer.php
namespace App\Security;
use InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use UnexpectedValueException;
final class WebsiteSecurityAnalyzer
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
private readonly string $websiteSecurityToken,
) {
if (trim($websiteSecurityToken) === '') {
throw new InvalidArgumentException('The website security token is empty.');
}
}
public function analyze(string $url): SecurityReport
{
$parts = parse_url($url);
if (
filter_var($url, FILTER_VALIDATE_URL) === false
|| ($parts['scheme'] ?? null) !== 'https'
|| empty($parts['host'])
) {
throw new InvalidArgumentException(
'The scan target must be a valid public HTTPS URL.'
);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
$response = null;
try {
$response = $this->httpClient->request('POST', self::ENDPOINT, [
'headers' => [
'Authorization' => 'Bearer '.$this->websiteSecurityToken,
'Accept' => 'application/json',
],
'json' => ['url' => $url],
'timeout' => 5.0,
'max_duration' => 30.0,
]);
$status = $response->getStatusCode();
if ($status >= 200 && $status < 300) {
try {
return SecurityReport::fromPayload(
$response->toArray(false)
);
} catch (
DecodingExceptionInterface
| UnexpectedValueException $exception
) {
throw new AnalyzerException(
'malformed_response',
'The analyzer returned an invalid response.',
$exception,
);
}
}
if ($status === 401 || $status === 403) {
throw new AnalyzerException(
'authentication',
'The analyzer rejected its service token.'
);
}
$retryable = $status === 429 || $status >= 500;
if (!$retryable || $attempt === 3) {
$kind = $status === 429
? 'rate_limited'
: ($status >= 500 ? 'upstream_failure' : 'request_rejected');
throw new AnalyzerException(
$kind,
sprintf('The analyzer returned HTTP %d.', $status)
);
}
$this->logger->warning('Website security scan will be retried.', [
'attempt' => $attempt,
'http_status' => $status,
]);
$this->pauseBeforeRetry($response, $attempt);
} catch (TransportExceptionInterface $exception) {
if ($attempt === 3) {
throw new AnalyzerException(
'transport',
'The analyzer could not be reached after three attempts.',
$exception,
);
}
$this->logger->warning('Website security scan transport failure.', [
'attempt' => $attempt,
'exception_class' => $exception::class,
]);
$this->pauseBeforeRetry(null, $attempt);
}
}
throw new AnalyzerException('internal', 'The scan ended unexpectedly.');
}
private function pauseBeforeRetry(
?ResponseInterface $response,
int $attempt,
): void {
$delayMicroseconds = 100_000 * (2 ** ($attempt - 1));
if ($response !== null) {
$retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;
if (is_string($retryAfter) && ctype_digit($retryAfter)) {
$delayMicroseconds = min((int) $retryAfter, 2) * 1_000_000;
}
}
usleep($delayMicroseconds);
}
}
Одложувањето Retry-After е ограничено на две секунди, така што одговорот на давателот не може неограничено да задржи работник за распоредување. И тајмаутот по операција и вкупното времетраење на барањето се ограничени. Логовите содржат статус, обид и класа на исклучок, но никогаш токен, заглавија на барањето, тело на одговорот или целосна порака за исклучок.
Поврзете ја конфигурацијата со Symfony
Експлицитно поврзете ги двете вредности поддржани со променливи на околината. Така тајните остануваат надвор од изворниот код, а се овозможува вообичаено автоматско поврзување за HTTP клиентот и логерот:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Security\WebsiteSecurityAnalyzer:
arguments:
$websiteSecurityToken: '%env(WEBSITE_SECURITY_TOKEN)%'
App\Command\WebsiteSecurityScanCommand:
arguments:
$websiteSecurityTargetUrl: '%env(WEBSITE_SECURITY_TARGET_URL)%'
Создадете команда по распоредувањето
Целната URL-адреса е конфигурација, а не аргумент на командата. Тоа спречува компромитиран или погрешен параметар од цевководот да ја претвори платената интеграција во скенер за URL-адреси со општа намена.
<?php
// src/Command/WebsiteSecurityScanCommand.php
namespace App\Command;
use App\Security\AnalyzerException;
use App\Security\WebsiteSecurityAnalyzer;
use InvalidArgumentException;
use JsonException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:security-scan',
description: 'Analyzes the configured production website security posture.'
)]
final class WebsiteSecurityScanCommand extends Command
{
public function __construct(
private readonly WebsiteSecurityAnalyzer $analyzer,
private readonly LoggerInterface $logger,
private readonly string $websiteSecurityTargetUrl,
) {
parent::__construct();
}
protected function execute(
InputInterface $input,
OutputInterface $output,
): int {
try {
$report = $this->analyzer->analyze(
$this->websiteSecurityTargetUrl
);
$output->writeln(json_encode(
$report,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
));
return Command::SUCCESS;
} catch (AnalyzerException | InvalidArgumentException | JsonException $exception) {
$kind = $exception instanceof AnalyzerException
? $exception->kind
: 'configuration';
$this->logger->error('Post-deployment security scan failed.', [
'failure_kind' => $kind,
'exception_class' => $exception::class,
]);
$output->writeln(sprintf(
'Security scan failed (%s). Consult application logs.',
$kind
));
return Command::FAILURE;
}
}
}
Успешната команда запишува еден JSON документ што ги содржи резултатот, групираните наоди, TLS деталите и препораките. Избегнувајте автоматски неуспех врз основа на претпоставен праг за резултат или претпоставен вокабулар за сериозност. Воспоставете политика за распоредување дури откако ќе ја потврдите семантиката за оценување на услугата и ќе се договорите кои документирани наоди треба да блокираат издание.
Тестирајте без да ја повикувате вистинската услуга
MockHttpClient го прави однесувањето при повторување и автентикација детерминистичко. Следните тестови докажуваат дека привремен неуспех на серверот се повторува, а неуспех на автентикација не:
<?php
// tests/Security/WebsiteSecurityAnalyzerTest.php
namespace App\Tests\Security;
use App\Security\AnalyzerException;
use App\Security\WebsiteSecurityAnalyzer;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class WebsiteSecurityAnalyzerTest extends TestCase
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
public function testItRetriesARecoverableFailureAndMapsTheReport(): void
{
$attempts = 0;
$client = new MockHttpClient(
function (string $method, string $url) use (&$attempts): MockResponse {
self::assertSame('POST', $method);
self::assertSame(self::ENDPOINT, $url);
$attempts++;
if ($attempts === 1) {
return new MockResponse('', ['http_code' => 503]);
}
return new MockResponse(json_encode([
'score' => 91,
'findings' => ['high' => [], 'medium' => []],
'tls' => ['available' => true],
'recommendations' => [],
], JSON_THROW_ON_ERROR), ['http_code' => 200]);
}
);
$analyzer = new WebsiteSecurityAnalyzer(
$client,
new NullLogger(),
'test-token'
);
$report = $analyzer->analyze('https://www.example.com');
self::assertSame(2, $attempts);
self::assertSame(91.0, $report->score);
self::assertArrayHasKey('high', $report->findings);
}
public function testItDoesNotRetryAuthenticationFailure(): void
{
$attempts = 0;
$client = new MockHttpClient(
function () use (&$attempts): MockResponse {
$attempts++;
return new MockResponse('', ['http_code' => 401]);
}
);
$analyzer = new WebsiteSecurityAnalyzer(
$client,
new NullLogger(),
'invalid-token'
);
try {
$analyzer->analyze('https://www.example.com');
self::fail('Expected an AnalyzerException.');
} catch (AnalyzerException $exception) {
self::assertSame('authentication', $exception->kind);
self::assertSame(1, $attempts);
}
}
}
Извршете го фокусираниот пакет тестови со php bin/phpunit tests/Security. Овие фикстури содржат лажен токен и синтетички одговори; ниту вистински ингеренции ниту копирани продукциски товари не припаѓаат во тест-фикстури.
Извршете го по секое продукциско распоредување
Извршете го скенирањето само откако продукцискиот сообраќај ќе укажува на новото издание. Скенирање пред изданието само би ја проверило старата јавна верзија. Мал завршен дел од распоредувањето може да ја потврди достапноста и потоа да го повика Symfony:
set -eu
curl --fail \
--silent \
--show-error \
--max-time 10 \
"$WEBSITE_SECURITY_TARGET_URL" > /dev/null
php bin/console app:security-scan --env=prod --no-debug
Дозволете му на системот за распоредување да го зачува JSON излезот на командата како артефакт или структуриран лог. Оставете ја апликацијата активна ако надворешниот анализатор е привремено недостапен, но означете ја пост-распоредувачката потврда како неуспешна и известете го одговорниот развивач. Автоматското враќање назад засновано единствено на тајмаут од трета страна може да направи здраво издание помалку доверливо.
Исцрпувањето на квотата и ограничувањето на стапката се појавуваат како структурираниот неуспех rate_limited по ограничените повторувања. Намалете ги дупликатите активирања на распоредување или изберете соодветен план, наместо да додавате неограничени повторувања.
Вообичаени продукциски неуспеси
- Неуспех на автентикација: потврдете дека тајната припаѓа на оваа услуга и дека е ажурирана насекаде по повторното генерирање.
- Одбиено барање: проверете дека конфигурираната цел е важечка јавна HTTPS URL-адреса и дека JSON телото сè уште содржи
url. - Неисправен одговор: задржете ја безбедната категорија на неуспех и споредете го договорот со официјалната документација пред да го менувате маперот.
- Неуспех на транспортот: проверете ги излезниот DNS, TLS, политиката на заштитниот ѕид и мрежната рута на работникот за распоредување.
- Неочекувани резултати за целта: потврдете дека DNS и продукцискиот сообраќај биле префрлени пред извршувањето на командата и дека URL-адресата ја претставува распоредената страница.
Завршна листа за потврда
- Планот на услугата е активен, а токенот ограничен на услугата е зачуван само во конфигурација за тајни поддржана со променливи на околината.
WEBSITE_SECURITY_TARGET_URLја содржи канонската јавна HTTPS URL-адреса.- Детерминистичките тестови поминуваат без пристап до надворешна мрежа.
- Продукциската команда враќа нула и ги емитува четирите мапирани делови од извештајот.
- Повторувања се случуваат само за грешки во транспортот, ограничувања на стапката и неуспеси на серверот.
- Логовите и CI артефактите не содржат токен, заглавие за авторизација или необработен чувствителен одговор.
- Командата се извршува откако секое продукциско издание ќе стане јавно достапно.
- Резултатот е опишан како ограничена анализа на безбедноста на веб-страницата, а не како пенетрационен тест.
Најсилните проверки при распоредување ретко се најсложените. Тоа се проверките поставени на правилната граница, со строги временски ограничувања, направени набљудливи и извршувани доследно. Со оваа команда прикачена на патеката за издание, регресиите свртени кон прелистувачот и TLS стануваат видливи веднаш по распоредувањето — додека имплементацијата останува доволно мала за развивач или мал тим да ја разбере, тестира и да ѝ верува.