Symfony Security Dashboard: Анализа на веб-страницата на клиентот и применлива санација
Безбедносното скенирање станува вредно само кога некој може да разбере што се променило и што треба следно да се поправи. За мала агенција што управува со неколку веб-страници на клиенти, еднократен API одговор не е доволен: корисниот производ е трајна историја на скенирања, јасно групирани наоди, TLS контекст и задачи за санација што може да се означат како завршени.
Ова упатство го гради тој производ со PHP 8.3 и Symfony. Скенирањата се извршуваат надвор од циклусот на барање преку Messenger, резултатите се валидираат на API границата, а секој исход станува експлицитна состојба на успех или неуспех. Анализаторот врши ограничена, неинвазивна анализа на јавниот 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-адресите, историјата на прелистувачот и вообичаените дневници за пристап. Повторното генерирање на сервисниот токен го поништува претходно активниот токен, па ротацијата на токени мора да ја ажурира секоја распоредена околина што го користи.
Потврдете ја крајната точка пред да пишувате апликациски код
Точната операција е POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Нејзиното JSON барање содржи url:
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://example.com"}'
Користете јавна веб-страница за која сте овластени да управувате. Не го вметнувајте вратениот товар во контрола на изворниот код: наодите може да откријат детали за безбедносната поставеност на клиентот.
Ставете ги ингеренциите во локална конфигурација што не е зачувана во репозиториумот. Во продукција, внесете ја истата променлива преку менаџерот за тајни на хостинг-платформата:
# .env.local
WEBSITE_SECURITY_TOKEN=YOUR_SERVICE_TOKEN
DATABASE_URL="postgresql://app:[email protected]:5432/security_dashboard"
MESSENGER_TRANSPORT_DSN="doctrine://default?auto_setup=false&queue_name=security_analysis"
Архитектура што одговара на мала агенција
Прелистувачот испраќа име на клиент и HTTPS URL-адреса. Контролерот создава запис за скенирање во редица и го испраќа неговиот идентификатор преку Messenger. Работникот го повикува анализаторот, го валидира одговорот и атомски ги складира резултатот, наодите групирани по сериозност, TLS деталите, препораките и локално генерираните задачи за санација.
Оваа асинхрона граница е важна. Далечинската анализа и контролираните повторни обиди не треба да држат отворено барање од прелистувач. Компромисот е оперативен: мора да работи барем еден Messenger работник, а контролната табла прикажува краткотрајни состојби queued и running.
Важните датотеки на проектот се:
src/
Analyzer/Analysis.php
Analyzer/AnalyzerException.php
Analyzer/WebsiteSecurityAnalyzer.php
Controller/SecurityDashboardController.php
Message/AnalyzeWebsite.php
MessageHandler/AnalyzeWebsiteHandler.php
Repository/SecurityScanRepository.php
migrations/Version20250101000000.php
templates/security/index.html.twig
tests/Analyzer/WebsiteSecurityAnalyzerTest.php
config/packages/messenger.yaml
config/services.yaml
Создадете Symfony апликација
composer create-project symfony/skeleton agency-security-dashboard
cd agency-security-dashboard
composer require symfony/http-client symfony/twig-bundle symfony/messenger \
symfony/doctrine-messenger symfony/security-csrf symfony/monolog-bundle \
doctrine/doctrine-bundle doctrine/dbal doctrine/doctrine-migrations-bundle
composer require --dev symfony/test-pack
Создадете две табели преку Doctrine миграција: security_scan го чува непроменливиот резултат од анализаторот и состојбата на животниот циклус; remediation_task чува проверлива работа изведена од препораките. Користете стринг-идентификатори генерирани со bin2hex(random_bytes(16)), JSON колони за структурите од надворешниот извор, временски ознаки и индекс на security_scan.status. Додајте надворешен клуч од секоја задача кон нејзиното скенирање со каскадно бришење.
Оваа шема намерно го складира валидираниот одговор наместо да претпоставува трајна форма за поединечните атрибути на наодите или TLS. API договорот ги гарантира концептите на највисоко ниво; дефанзивното прикажување на вгнездени вредности ја штити контролната табла од недокументирани структурни промени.
Конфигурирајте зависности и редица
# config/services.yaml
parameters: {}
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$analyzerToken: '%env(WEBSITE_SECURITY_TOKEN)%'
App\:
resource: '../src/'
exclude:
- '../src/Kernel.php'
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 0
failed: 'doctrine://default?queue_name=failed'
routing:
App\Message\AnalyzeWebsite: async
HTTP границата врши сопствени кратки повторни обиди, па повторните обиди на Messenger се оневозможени за да се избегне неочекувано умножување на повиците. Секој исцрпен неуспех се запишува во записот за скенирање. Посебниот транспорт за неуспешни пораки останува корисен за неуспеси надвор од вообичаената контрола на обработувачот, како што е прекинат работник.
Валидирајте го одговорот на анализаторот на границата
<?php
// src/Analyzer/Analysis.php
namespace App\Analyzer;
final readonly class Analysis
{
public function __construct(
public float $score,
public array $findings,
public array $tls,
public array $recommendations,
) {}
public static function fromPayload(array $payload): self
{
$score = $payload['score'] ?? null;
$findings = $payload['findings'] ?? null;
$tls = $payload['tls'] ?? null;
$recommendations = $payload['recommendations'] ?? null;
if ((!is_int($score) && !is_float($score)) || !is_finite((float) $score)) {
throw new AnalyzerException('invalid_response', 'Analyzer score is invalid.');
}
if (!is_array($findings) || !is_array($tls) || !is_array($recommendations)) {
throw new AnalyzerException('invalid_response', 'Analyzer sections are invalid.');
}
foreach ($findings as $severity => $items) {
if (!is_string($severity) || !is_array($items)) {
throw new AnalyzerException(
'invalid_response',
'Findings are not grouped by severity.'
);
}
}
return new self((float) $score, $findings, $tls, $recommendations);
}
}
// src/Analyzer/AnalyzerException.php
namespace App\Analyzer;
final class AnalyzerException extends \RuntimeException
{
public function __construct(
public readonly string $failureCode,
string $message,
) {
parent::__construct($message);
}
}
Забележете што не прави овој мапер: не измислува вгнездени полиња на одговорот. Наодите остануваат групирани според клучевите за сериозност што ги доставува услугата, додека TLS податоците и препораките остануваат валидирани JSON структури.
Изградете ограничен HTTP клиент што е свесен за неуспеси
<?php
// src/Analyzer/WebsiteSecurityAnalyzer.php
namespace App\Analyzer;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class WebsiteSecurityAnalyzer
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
public function __construct(
private HttpClientInterface $http,
private string $analyzerToken,
) {}
public function analyze(string $url): Analysis
{
for ($attempt = 1; $attempt <= 3; ++$attempt) {
try {
$response = $this->http->request('POST', self::ENDPOINT, [
'auth_bearer' => $this->analyzerToken,
'json' => ['url' => $url],
'timeout' => 8.0,
'max_duration' => 20.0,
]);
$status = $response->getStatusCode();
if ($status >= 200 && $status < 300) {
try {
return Analysis::fromPayload($response->toArray(false));
} catch (DecodingExceptionInterface $e) {
throw new AnalyzerException(
'invalid_response',
'Analyzer returned invalid JSON.'
);
}
}
if ($status === 401 || $status === 403) {
throw new AnalyzerException(
'authentication',
'Analyzer authentication was rejected.'
);
}
if ($status === 400 || $status === 422) {
throw new AnalyzerException(
'validation',
'Analyzer rejected the submitted URL.'
);
}
$retryable = $status === 429 || $status >= 500;
if (!$retryable) {
throw new AnalyzerException(
'upstream_response',
'Analyzer returned an unexpected response.'
);
}
if ($attempt === 3) {
$code = $status === 429 ? 'rate_limited' : 'upstream_unavailable';
throw new AnalyzerException($code, 'Analyzer is temporarily unavailable.');
}
$headers = $response->getHeaders(false);
$retryAfter = $headers['retry-after'][0] ?? null;
$delayMs = ctype_digit((string) $retryAfter)
? min(5000, (int) $retryAfter * 1000)
: 250 * (2 ** ($attempt - 1));
usleep($delayMs * 1000);
} catch (TransportExceptionInterface $e) {
if ($attempt === 3) {
throw new AnalyzerException(
'transport',
'Could not reach the analyzer.'
);
}
usleep(250 * (2 ** ($attempt - 1)) * 1000);
}
}
throw new AnalyzerException('internal', 'Analysis did not complete.');
}
}
Се повторуваат само транспортни неуспеси, HTTP 429 одговори и неуспеси на серверската страна. Неуспесите при автентикација и валидација бараат интервенција, а не повторување. Одложувањата се ограничени, како и неактивноста на врската и вкупното траење на барањето. Телата на одговорите и ингеренциите никогаш не влегуваат во пораките за исклучоци.
Зачувајте историја и создајте работа за санација
Репозиториумот треба да изложува пет фокусирани операции: queue(), markRunning(), complete(), fail() и history(). Во complete(), користете Connection::transactional() за да го ажурирате скенирањето и да внесете по една задача за секоја препорака.
Препораката може да биде стринг или структурирана JSON вредност. Зачувајте ја нејзината целосна вредност во скенирањето. За резимето на задачата, користете го стрингот директно; во спротивно, серијализирајте ја структурата со JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR. Ова е помалку декоративно од претпоставување недокументирани својства, но е сигурно.
<?php
// src/Message/AnalyzeWebsite.php
namespace App\Message;
final readonly class AnalyzeWebsite
{
public function __construct(
public string $scanId,
public string $url,
) {}
}
// src/MessageHandler/AnalyzeWebsiteHandler.php
namespace App\MessageHandler;
use App\Analyzer\AnalyzerException;
use App\Analyzer\WebsiteSecurityAnalyzer;
use App\Message\AnalyzeWebsite;
use App\Repository\SecurityScanRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class AnalyzeWebsiteHandler
{
public function __construct(
private WebsiteSecurityAnalyzer $analyzer,
private SecurityScanRepository $scans,
private LoggerInterface $logger,
) {}
public function __invoke(AnalyzeWebsite $message): void
{
$this->scans->markRunning($message->scanId);
try {
$result = $this->analyzer->analyze($message->url);
$this->scans->complete($message->scanId, $result);
$this->logger->info('Security analysis completed.', [
'scan_id' => $message->scanId,
'score' => $result->score,
]);
} catch (AnalyzerException $e) {
$this->scans->fail(
$message->scanId,
$e->failureCode,
$e->getMessage()
);
$this->logger->warning('Security analysis failed.', [
'scan_id' => $message->scanId,
'failure_code' => $e->failureCode,
]);
} catch (\Throwable $e) {
$this->scans->fail(
$message->scanId,
'internal',
'An internal processing error occurred.'
);
$this->logger->error('Security analysis crashed.', [
'scan_id' => $message->scanId,
'exception_class' => $e::class,
]);
}
}
}
Задржете го завршувањето на задачите локално во апликацијата на агенцијата. POST рута заштитена со CSRF може да го ажурира remediation_task.completed_at; никогаш не треба да активира друга надворешна анализа.
Однесување на контролерот и контролната табла
Рутата за создавање мора да одбие неисправен влез пред испраќањето. Барајте разумна ознака за клиент, https шема, име на хост и без вградени корисничко име, лозинка, низа на барање или фрагмент. Одбијте приватни или резервирани IP литерали. Складирајте нормализирана URL-адреса за тајните да не можат случајно да се појават во дневниците.
<?php
// Core create action in SecurityDashboardController
#[Route('/security/scans', name: 'security_scan_create', methods: ['POST'])]
public function create(
Request $request,
SecurityScanRepository $scans,
MessageBusInterface $bus,
): Response {
if (!$this->isCsrfTokenValid('create-scan', $request->request->getString('_token'))) {
throw $this->createAccessDeniedException();
}
$client = trim($request->request->getString('client'));
$rawUrl = trim($request->request->getString('url'));
$parts = parse_url($rawUrl);
$valid = strlen($client) >= 2
&& strlen($client) <= 120
&& filter_var($rawUrl, FILTER_VALIDATE_URL)
&& is_array($parts)
&& ($parts['scheme'] ?? null) === 'https'
&& isset($parts['host'])
&& !isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment']);
if (!$valid) {
$this->addFlash('error', 'Enter a public HTTPS URL and a valid client name.');
return $this->redirectToRoute('security_dashboard');
}
$host = strtolower($parts['host']);
if (filter_var($host, FILTER_VALIDATE_IP)
&& !filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
)) {
$this->addFlash('error', 'Private and reserved addresses are not allowed.');
return $this->redirectToRoute('security_dashboard');
}
$id = $scans->queue($client, $rawUrl);
$bus->dispatch(new AnalyzeWebsite($id, $rawUrl));
return $this->redirectToRoute('security_dashboard');
}
GET контролната табла треба да ги подредува скенирањата од најново кон најстаро. Секоја картичка ги прикажува клиентот, URL-адресата, состојбата на животниот циклус, резултатот кога е достапен, наодите под нивните вратени наслови за сериозност, TLS деталите и задачите за санација. Прикажувајте непознати вгнездени вредности преку Twig филтерот json_encode наместо да ги интерполирате како доверлив HTML. Задржете го автоматското избегнување знаци овозможено.
Не ги изложувајте овие рути јавно. Ставете ги зад постојната Symfony автентикација на агенцијата и правило access_control, наметнете HTTPS и ограничете ги барањата по агенција или автентициран корисник ако апликацијата е повеќезакупничка. CSRF заштитата ја дополнува автентикацијата; не ја заменува.
Автоматизирани гранични тестови со MockHttpClient
<?php
namespace App\Tests\Analyzer;
use App\Analyzer\AnalyzerException;
use App\Analyzer\WebsiteSecurityAnalyzer;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class WebsiteSecurityAnalyzerTest extends TestCase
{
public function testMapsAValidResponse(): void
{
$client = new MockHttpClient([
new MockResponse(json_encode([
'score' => 82,
'findings' => ['high' => [], 'medium' => [['check' => 'header']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review the reported header configuration.'],
], JSON_THROW_ON_ERROR), ['http_code' => 200]),
]);
$result = (new WebsiteSecurityAnalyzer($client, 'test-token'))
->analyze('https://example.com');
self::assertSame(82.0, $result->score);
self::assertArrayHasKey('medium', $result->findings);
self::assertSame(1, $client->getRequestsCount());
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$client = new MockHttpClient([
new MockResponse('', ['http_code' => 401]),
]);
try {
(new WebsiteSecurityAnalyzer($client, 'invalid'))
->analyze('https://example.com');
self::fail('Expected authentication failure.');
} catch (AnalyzerException $e) {
self::assertSame('authentication', $e->failureCode);
self::assertSame(1, $client->getRequestsCount());
}
}
}
Додајте интеграциски тестови за репозиториумот за трансакциско завршување и тестови за контролерот за CSRF, неважечки URL-адреси, авторизација и успешно испраќање пораки. Никогаш не ставајте вистински токен во фикстури: MockHttpClient го прави надворешниот сообраќај непотребен и го одржува тест-пакетот детерминистички.
Распоредување и операции
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console messenger:setup-transports
php bin/phpunit
php bin/console cache:clear --env=prod
php bin/console messenger:consume async \
--time-limit=3600 --memory-limit=128M --no-interaction
Извршувајте го потрошувачот под systemd, Supervisor или управуваната можност за работници на платформата и рестартирајте го по распоредувања за да вчита нов код. Распоредете ги миграциите пред да ја стартувате новата верзија на работникот. Конфигурирајте благ прекин и задржете повеќе од еден работник само кога претплатениот план и очекуваниот обем на скенирања ја дозволуваат добиената паралелност.
Евидентирајте идентификатори на скенирања, премини на животниот циклус, конечни кодови за неуспех, класи на статус и времетраење. Не евидентирајте токени, заглавија за авторизација, цели надворешни тела или URL-адреси што содржат чувствителни параметри. Корисни оперативни сигнали вклучуваат старост на редицата, скенирања заглавени во running, стапки на неуспеси authentication, rate_limited и invalid_response, како и рестартирања на работниците.
Вообичаени продукциски неуспеси
- Секое скенирање пријавува неуспех при автентикација: проверете ја активацијата на планот и распоредeната тајна. Ако токенот бил повторно генериран, претходната вредност е поништена.
- Скенирањата остануваат во редица: потврдете дека Messenger работникот работи со истата база на податоци и транспортна конфигурација како веб-процесот.
- Ограничувањата на стапката се повторуваат: намалете ја паралелноста на работниците или зачестеноста на скенирањата. Не ги зголемувајте повторните обиди неограничено.
- API успева, но мапирањето не успева: задржете ја структурираната состојба
invalid_responseи споредете ја официјалната документација со граничниот мапер. Не присилувајте тивко недостасувачки делови. - Историјата постои, но задачите не: проверете дека складирањето на резултатите и внесувањето задачи делат една трансакција и дека структурираните препораки успешно се серијализираат.
Конечна листа за проверка
- Токенот доаѓа од конфигурација поддржана од околински променливи и никогаш не се појавува во изворен код, фикстури или дневници.
- Валидно поднесување веднаш враќа запис за историја во редица.
- Работникот го менува тој запис во running, а потоа во completed или одредена состојба на неуспех.
- Завршените скенирања прикажуваат резултат, наоди групирани по сериозност, TLS детали и препораки.
- Препораките создаваат трајни, проверливи задачи за санација.
- Неуспесите при автентикација и валидација не се повторуваат; неуспесите поради стапка и сервер користат ограничено повлекување.
- Рутите на контролната табла и задачите бараат автентикација, авторизација, HTTPS и CSRF заштита.
- Тестовите поминуваат без контактирање на активната услуга.
Резултатот е повеќе од обвивка околу крајна точка. Тоа е скромен оперативен систем: историјата на клиентот ја прави промената видлива, дефанзивното мапирање ги одржува податоците од надворешниот извор чесни, а задачите за санација ја претвораат анализата во одговорна работа. Тоа е разликата меѓу прикажување безбедносни информации и градење контролна табла што агенцијата навистина може да ја користи.