Symfony: Автоматизирајте ги безбедносните ревизии по распоредувањето со ВИ
Распоредувањето може да биде здраво, брзо и целосно достапно, а сепак тивко да ја ослабува безбедносната поставеност насочена кон прелистувачот. Недостасувачко безбедносно заглавје, неочекувана TLS промена или претерано попустлива политика можеби воопшто нема да ги активираат тестовите на апликацијата. Практичниот одговор е јавната безбедносна ревизија да стане дел од животниот циклус на распоредувањето, веднаш откако новото издание ќе стане достапно.
Овој туторијал ја гради таа интеграција во Symfony на PHP 8.3 или понова верзија. Конзолна команда ја испраќа продукциската URL-адреса до 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 токен бидејќи Symfony може да го постави директно во заглавје за авторизација. Избегнувајте го параметарот за барање во продукција: URL-адресите почесто од заглавјата се појавуваат во прокси и евиденциските дневници за пристап.
Повторното генерирање на сервисниот токен го повлекува претходно активниот токен. Третирајте ја ротацијата како координирана промена на конфигурацијата: инсталирајте ја новата вредност во складиштето за тајни на распоредувањето, распоредете ја, потврдете ја ревизијата и дури потоа сметајте дека ротацијата е завршена.
Потврдете ја крајната точка пред да ја изградите функционалноста
Точниот API повик е POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Неговото JSON тело на барањето ја содржи јавната url:
curl --fail-with-body \
--request POST \
--url '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"}'
Извршете го ова врз јавна HTTPS адреса, а не localhost, внатрешно име на домаќин или преглед заштитен со приватно вмрежување. Услугата мора да може да стигне до истата веб-страница до која стигнуваат посетителите.
За локален развој, ставете местодржачи во .env и реални локални вредности во непредадената .env.local. Во продукција, внесете ги двете променливи преку менаџерот за тајни на хостот или CI, наместо да предавате продукциска датотека со околински променливи:
# .env
PUBLIC_SITE_URL=https://www.example.com
WEBSITE_SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
# .env.local, never committed
WEBSITE_SECURITY_ANALYZER_TOKEN=replace-with-your-service-scoped-token
Изберете намерно мала архитектура
На овој проект не му треба контролер или Messenger редица. Системот за распоредување веќе обезбедува сигурна оркестрација, а командата за ревизија има корисен синхрон договор: успех значи дека одговорот е преземен и мапиран; неуспех значи дека проверката не е завршена.
Добиената структура на проектот е компактна:
src/
Command/AuditPublicWebsiteCommand.php
SecurityAudit/SecurityAuditResult.php
SecurityAudit/AnalyzerException.php
SecurityAudit/WebsiteSecurityAnalyzer.php
SecurityAudit/WebsiteSecurityAnalyzerClient.php
tests/
SecurityAudit/WebsiteSecurityAnalyzerClientTest.php
config/
services.yaml
HTTP клиентот ги поседува транспортните аспекти, објектот за резултатот го претставува доменскиот одговор, а командата се справува со презентацијата ориентирана кон распоредувањето. Ова раздвојување исто така го прави надворешниот повик детерминистички во тестовите.
Мапирајте го одговорот на границата на апликацијата
На надворешниот JSON никогаш не смее да му се дозволи неконтролирано да отстапува низ апликацијата. Одговорот од анализаторот обезбедува резултат, наоди групирани по сериозност, TLS детали и препораки. Маперот подолу ги проверува нивните типови на контејнери без да претпоставува недокументирани полиња во поединечните наоди или TLS детали.
<?php
// src/SecurityAudit/SecurityAuditResult.php
namespace App\SecurityAudit;
final readonly class SecurityAuditResult
{
/**
* @param array<string, list<array<string, mixed>>> $findings
* @param array<string, mixed> $tls
* @param list<string> $recommendations
*/
public function __construct(
public int|float $score,
public array $findings,
public array $tls,
public array $recommendations,
) {
}
/** @param array<string, mixed> $payload */
public static function fromPayload(array $payload): self
{
if (!isset($payload['score']) || !is_numeric($payload['score'])) {
throw AnalyzerException::invalidResponse('Missing or invalid score.');
}
if (!isset($payload['findings']) || !is_array($payload['findings'])) {
throw AnalyzerException::invalidResponse('Missing severity-grouped findings.');
}
$findings = [];
foreach ($payload['findings'] as $severity => $items) {
if (!is_string($severity) || !is_array($items)) {
throw AnalyzerException::invalidResponse('Invalid findings group.');
}
foreach ($items as $item) {
if (!is_array($item)) {
throw AnalyzerException::invalidResponse('Invalid finding.');
}
}
$findings[$severity] = array_values($items);
}
if (!isset($payload['tls']) || !is_array($payload['tls'])) {
throw AnalyzerException::invalidResponse('Missing or invalid TLS details.');
}
if (!isset($payload['recommendations']) || !is_array($payload['recommendations'])) {
throw AnalyzerException::invalidResponse('Missing recommendations.');
}
$recommendations = [];
foreach ($payload['recommendations'] as $recommendation) {
if (!is_string($recommendation)) {
throw AnalyzerException::invalidResponse('Invalid recommendation.');
}
$recommendations[] = $recommendation;
}
return new self(
score: $payload['score'] + 0,
findings: $findings,
tls: $payload['tls'],
recommendations: $recommendations,
);
}
}
Намерно е внатрешните делови на наодите и TLS да се задржат како проверени низи. Тоа избегнува измислување недокументирани имиња на полиња, а сепак спречува неправилно обликувани податоци од највисоко ниво да ја загадат командата.
Имплементирајте ограничено HTTP однесување
Продукциските интеграции имаат потреба од експлицитни временски ограничувања и селективни повторни обиди. Неуспесите при автентикација и валидација треба веднаш да се вратат; нивното повторување троши квота и ги прикрива грешките во конфигурацијата. Ограничувањата на стапката, серверските грешки и прекините во транспортот може да бидат привремени, па овој клиент ги повторува со ограничено експоненцијално одложување.
<?php
// src/SecurityAudit/AnalyzerException.php
namespace App\SecurityAudit;
final class AnalyzerException extends \RuntimeException
{
public function __construct(
string $message,
public readonly string $category,
public readonly ?int $statusCode = null,
public readonly bool $retryable = false,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
public static function invalidResponse(string $message): self
{
return new self($message, 'invalid_response');
}
}
<?php
// src/SecurityAudit/WebsiteSecurityAnalyzer.php
namespace App\SecurityAudit;
interface WebsiteSecurityAnalyzer
{
public function analyze(string $url): SecurityAuditResult;
}
<?php
// src/SecurityAudit/WebsiteSecurityAnalyzerClient.php
namespace App\SecurityAudit;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class WebsiteSecurityAnalyzerClient implements WebsiteSecurityAnalyzer
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
private string $token,
) {
if ($this->token === '' || $this->token === 'YOUR_SERVICE_TOKEN') {
throw new \InvalidArgumentException('Analyzer token is not configured.');
}
}
public function analyze(string $url): SecurityAuditResult
{
if (filter_var($url, FILTER_VALIDATE_URL) === false
|| parse_url($url, PHP_URL_SCHEME) !== 'https') {
throw new \InvalidArgumentException('PUBLIC_SITE_URL must be a valid HTTPS URL.');
}
for ($attempt = 1; $attempt <= 3; ++$attempt) {
try {
$response = $this->httpClient->request('POST', self::ENDPOINT, [
'auth_bearer' => $this->token,
'json' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 30.0,
]);
$status = $response->getStatusCode();
if ($status >= 200 && $status < 300) {
return SecurityAuditResult::fromPayload($response->toArray(false));
}
$retryable = $status === 429 || $status >= 500;
if ($retryable && $attempt < 3) {
$this->logger->warning('Security audit request will be retried.', [
'attempt' => $attempt,
'status' => $status,
'host' => parse_url($url, PHP_URL_HOST),
]);
$this->backoff($attempt);
continue;
}
$category = match ($status) {
401, 403 => 'authentication',
400, 422 => 'validation',
429 => 'rate_limit',
default => $status >= 500 ? 'service' : 'http',
};
throw new AnalyzerException(
"Analyzer request failed with HTTP {$status}.",
$category,
$status,
$retryable,
);
} catch (DecodingExceptionInterface $exception) {
throw new AnalyzerException(
'Analyzer returned invalid JSON.',
'invalid_response',
previous: $exception,
);
} catch (TransportExceptionInterface $exception) {
if ($attempt < 3) {
$this->logger->warning('Security audit transport failure; retrying.', [
'attempt' => $attempt,
'host' => parse_url($url, PHP_URL_HOST),
]);
$this->backoff($attempt);
continue;
}
throw new AnalyzerException(
'Analyzer transport failed after three attempts.',
'transport',
retryable: true,
previous: $exception,
);
}
}
throw new \LogicException('Unreachable retry state.');
}
private function backoff(int $attempt): void
{
usleep(250_000 * (2 ** ($attempt - 1)));
}
}
Дневниците ги вклучуваат одредишниот хост, обидот и статусот, но никогаш токенот, заглавјето за авторизација, целото тело на одговорот или чувствителниот контекст на исклучокот. Максималното одложување останува кратко, додека timeout ја ограничува неактивноста, а max_duration ја ограничува секоја HTTP операција.
Конфигурирајте вбризгување на зависности
Централно поврзете ги вредностите од околината и поставете алијас од интерфејсот кон неговиот конкретен клиент:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$token: '%env(WEBSITE_SECURITY_ANALYZER_TOKEN)%'
$publicUrl: '%env(PUBLIC_SITE_URL)%'
App\:
resource: '../src/'
App\SecurityAudit\WebsiteSecurityAnalyzer:
alias: App\SecurityAudit\WebsiteSecurityAnalyzerClient
Ова го користи HTTP клиентот од прва страна на Symfony преку HttpClientInterface. Во постоечка Symfony апликација, осигурете се дека компонентата е присутна со composer require symfony/http-client.
Креирајте ја командата за распоредување
Командата печати само оперативен преглед. Не ги означува наодите како доказ за експлоатација и не се преправа дека резултатот заменува автентицирано скенирање, преглед на код, ревизија на зависности или пенетрационен тест.
<?php
// src/Command/AuditPublicWebsiteCommand.php
namespace App\Command;
use App\SecurityAudit\AnalyzerException;
use App\SecurityAudit\WebsiteSecurityAnalyzer;
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;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:security:audit-public-site',
description: 'Analyzes the deployed public website security posture.',
)]
final class AuditPublicWebsiteCommand extends Command
{
public function __construct(
private readonly WebsiteSecurityAnalyzer $analyzer,
private readonly LoggerInterface $logger,
private readonly string $publicUrl,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$result = $this->analyzer->analyze($this->publicUrl);
} catch (AnalyzerException|\InvalidArgumentException $exception) {
$this->logger->error('Post-deployment security audit failed.', [
'category' => $exception instanceof AnalyzerException
? $exception->category
: 'configuration',
'status' => $exception instanceof AnalyzerException
? $exception->statusCode
: null,
]);
$io->error($exception->getMessage());
return Command::FAILURE;
}
$counts = [];
foreach ($result->findings as $severity => $findings) {
$counts[$severity] = count($findings);
}
$this->logger->notice('Post-deployment security audit completed.', [
'score' => $result->score,
'finding_counts' => $counts,
'recommendation_count' => count($result->recommendations),
]);
$io->success(sprintf('Security audit completed. Score: %s', $result->score));
foreach ($counts as $severity => $count) {
$io->writeln(sprintf('%s findings: %d', ucfirst($severity), $count));
}
$io->writeln(sprintf(
'Recommendations: %d; TLS details received: %s',
count($result->recommendations),
$result->tls === [] ? 'no' : 'yes',
));
return Command::SUCCESS;
}
}
Тестирајте ја границата без мрежни повици
MockHttpClient обезбедува детерминистички транспорт од прва страна. Овие тестови ги проверуваат крајната точка, автентикацијата, телото на барањето, мапирањето, однесувањето при повторен обид и отфрлањето на неправилно обликувани одговори.
<?php
// tests/SecurityAudit/WebsiteSecurityAnalyzerClientTest.php
namespace App\Tests\SecurityAudit;
use App\SecurityAudit\AnalyzerException;
use App\SecurityAudit\WebsiteSecurityAnalyzerClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class WebsiteSecurityAnalyzerClientTest extends TestCase
{
public function testItSendsAndMapsAnAudit(): void
{
$http = new MockHttpClient(
function (string $method, string $url, array $options): MockResponse {
self::assertSame('POST', $method);
self::assertSame(
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
$url,
);
self::assertSame('Bearer test-token', $options['normalized_headers']['authorization'][0]);
self::assertSame(
['url' => 'https://www.example.com'],
json_decode($options['body'], true, flags: JSON_THROW_ON_ERROR),
);
return new MockResponse(json_encode([
'score' => 91,
'findings' => ['high' => [], 'medium' => [['id' => 'example']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review the reported medium finding.'],
], JSON_THROW_ON_ERROR));
},
);
$result = (new WebsiteSecurityAnalyzerClient(
$http,
new NullLogger(),
'test-token',
))->analyze('https://www.example.com');
self::assertSame(91, $result->score);
self::assertCount(1, $result->findings['medium']);
self::assertTrue($result->tls['enabled']);
}
public function testItRetriesAServiceFailure(): void
{
$http = new MockHttpClient([
new MockResponse('', ['http_code' => 503]),
new MockResponse(json_encode([
'score' => 100,
'findings' => [],
'tls' => [],
'recommendations' => [],
], JSON_THROW_ON_ERROR)),
]);
$result = (new WebsiteSecurityAnalyzerClient(
$http,
new NullLogger(),
'test-token',
))->analyze('https://www.example.com');
self::assertSame(100, $result->score);
self::assertSame(2, $http->getRequestsCount());
}
public function testItRejectsMalformedPayloads(): void
{
$http = new MockHttpClient(new MockResponse('{"score":90}'));
$client = new WebsiteSecurityAnalyzerClient($http, new NullLogger(), 'test-token');
$this->expectException(AnalyzerException::class);
$client->analyze('https://www.example.com');
}
}
Извршете го пакетот со php bin/phpunit. Ако вашата апликација го повикува PHPUnit преку друга стандардна команда за проектот, користете ја таа обвивка, а транспортот на тестот оставете го непроменет.
Поставете ја ревизијата по префрлањето на продукцискиот сообраќај
Анализаторот мора да го провери ново распореденото јавно издание. Извршете ја командата по миграциите, загревањето на кешот, здравствените проверки и префрлањето на балансерот на оптоварување или симболичката врска — не додека старото издание сè уште го опслужува јавното име на домаќин.
curl --fail --silent --show-error \
--max-time 15 \
'https://www.example.com/health'
php bin/console app:security:audit-public-site \
--env=prod \
--no-debug
Прикачете ги овие команди на фазата по распоредувањето што ја користи вашата хостинг платформа. Резултат од команда различен од нула треба да ја прекине фазата на ревизија и да активира известување, но не треба автоматски да уништи инаку здраво издание. Прекин на надворешниот анализатор и неисправно распоредување на апликацијата се различни домени на неуспех. Повторете ја фазата на ревизија одделно или нека оператор ја прегледа нејзината структурирана категорија на неуспех.
Ако наодите на крајот треба да ја блокираат промоцијата, воведете ја таа политика експлицитно. На пример, одлучете кои нормализирани групи на сериозност блокираат и земете ги предвид одобрените исклучоци. Не претворајте тивко променлив резултат од трета страна во недекларирано правило за враќање назад.
Безбедност, набљудливост и вообичаени неуспеси
Ограничете го токенот на услугата за која е издаден, чувајте го во менаџер за тајни и ограничете кој може да чита продукциски вредности на околината. Редактирајте ги заглавјата за авторизација во HTTP следењето и интеграциите за известување грешки. Ако токенот е изложен, генерирајте го повторно, имајќи предвид дека претходниот активен токен веднаш се повлекува.
Следете го завршувањето на ревизијата, како и наодите. Корисни димензии вклучуваат идентификатор на распоредување, целен хост, резултат, броеви по сериозност, латентност, број на повторни обиди, HTTP статус и категорија на неуспех. Никогаш не го користете токенот како вредност за корелација во дневници.
- HTTP 401 или 403: потврдете дека активниот сервисно-ограничен токен стигнал до продукцискиот процес. Не обидувајте се повторно наслепо; повторното генерирање можеби ја повлекло распоредената вредност.
- HTTP 400 или 422: проверете дали барањето ја содржи JSON
urlи далиPUBLIC_SITE_URLе важечка јавна HTTPS URL-адреса. - HTTP 429: тековниот план или стапката на барања можеби ги ограничува повиците. Повторувањето при секое распоредување истовремено може да го влоши проблемот; сериски извршувајте ревизии на распоредувањата каде што е соодветно.
- Истекување на транспортот: разликувајте ја поврзаноста со анализаторот од здравјето на апликацијата. Ограничените повторни обиди треба предвидливо да завршат и да остават видлива неуспешна фаза на ревизија.
- Неочекуван облик на одговор: третирајте го како неуспех на интеграцијата. Зачувајте ја строго типизираната граница и ажурирајте ја само по проверка на официјалната документација.
- Резултатите го опишуваат претходното издание: преместете ја командата по префрлањето на DNS, прокси или издание и чекајте само нормалната здравствена проверка да потврди дека новото издание јавно се опслужува.
Контролна листа за конечна потврда
- Продукциската URL-адреса е јавна, користи HTTPS и се разрешува до ново распореденото издание.
- Free, Plus или Pro планот е активен за услугата на анализаторот.
- Сервисниот токен доаѓа од панелот Service token на страницата со документација.
- Токенот се внесува преку конфигурација поткрепена со околински променливи и не е присутен во контрола на изворниот код и дневници.
- Клиентот испраќа
POSTдо точната крајна точка на анализаторот со JSONurl. - Истекувањата и повторните обиди се ограничени, додека неуспесите при автентикација и валидација не се повторуваат.
- Резултатот, наодите групирани по сериозност, TLS деталите и препораките се проверени на API границата.
php bin/phpunitпоминува без зависност од жива мрежа.- Командата за ревизија се извршува по секое префрлање на продукцискиот сообраќај и нејзиниот неуспех е набљудлив.
- Резултатот се соопштува како анализа на јавната безбедносна поставеност, никогаш како пенетрационен тест.
Ревизијата по распоредувањето е највредна кога се однесува како обична инженерска инфраструктура: предвидливи влезови, ограничено извршување, строго типизирани излези, експлицитна семантика на неуспех и без потреба од херојско толкување. Откако оваа команда е дел од патеката на изданието, поставеноста на прелистувачот и TLS престануваат да бидат нешто што тимот се сеќава да го прегледа повремено. Тие стануваат уште едно својство што се проверува секогаш кога продукцијата се менува — токму таму каде што безбедносната автоматизација го заслужува своето место.