Symfony безбедносна контролна табла: Закажете проверки на веб-страницата
Безбедносната контролна табла заслужува доверба само кога јасно одговара на две прашања: што не е во ред сега и кога доказите последен пат биле освежени? Еднократно скенирање не може да го направи тоа. На продукциските системи им треба повторлива проценка, зачувани резултати, разбирливи состојби на неуспех и строга одвоеност меѓу податоците на клиентите.
Ова упатство го гради тој систем со PHP 8.3, Symfony 7.4, PostgreSQL, Symfony HttpClient и Mihajlo Website Security Analyzer. Завршената апликација складира овластени веб-локации на клиенти, повторно ги проценува според распоред, ја зачувува историјата на проценките и ја прикажува најновата безбедносна состојба на веб-локацијата преку автентицирана контролна табла.
Анализаторот врши ограничена, неинвазивна анализа на јавниот HTTPS и безбедносната состојба во прелистувачот. Не смее да се опишува како пенетрационен тест, сертификација за ранливости или доказ дека веб-локацијата е безбедна.
Добијте пристап и копирајте го сервисниот токен
Завршете го поставувањето на пристапот пред да го создадете Symfony клиентот:
- Создадете сметка за AI-алатки или најавете се ако веќе имате таква.
- Отворете ја страницата на услугата Website Security Analyzer, скролувајте до плановите, изберете Free за почетно тестирање или поголем план за продукциска употреба и завршете ја активацијата.
- Отворете ја интерактивната API документација. Во панелот Service token, притиснете Copy за да го копирате токенот ограничен на услугата.
- Ставете го токенот во
.env.local. Никогаш не го предавајте во commit. Повторното генерирање токен го поништува претходниот активен токен, затоа ажурирајте ја распоредената тајна веднаш по ротацијата.
MIHAJLO_API_TOKEN=YOUR_SERVICE_TOKEN
Потврдете ги токенот и договорот за барањето директно пред да го поврзете со Symfony:
curl --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://example.com\"}"
Успешен JSON одговор потврдува дека планот е активен, токенот е валиден и до крајната точка може да се пристапи од машината каде што ќе работи проектот.
Прво разберете ја API границата
Официјалниот договор е документиран во API документацијата за Website Security Analyzer. Интеграцијата користи точно:
POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
Автентикацијата може да користи Bearer токен, заглавие X-API-Token или параметар за барање со токен. Оваа имплементација ја избира Bearer формата бидејќи ги држи акредитивите надвор од URL-адресите и дневниците за пристап. JSON барањето содржи едно поле, url. Одговорот се мапира во резултат, наоди групирани по сериозност, TLS детали и препораки.
Оддалечениот одговор е недоверлив влез. Дури и кога полињата се документирани, апликацијата ги проверува нивните типови пред нешто да стигне до перзистентноста или Twig.
Архитектура и компромиси
Прелистувачот никогаш не го повикува анализаторот. Закажана Symfony команда ја извршува бавната надворешна работа, додека контролерот чита локална снимка. Затоа клиентите добиваат предвидлива латентност на контролната табла дури и кога надворешниот API е бавен или привремено недостапен.
- Клиент на анализаторот: поседува автентикација, временски ограничувања, валидација на одговори и ограничени повторни обиди.
- Доменски резултат: спречува произволен JSON од надворешниот извор да протече низ апликацијата.
- Репозиториум: презема веб-локации што се доспеани со закуп во базата на податоци и складира снимки плус историја.
- Конзолна команда: повторно ги проценува веб-локациите надвор од обработката на барања.
- Контролер на контролната табла: наметнува сопственост на клиентот пред да прикаже резултат.
Messenger би бил разумен при многу поголем обем, но тука е непотребен. Закуп во базата на податоци и мала закажана серија обезбедуваат опоравување од пад и безбедно извршување со повеќе инстанци без воведување друг транспорт.
Создадете го Symfony проектот
Symfony 7.4 поддржува PHP 8.3. Doctrine DBAL 4 обезбедува експлицитна SQL и контрола на трансакции без да бара ORM ентитети за овој мал модел за перзистентност.
composer create-project symfony/skeleton:"7.4.*" security-dashboard
cd security-dashboard
composer require symfony/http-client:^7.4 symfony/console:^7.4 \
symfony/twig-bundle:^7.4 symfony/security-bundle:^7.4 \
doctrine/doctrine-bundle:^2.13 doctrine/dbal:^4.0
composer require --dev symfony/test-pack
Релевантната структура на проектот е намерно компактна:
src/
Command/ReassessWebsitesCommand.php
Controller/SecurityDashboardController.php
Security/AnalyzerClient.php
Security/AnalyzerException.php
Security/AssessmentResult.php
Security/AssessmentRepository.php
templates/security/dashboard.html.twig
tests/Security/AnalyzerClientTest.php
migrations/Version20260811000000.php
config/services.yaml
Конфигурирајте тајни и вбризгување зависности
Предадете ја крајната точка како обична конфигурација, но вбризгајте го токенот преку околината. Ставете го местодржачот во .env; користете .env.local, тајна за распоредување или менаџер за тајни за вистинската вредност.
MIHAJLO_ANALYZER_ENDPOINT=https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
MIHAJLO_API_TOKEN=YOUR_SERVICE_TOKEN
DATABASE_URL=postgresql://app:[email protected]:5432/security_dashboard
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Security\AnalyzerClient:
arguments:
$endpoint: '%env(string:MIHAJLO_ANALYZER_ENDPOINT)%'
$token: '%env(string:MIHAJLO_API_TOKEN)%'
Никогаш не ги евидентирајте токенот, заглавието за авторизација или целосниот одговор од надворешниот извор. URL-адресите исто така може да содржат чувствителни патеки или низи за барања, затоа оперативните дневници обично треба да ги идентификуваат само ID-то на веб-локацијата и името на домаќинот.
Изградете одбранбен доменски мапер
Маперот ги бара сите четири области на резултатот, притоа избегнувајќи претпоставки за поединечни објекти на наоди. Тоа ја зачувува компатибилноста со идни верзии во секоја група по сериозност без да прифати структурно неповрзан одговор.
<?php
// src/Security/AssessmentResult.php
namespace App\Security;
final readonly class AssessmentResult
{
public function __construct(
public int|float $score,
public array $findingsBySeverity,
public array $tlsDetails,
public array $recommendations,
) {}
public static function fromPayload(array $data): self
{
if (!isset($data['score']) || !is_int($data['score']) && !is_float($data['score'])) {
throw new \UnexpectedValueException('Analyzer score is missing or invalid.');
}
foreach (['findings', 'tls', 'recommendations'] as $field) {
if (!isset($data[$field]) || !is_array($data[$field])) {
throw new \UnexpectedValueException("Analyzer field {$field} is invalid.");
}
}
foreach ($data['findings'] as $severity => $findings) {
if (!is_string($severity) || !is_array($findings)) {
throw new \UnexpectedValueException('Findings are not grouped by severity.');
}
}
return new self(
$data['score'],
$data['findings'],
$data['tls'],
array_values($data['recommendations']),
);
}
}
Twig стандардно ги избегнува прикажаните низи. Оставете ја таа заштита вклучена: излезот од анализаторот никогаш не смее да се означи како безбеден или да се прикаже како суров HTML.
Имплементирајте отпорен HTTP клиент
Надворешните повици добиваат ограничени временски ограничувања за поврзување и за целокупниот одговор. Клиентот ги повторува транспортните неуспеси, HTTP 429 и грешките на серверот најмногу двапати по првиот обид. Автентикацијата, валидацијата и другите грешки на клиентот веднаш не успеваат бидејќи друго идентично барање нема да ги поправи.
<?php
// src/Security/AnalyzerException.php
namespace App\Security;
final class AnalyzerException extends \RuntimeException
{
public function __construct(
public readonly string $category,
public readonly bool $retryable,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
<?php
// src/Security/AnalyzerClient.php
namespace App\Security;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class AnalyzerClient
{
public function __construct(
private HttpClientInterface $http,
private LoggerInterface $logger,
private string $endpoint,
private string $token,
) {}
public function analyze(string $url): AssessmentResult
{
$host = parse_url($url, PHP_URL_HOST) ?: 'invalid-host';
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('POST', $this->endpoint, [
'headers' => [
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
],
'json' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 20.0,
]);
$status = $response->getStatusCode();
if ($status >= 200 && $status < 300) {
try {
$payload = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($payload)) {
throw new \UnexpectedValueException('Expected a JSON object.');
}
return AssessmentResult::fromPayload($payload);
} catch (\JsonException|\UnexpectedValueException $e) {
throw new AnalyzerException(
'response_contract',
false,
'The analyzer returned an invalid response.',
$e
);
}
}
$retryable = $status === 429 || $status >= 500;
$category = match (true) {
$status === 401 || $status === 403 => 'authentication',
$status === 400 || $status === 422 => 'invalid_target',
$status === 429 => 'rate_limited',
$status >= 500 => 'upstream',
default => 'http_error',
};
if (!$retryable || $attempt === 3) {
throw new AnalyzerException(
$category,
$retryable,
"Analyzer request failed with HTTP {$status}."
);
}
} catch (TransportExceptionInterface $e) {
if ($attempt === 3) {
throw new AnalyzerException(
'transport',
true,
'Analyzer transport failed.',
$e
);
}
}
$this->logger->warning('Website analysis will be retried.', [
'host' => $host,
'attempt' => $attempt,
]);
sleep(2 ** ($attempt - 1));
}
throw new \LogicException('Retry loop terminated unexpectedly.');
}
}
Продукциска варијанта може да почитува нумеричко заглавие Retry-After, но треба да го ограничи до конфигуриран максимум. Никогаш не дозволувајте заглавие од надворешен извор да суспендира работник на неодредено време.
Складирајте снимки, историја и состојба на распоредување
Следната PostgreSQL миграција ја одвојува тековната снимка на контролната табла од непроменливата историја на проценки. locked_until е обновливо преземање, а не трајно заклучување; паднат процес повторно станува подобен откако ќе истече закупот.
CREATE TABLE monitored_site (
id BIGSERIAL PRIMARY KEY,
client_id VARCHAR(180) NOT NULL,
url TEXT NOT NULL,
interval_minutes INTEGER NOT NULL CHECK (interval_minutes >= 15),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
next_assessment_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
locked_until TIMESTAMPTZ NULL,
last_status VARCHAR(32) NULL,
last_error_code VARCHAR(64) NULL,
latest_score DOUBLE PRECISION NULL,
latest_findings JSONB NULL,
latest_tls JSONB NULL,
latest_recommendations JSONB NULL,
latest_assessed_at TIMESTAMPTZ NULL,
UNIQUE (client_id, url)
);
CREATE INDEX monitored_site_due_idx
ON monitored_site (next_assessment_at)
WHERE enabled = TRUE;
CREATE TABLE security_assessment (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL REFERENCES monitored_site(id) ON DELETE CASCADE,
status VARCHAR(32) NOT NULL,
score DOUBLE PRECISION NULL,
findings JSONB NULL,
tls JSONB NULL,
recommendations JSONB NULL,
error_code VARCHAR(64) NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Репозиториумот презема еден запис во исто време. Тоа го одржува закупот валиден дури и кога претходно API барање го троши целиот свој буџет за временско ограничување.
<?php
// Selected methods from src/Security/AssessmentRepository.php
namespace App\Security;
use Doctrine\DBAL\Connection;
final readonly class AssessmentRepository
{
public function __construct(private Connection $db) {}
public function claimOne(): ?array
{
$row = $this->db->fetchAssociative(<<<'SQL'
WITH candidate AS (
SELECT id FROM monitored_site
WHERE enabled = TRUE
AND next_assessment_at <= CURRENT_TIMESTAMP
AND (locked_until IS NULL OR locked_until < CURRENT_TIMESTAMP)
ORDER BY next_assessment_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE monitored_site AS site
SET locked_until = CURRENT_TIMESTAMP + INTERVAL '2 minutes'
FROM candidate
WHERE site.id = candidate.id
RETURNING site.*
SQL);
return $row ?: null;
}
public function saveSuccess(array $site, AssessmentResult $result): void
{
$values = [
'id' => $site['id'],
'score' => $result->score,
'findings' => json_encode($result->findingsBySeverity, JSON_THROW_ON_ERROR),
'tls' => json_encode($result->tlsDetails, JSON_THROW_ON_ERROR),
'recommendations' => json_encode($result->recommendations, JSON_THROW_ON_ERROR),
];
$this->db->transactional(function (Connection $db) use ($values): void {
$db->executeStatement(<<<'SQL'
INSERT INTO security_assessment
(site_id, status, score, findings, tls, recommendations)
VALUES (:id, 'success', :score, :findings::jsonb, :tls::jsonb, :recommendations::jsonb)
SQL, $values);
$db->executeStatement(<<<'SQL'
UPDATE monitored_site
SET last_status = 'success', last_error_code = NULL,
latest_score = :score, latest_findings = :findings::jsonb,
latest_tls = :tls::jsonb, latest_recommendations = :recommendations::jsonb,
latest_assessed_at = CURRENT_TIMESTAMP, locked_until = NULL,
next_assessment_at =
CURRENT_TIMESTAMP + interval_minutes * INTERVAL '1 minute'
WHERE id = :id
SQL, $values);
});
}
public function saveFailure(array $site, AnalyzerException $e): void
{
$delay = $e->retryable ? 15 : (int) $site['interval_minutes'];
$this->db->executeStatement(<<<'SQL'
UPDATE monitored_site
SET last_status = 'failed', last_error_code = :error,
locked_until = NULL,
next_assessment_at = CURRENT_TIMESTAMP + :delay * INTERVAL '1 minute'
WHERE id = :id
SQL, ['id' => $site['id'], 'error' => $e->category, 'delay' => $delay]);
}
public function dashboard(int $siteId, string $clientId): ?array
{
$row = $this->db->fetchAssociative(
'SELECT * FROM monitored_site WHERE id = :id AND client_id = :client',
['id' => $siteId, 'client' => $clientId]
);
if (!$row) {
return null;
}
foreach (['latest_findings', 'latest_tls', 'latest_recommendations'] as $field) {
$row[$field] = $row[$field] === null
? []
: json_decode($row[$field], true, 512, JSON_THROW_ON_ERROR);
}
return $row;
}
}
Извршувајте повторна проценка како закажана команда
<?php
// src/Command/ReassessWebsitesCommand.php
namespace App\Command;
use App\Security\AnalyzerClient;
use App\Security\AnalyzerException;
use App\Security\AssessmentRepository;
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\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:security:reassess')]
final class ReassessWebsitesCommand extends Command
{
public function __construct(
private readonly AssessmentRepository $repository,
private readonly AnalyzerClient $analyzer,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum sites', '20');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$limit = max(1, min(100, (int) $input->getOption('limit')));
for ($processed = 0; $processed < $limit; $processed++) {
$site = $this->repository->claimOne();
if ($site === null) {
break;
}
try {
$result = $this->analyzer->analyze($site['url']);
$this->repository->saveSuccess($site, $result);
} catch (AnalyzerException $e) {
$this->repository->saveFailure($site, $e);
$this->logger->error('Website assessment failed.', [
'site_id' => $site['id'],
'category' => $e->category,
'retryable' => $e->retryable,
]);
}
}
return Command::SUCCESS;
}
}
Извршувајте ја командата на секои пет минути; next_assessment_at на секој ред ја контролира неговата вистинска динамика. На традиционален хост:
*/5 * * * * cd /srv/security-dashboard && php bin/console app:security:reassess --limit=20 --env=prod
Изложете контролна табла за клиенти безбедна во однос на сопственоста
Барањето до базата на податоци ги вклучува и ID-то на веб-локацијата и ID-то на автентицираниот клиент. Враќањето 404 при несовпаѓање спречува откривање дали постои веб-локација на друг клиент.
<?php
// src/Controller/SecurityDashboardController.php
namespace App\Controller;
use App\Security\AssessmentRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_CLIENT')]
final class SecurityDashboardController extends AbstractController
{
#[Route('/security-health/{siteId}', name: 'security_dashboard', methods: ['GET'])]
public function __invoke(int $siteId, AssessmentRepository $repository): Response
{
$user = $this->getUser();
$site = $repository->dashboard($siteId, $user->getUserIdentifier());
if ($site === null) {
throw $this->createNotFoundException();
}
return $this->render('security/dashboard.html.twig', ['site' => $site]);
}
}
<h2>Website security health</h2>
<p>{{ site.url }}</p>
<p>Status: {{ site.last_status ?? 'Not assessed' }}</p>
{% if site.latest_assessed_at %}
<p>Last assessed: {{ site.latest_assessed_at }}</p>
<p>Score: {{ site.latest_score }}</p>
<h3>Findings</h3>
{% for severity, findings in site.latest_findings %}
<h3>{{ severity|title }}</h3>
<pre>{{ findings|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
{% endfor %}
<h3>TLS details</h3>
<pre>{{ site.latest_tls|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
<h3>Recommendations</h3>
<pre>{{ site.latest_recommendations|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
{% endif %}
<p>This is a bounded, non-invasive review of public HTTPS and browser
security posture. It is not a penetration test or security certification.</p>
Тестирајте без да ја контактирате услугата
MockHttpClient ја прави границата детерминистичка. Покријте успех, невалиден JSON, неуспех на автентикација без повторен обид, ограничување на стапката со ограничени повторни обиди и транспортен неуспех. Тестовите за преземање во репозиториумот треба да работат со PostgreSQL бидејќи SQLite не може да ги репродуцира SKIP LOCKED или PostgreSQL JSON однесувањето.
<?php
// tests/Security/AnalyzerClientTest.php
namespace App\Tests\Security;
use App\Security\AnalyzerClient;
use App\Security\AnalyzerException;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class AnalyzerClientTest extends TestCase
{
public function testMapsAValidAssessment(): void
{
$http = new MockHttpClient(new MockResponse(json_encode([
'score' => 82,
'findings' => ['high' => [], 'medium' => [['name' => 'Example']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review the medium-severity finding.'],
], JSON_THROW_ON_ERROR)));
$client = new AnalyzerClient(
$http,
new NullLogger(),
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
'test-token'
);
$result = $client->analyze('https://example.com');
self::assertSame(82, $result->score);
self::assertArrayHasKey('medium', $result->findingsBySeverity);
}
public function testAuthenticationFailureIsNotRetried(): void
{
$calls = 0;
$http = new MockHttpClient(function () use (&$calls): MockResponse {
$calls++;
return new MockResponse('{}', ['http_code' => 401]);
});
$client = new AnalyzerClient(
$http,
new NullLogger(),
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
'bad-token'
);
try {
$client->analyze('https://example.com');
self::fail('Expected AnalyzerException.');
} catch (AnalyzerException $e) {
self::assertSame('authentication', $e->category);
self::assertFalse($e->retryable);
self::assertSame(1, $calls);
}
}
}
Продукциски заштити и вообичаени неуспеси
Прифаќајте само нормализирани https URL-адреси за веб-локации што клиентот е овластен да ги следи. Потврдувањето на сопственоста на доменот е разумно барање при запишување. Одбијте вградени акредитиви, фрагменти, невалидни домаќини и неподдржани шеми пред перзистентност. Иако вашиот сервер ја испраќа URL-адресата до ограничен оддалечен анализатор наместо директно да ја презема, контролите при запишување сè уште спречуваат злоупотреба.
- 401 или 403: проверете ја вбризганата тајна и формата за автентикација; не обидувајте се повторно наслепо.
- 400 или 422: означете ја целта како невалидна и побарајте исправка.
- 429: задржете ја последната успешна снимка, прикажете застарено време и закажете одложен повторен обид.
- 5xx или истек на време: користете ограничено повлекување и зачувајте ја структурираната категорија на неуспех.
- Неуспех на договорот: алармирајте ги операторите; никогаш не заменувајте фабрикувани стандардни вредности за недостасувачки безбедносни податоци.
Следете ги бројот на успеси и неуспеси, староста на проценките, латентноста, бројот на повторни обиди и бројот на задоцнети веб-локации. Алармирајте за растечки заостаток и повторени неуспеси на автентикацијата. Чувајте историски резултати според декларирана политика за задржување и ограничете го пристапот до дневниците и базата на податоци бидејќи наодите може да откриваат одбранбени слабости.
Конечна контролна листа за верификација
- Извршете ја миграцијата и потврдете дека апликацијата може да стигне до PostgreSQL.
- Вбризгајте валиден сервисен токен преку механизмот за продукциски тајни.
- Запишете овластена јавна HTTPS веб-локација со ID на клиент и интервал на проценка.
- Извршете
php bin/console app:security:reassess --limit=1 --env=prod. - Потврдете успешен ред во историјата и ажурирана снимка на контролната табла.
- Најавете се како клиентот сопственик и проверете ги резултатот, наодите, TLS деталите, препораките и времето на проценка.
- Потврдете дека друг клиент добива 404 за истото ID на веб-локацијата.
- Симулирајте одговори 401, 429, невалиден JSON и истек на време во тестовите.
- Овозможете го распоредувачот и алармирајте кога проценките ќе станат задоцнети.
Најсилната карактеристика на оваа контролна табла не е нејзиниот резултат. Тоа е дисциплинираниот синџир зад тој резултат: овластена цел, ограничена проценка, валидиран договор, ревизорска снимка и познато време на освежување. Тој синџир претвора оддалечен безбедносен сигнал во нешто што клиентите можат да го разберат — и на кое операторите можат да му веруваат.