Symfony sigurnosna nadzorna ploča: Analiza klijentske stranice i provedive mjere sanacije
Sigurnosno skeniranje postaje vrijedno tek kada netko može razumjeti što se promijenilo i što sljedeće treba popraviti. Za malu agenciju koja upravlja s nekoliko web-mjesta klijenata, jednokratan API odgovor nije dovoljan: koristan proizvod je trajna povijest skeniranja, jasno grupirani nalazi, TLS kontekst i zadaci za otklanjanje problema koji se mogu označiti kao dovršeni.
Ovaj vodič izrađuje taj proizvod pomoću PHP-a 8.3 i Symfonyja. Skeniranja se izvršavaju izvan ciklusa zahtjeva putem Messengera, rezultati se provjeravaju na API granici, a svaki ishod postaje eksplicitno stanje uspjeha ili neuspjeha. Analizator provodi ograničenu, neinvazivnu analizu javnog HTTPS-a i sigurnosnog položaja preglednika. Klijentima se ne smije predstavljati kao penetracijski test, procjena ranjivosti ili jamstvo sigurnosti.
Dobijte pristup i kopirajte servisni token
- Izradite račun na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako ga već imate.
- Otvorite stranicu usluge Website Security Analyzer.
- Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu.
Ova usluga zahtijeva autentifikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo oblik Bearer jer vjerodajnicu zadržava izvan URL-ova, povijesti preglednika i uobičajenih zapisnika pristupa. Ponovno generiranje servisnog tokena opoziva prethodno aktivni token, stoga rotacija tokena mora ažurirati svako produkcijsko okruženje koje ga koristi.
Potvrdite krajnju točku prije pisanja koda aplikacije
Točna operacija je POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Njezin JSON zahtjev sadrži 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"}'
Upotrijebite javno web-mjesto kojim ste ovlašteni upravljati. Nemojte vraćeni sadržaj umetati u kontrolu izvornog koda: nalazi mogu otkriti pojedinosti o sigurnosnom položaju klijenta.
Vjerodajnicu smjestite u lokalnu konfiguraciju koja nije predana u repozitorij. U produkciji istu varijablu umetnite putem upravitelja tajni hosting platforme:
# .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"
Arhitektura prikladna za malu agenciju
Preglednik šalje naziv klijenta i HTTPS URL. Kontroler stvara zapis skeniranja u redu čekanja i šalje njegov identifikator putem Messengera. Radnik poziva analizator, provjerava odgovor te atomski pohranjuje rezultat, nalaze grupirane prema ozbiljnosti, TLS pojedinosti, preporuke i lokalno generirane zadatke za otklanjanje problema.
Ova asinkrona granica je važna. Udaljena analiza i kontrolirani ponovni pokušaji ne bi smjeli držati zahtjev preglednika otvorenim. Kompromis je operativan: mora raditi barem jedan Messenger radnik, a nadzorna ploča prikazuje kratkotrajna stanja queued i running.
Važne datoteke projekta su:
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
Izradite Symfony aplikaciju
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
Izradite dvije tablice putem Doctrine migracije: security_scan čuva nepromjenjivi rezultat analizatora i stanje životnog ciklusa; remediation_task čuva provjerljiv rad izveden iz preporuka. Upotrijebite identifikatore nizova generirane s bin2hex(random_bytes(16)), JSON stupce za uzvodne strukture, vremenske oznake i indeks nad security_scan.status. Dodajte strani ključ iz svakog zadatka na njegovo skeniranje s kaskadnim brisanjem.
Ova shema namjerno pohranjuje provjereni odgovor umjesto da pretpostavlja trajni oblik pojedinačnih atributa nalaza ili TLS-a. API ugovor jamči koncepte najviše razine; obrambeno prikazivanje ugniježđenih vrijednosti štiti nadzornu ploču od nedokumentiranih strukturnih promjena.
Konfigurirajte ubrizgavanje ovisnosti i red čekanja
# 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 granica provodi vlastite kratke ponovne pokušaje, pa su Messenger ponovni pokušaji onemogućeni kako bi se izbjeglo neočekivano umnažanje poziva. Svaki iscrpljeni neuspjeh upisuje se u zapis skeniranja. Zaseban transport za neuspjele poruke i dalje je koristan za neuspjehe izvan uobičajene kontrole obrađivača, primjerice za prekinutog radnika.
Provjerite odgovor analizatora na granici
<?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);
}
}
Primijetite što ovaj preslikavač ne radi: ne izmišlja ugniježđena polja odgovora. Nalazi ostaju grupirani prema ključevima ozbiljnosti koje isporučuje usluga, dok TLS podaci i preporuke ostaju provjerene JSON strukture.
Izradite ograničen HTTP klijent svjestan neuspjeha
<?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.');
}
}
Ponovno se pokušavaju samo transportni neuspjesi, HTTP odgovori 429 i neuspjesi na strani poslužitelja. Neuspjesi autentifikacije i validacije zahtijevaju intervenciju, a ne ponavljanje. Odgode su ograničene, kao i neaktivnost veze i ukupno trajanje zahtjeva. Tijela odgovora i vjerodajnice nikada ne ulaze u poruke iznimki.
Pohranite povijest i izradite zadatke za otklanjanje problema
Repozitorij bi trebao izložiti pet usmjerenih operacija: queue(), markRunning(), complete(), fail() i history(). U metodi complete() upotrijebite Connection::transactional() za ažuriranje skeniranja i umetanje jednog zadatka po preporuci.
Preporuka može biti niz ili strukturirana JSON vrijednost. Sačuvajte njezinu potpunu vrijednost u skeniranju. Za sažetak zadatka upotrijebite niz izravno; u protivnom serializirajte strukturu s JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR. To je manje dekorativno od nagađanja o nedokumentiranim svojstvima, ali je pouzdano.
<?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,
]);
}
}
}
Završavanje zadataka zadržite lokalno u aplikaciji agencije. POST ruta zaštićena CSRF-om može ažurirati remediation_task.completed_at; nikada ne bi smjela pokrenuti drugu vanjsku analizu.
Ponašanje kontrolera i nadzorne ploče
Ruta za stvaranje mora odbiti neispravan unos prije slanja. Zahtijevajte smislen naziv klijenta, shemu https, naziv hosta i odsutnost ugrađenog korisničkog imena, lozinke, niza upita ili fragmenta. Odbijte privatne ili rezervirane IP literale. Pohranite normalizirani URL kako se tajne ne bi slučajno pojavile u zapisnicima.
<?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 nadzorna ploča trebala bi poredati skeniranja od najnovijeg prema najstarijem. Svaka kartica prikazuje klijenta, URL, stanje životnog ciklusa, rezultat kada je dostupan, nalaze pod vraćenim naslovima ozbiljnosti, TLS pojedinosti i zadatke za otklanjanje problema. Nepoznate ugniježđene vrijednosti prikažite putem Twig filtra json_encode, umjesto da ih interpolirate kao pouzdani HTML. Zadržite automatsko izbjegavanje znakova aktivnim.
Nemojte javno izlagati ove rute. Postavite ih iza postojeće Symfony autentifikacije agencije i pravila access_control, nametnite HTTPS i ograničite upite prema agenciji ili autentificiranom korisniku ako je aplikacija višezakupnička. CSRF zaštita nadopunjuje autentifikaciju; ne zamjenjuje je.
Automatizirani granični testovi s MockHttpClientom
<?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());
}
}
}
Dodajte integracijske testove repozitorija za transakcijsko dovršavanje i testove kontrolera za CSRF, neispravne URL-ove, autorizaciju i uspješno slanje poruke. Nikada ne stavljajte stvarni token u podatke testova: MockHttpClient čini vanjski promet nepotrebnim i održava skup testova determinističkim.
Implementacija i rad
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
Pokrenite potrošača pod systemd-om, Supervisorom ili upravljanom radničkom uslugom platforme te ga ponovno pokrenite nakon implementacija kako bi učitao novi kod. Implementirajte migracije prije pokretanja nove verzije radnika. Konfigurirajte graciozno zaustavljanje i zadržite više od jednog radnika samo kada pretplaćeni plan i očekivani volumen skeniranja dopuštaju tako nastalu konkurentnost.
Zapisujte identifikatore skeniranja, prijelaze životnog ciklusa, konačne kodove neuspjeha, klase statusa i trajanje. Nemojte zapisivati tokene, autorizacijska zaglavlja, potpuna uzvodna tijela ni URL-ove koji sadrže osjetljive parametre. Korisni operativni signali uključuju starost reda čekanja, skeniranja zaglavljena u stanju running, stope neuspjeha authentication, rate_limited i invalid_response, kao i ponovna pokretanja radnika.
Česti produkcijski neuspjesi
- Svako skeniranje prijavljuje neuspjeh autentifikacije: provjerite aktivaciju plana i implementiranu tajnu. Ako je token ponovno generiran, prethodna vrijednost je opozvana.
- Skeniranja ostaju u redu čekanja: potvrdite da Messenger radnik radi s istom bazom podataka i konfiguracijom transporta kao web-proces.
- Ograničenja brzine se ponavljaju: smanjite konkurentnost radnika ili učestalost skeniranja. Nemojte neograničeno povećavati broj ponovnih pokušaja.
- API uspijeva, ali preslikavanje ne uspijeva: zadržite strukturirano stanje
invalid_responsei usporedite službenu dokumentaciju s graničnim preslikavačem. Nemojte tiho prisilno pretvarati nedostajuće odjeljke. - Povijest postoji, ali zadaci ne: provjerite dijele li pohrana rezultata i umetanje zadataka jednu transakciju te serializiraju li se strukturirane preporuke uspješno.
Završni kontrolni popis za provjeru
- Token dolazi iz konfiguracije podržane varijablama okruženja i nikada se ne pojavljuje u izvornom kodu, podacima testova ni zapisnicima.
- Valjana prijava odmah vraća unos povijesti u redu čekanja.
- Radnik mijenja taj unos u running, a zatim u completed ili određeno stanje neuspjeha.
- Dovršena skeniranja prikazuju rezultat, nalaze grupirane prema ozbiljnosti, TLS pojedinosti i preporuke.
- Preporuke stvaraju trajne, provjerljive zadatke za otklanjanje problema.
- Neuspjesi autentifikacije i validacije ne pokušavaju se ponovno; neuspjesi zbog ograničenja brzine i poslužitelja koriste ograničeno odgađanje.
- Rute nadzorne ploče i zadataka zahtijevaju autentifikaciju, autorizaciju, HTTPS i CSRF zaštitu.
- Testovi prolaze bez povezivanja s aktivnom uslugom.
Rezultat je više od omotača oko krajnje točke. To je skroman operativni sustav: povijest klijenta čini promjenu vidljivom, obrambeno preslikavanje održava uzvodne podatke vjerodostojnima, a zadaci za otklanjanje problema pretvaraju analizu u odgovoran rad. To je razlika između prikazivanja sigurnosnih informacija i izgradnje nadzorne ploče koju agencija doista može koristiti.